History

Show local app source shape in directory picker

Rhys Sullivan committed 4 months ago

Commit 1ef67be1 parent 07d4d4f9 files · +336 −24Browse files

Unified · Split

9 files
  1. e2e/local/custom-tools-directory-picker.test.ts +7 −0
  2. packages/plugins/apps/src/pipeline/discover.ts +12 −3
  3. packages/plugins/apps/src/plugin/routes.ts +6 −0
  4. packages/plugins/apps/src/react/AddCustomToolsSource.tsx +70 −12
  5. packages/plugins/apps/src/react/custom-tools-client.ts +8 −0
  6. packages/plugins/apps/src/react/custom-tools-client.web.test.ts +69 −7
  7. packages/plugins/apps/src/react/source-panel-model.ts +41 −0
  8. packages/plugins/apps/src/source/app-source.test.ts +49 −0
  9. packages/plugins/apps/src/source/local-directory-source.ts +74 −2
e2e/local/custom-tools-directory-picker.test.ts+7−0
@@ -68,7 +68,14 @@ export default defineTool({ · 67 unmodified lines
6868 await pathInput.fill(fixtureRoot);
6969 await page.getByRole("button", { name: "Browse" }).click();
7070 const dialog = page.getByRole("dialog", { name: "Choose directory" });
71+ await dialog
72+ .getByRole("button", { name: /picked-tools/ })
73+ .locator("span")
74+ .filter({ hasText: "tools" })
75+ .waitFor({ timeout: 30_000 });
7176 await dialog.getByRole("button", { name: "picked-tools" }).click();
77+ await dialog.getByText("1 tool found:").waitFor({ timeout: 30_000 });
78+ await dialog.getByText("echo", { exact: true }).waitFor({ timeout: 30_000 });
7279 await dialog.getByRole("button", { name: "Select" }).click();
7380 expect(await pathInput.inputValue()).toBe(fixtureDir);
7481 });
packages/plugins/apps/src/pipeline/discover.ts+12−3
@@ -29,6 +29,15 @@ export interface DiscoverResult { · 28 unmodified lines
2929 const TOOL_RE = /^tools\/([a-z0-9][a-z0-9-]*)\.(ts|tsx|js|jsx)$/;
3030 const DEFERRED_RE = /^(workflows|ui|skills)\//;
3131
32+/** Reserved top-level app source folders whose contents are deferred by discovery. */
33+export const RESERVED_ARTIFACT_DIRS = ["workflows", "ui", "skills"] as const;
34+
35+/** Returns the discovered tool key for a path when it matches the canonical tool layout. */
36+export const toolKeyFromPath = (path: string): string | null => path.match(TOOL_RE)?.[1] ?? null;
37+
38+/** Returns true when a path is under a reserved app source folder. */
39+export const isReservedArtifactPath = (path: string): boolean => DEFERRED_RE.test(path);
40+
3241 export const validToolKey = (key: string): boolean => /^[a-z0-9][a-z0-9-]*$/.test(key);
3342
3443 export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | PublishError => {
@@ -39,9 +48,9 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 4 unmodified lines
3948
4049 for (const [path] of files) {
4150 if (path === "executor.json") continue;
42− const tool = path.match(TOOL_RE);
51+ const tool = toolKeyFromPath(path);
4352 if (tool) {
44− const name = tool[1]!;
53+ const name = tool;
4554 if (seen.has(name)) {
4655 diagnostics.push({ path, message: `duplicate tool identity: ${name}` });
4756 } else {
@@ -54,7 +63,7 @@ export const discover = (files: ReadonlyMap<string, string>): DiscoverResult | P · 6 unmodified lines
5463 diagnostics.push({ path, message: "file does not match the expected layout for tools/" });
5564 continue;
5665 }
57− if (DEFERRED_RE.test(path)) skipped.push({ path, reason: "not supported yet" });
66+ if (isReservedArtifactPath(path)) skipped.push({ path, reason: "not supported yet" });
5867 }
5968
6069 if (diagnostics.length > 0) {
packages/plugins/apps/src/plugin/routes.ts+6−0
@@ -79,8 +79,14 @@ const ListDirsResponse = Schema.Struct({ · 78 unmodified lines
7979 name: Schema.String,
8080 path: Schema.String,
8181 isSymlink: Schema.Boolean,
82+ hasTools: Schema.Boolean,
8283 }),
8384 ),
85+ source: Schema.Struct({
86+ toolFiles: Schema.Array(Schema.String),
87+ skipped: Schema.Array(Schema.String),
88+ hasPackageJson: Schema.Boolean,
89+ }),
8490 });
8591
8692 const DomainErrors = [InternalError] as const;
packages/plugins/apps/src/react/AddCustomToolsSource.tsx+70−12
@@ -34,7 +34,7 @@ import { · 33 unmodified lines
3434 type CustomToolsDirectoryListing,
3535 type AppSourceKind,
3636 } from "./custom-tools-client";
37−import { directoryBrowserRows } from "./source-panel-model";
37+import { directoryBrowserRows, directorySourceVerdict } from "./source-panel-model";
3838
3939 export default function AddCustomToolsSource(props: {
4040 readonly onComplete: (slug?: string) => void;
@@ -65,6 +65,7 @@ export default function AddCustomToolsSource(props: { · 24 unmodified lines
6565 const [syncing, setSyncing] = useState(false);
6666 const [syncError, setSyncError] = useState<string | null>(null);
6767 const [browseOpen, setBrowseOpen] = useState(false);
68+ const [pathListing, setPathListing] = useState<CustomToolsDirectoryListing | null>(null);
6869
6970 const sourceValue = kind === "git" ? url : path;
7071 const effectiveName = useMemo(
@@ -74,6 +75,26 @@ export default function AddCustomToolsSource(props: { · 3 unmodified lines
7475 const slug = effectiveName;
7576 const slugAlreadyExists = useSlugAlreadyExists(slug);
7677
78+ useEffect(() => {
79+ if (kind !== "local-directory" || !path.trim().startsWith("/")) {
80+ setPathListing(null);
81+ return;
82+ }
83+ let active = true;
84+ const timeout = window.setTimeout(() => {
85+ void Effect.runPromiseExit(listCustomToolDirectoriesEffect({ path: path.trim() })).then(
86+ (exit) => {
87+ if (!active) return;
88+ setPathListing(Exit.isSuccess(exit) ? exit.value : null);
89+ },
90+ );
91+ }, 300);
92+ return () => {
93+ active = false;
94+ window.clearTimeout(timeout);
95+ };
96+ }, [kind, path]);
97+
7798 const submit = async () => {
7899 const nextFieldError =
79100 kind === "git"
@@ -226,6 +247,14 @@ export default function AddCustomToolsSource(props: { · 146 unmodified lines
226247 setFieldError(null);
227248 setSyncError(null);
228249 }}
250+ onBlur={() => {
251+ if (!path.trim().startsWith("/")) return;
252+ void Effect.runPromiseExit(
253+ listCustomToolDirectoriesEffect({ path: path.trim() }),
254+ ).then((exit) => {
255+ setPathListing(Exit.isSuccess(exit) ? exit.value : null);
256+ });
257+ }}
229258 placeholder="/Users/me/tools"
230259 className="font-mono text-sm"
231260 aria-invalid={fieldError ? true : undefined}
@@ -239,6 +268,7 @@ export default function AddCustomToolsSource(props: { · 7 unmodified lines
239268 Browse
240269 </Button>
241270 </div>
271+ {pathListing && <DirectorySourceStatus listing={pathListing} />}
242272 {fieldError && <p className="text-xs text-destructive">{fieldError}</p>}
243273 </div>
244274 </CardStackEntryField>
@@ -332,6 +362,8 @@ function DirectoryBrowserModal(props: { · 87 unmodified lines
332362
333363 const rows = listing ? directoryBrowserRows(listing) : [];
334364 const selectedPath = listing?.path ?? currentPath ?? "";
365+ const canSelect = Boolean(selectedPath) && !loading && !error;
366+ const hasToolFiles = (listing?.source.toolFiles.length ?? 0) > 0;
335367
336368 return (
337369 <Dialog open onOpenChange={(open) => (open ? undefined : props.onClose())}>
@@ -341,6 +373,7 @@ function DirectoryBrowserModal(props: { · 3 unmodified lines
341373 <DialogDescription className="font-mono text-xs break-all">
342374 {selectedPath || "Home"}
343375 </DialogDescription>
376+ {listing && <DirectorySourceStatus listing={listing} />}
344377 </DialogHeader>
345378 <div className="space-y-3">
346379 <Label className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -372,11 +405,16 @@ function DirectoryBrowserModal(props: { · 25 unmodified lines
372405 <span className="min-w-0 truncate font-mono">
373406 {row.kind === "parent" ? ".." : row.name}
374407 </span>
375− {row.kind === "dir" && row.isSymlink && (
376− <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
377− symlink
378− </span>
379− )}
408+ <span className="flex shrink-0 items-center gap-2">
409+ {row.kind === "dir" && row.hasTools && (
410+ <span className="rounded-sm border border-border bg-secondary px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
411+ tools
412+ </span>
413+ )}
414+ {row.kind === "dir" && row.isSymlink && (
415+ <span className="font-mono text-[11px] text-muted-foreground">symlink</span>
416+ )}
417+ </span>
380418 </Button>
381419 ))}
382420 </div>
@@ -387,15 +425,35 @@ function DirectoryBrowserModal(props: { · 4 unmodified lines
387425 <Button type="button" variant="ghost" onClick={props.onClose}>
388426 Cancel
389427 </Button>
390− <Button
391− type="button"
392− onClick={() => props.onSelect(selectedPath)}
393− disabled={!selectedPath || loading || Boolean(error)}
394− >
395− Select
428+ <Button type="button" onClick={() => props.onSelect(selectedPath)} disabled={!canSelect}>
429+ {hasToolFiles ? "Select" : "Select anyway"}
396430 </Button>
397431 </DialogFooter>
398432 </DialogContent>
399433 </Dialog>
400434 );
401435 }
436+
437+function DirectorySourceStatus(props: { readonly listing: CustomToolsDirectoryListing }) {
438+ const verdict = directorySourceVerdict(props.listing);
439+ if (verdict.type === "valid") {
440+ return (
441+ <p className="mt-2 text-xs text-foreground">
442+ {verdict.message}:{" "}
443+ {verdict.visibleTools.map((tool, index) => (
444+ <span key={tool}>
445+ {index > 0 ? ", " : ""}
446+ <span className="font-mono">{tool}</span>
447+ </span>
448+ ))}
449+ {verdict.moreCount > 0 && (
450+ <>
451+ {", "}
452+ <span className="font-mono">+{verdict.moreCount} more</span>
453+ </>
454+ )}
455+ </p>
456+ );
457+ }
458+ return <p className="mt-2 text-xs text-muted-foreground">{verdict.message}</p>;
459+}
packages/plugins/apps/src/react/custom-tools-client.ts+8−0
@@ -119,12 +119,20 @@ export interface CustomToolsDirectoryEntry { · 118 unmodified lines
119119 readonly name: string;
120120 readonly path: string;
121121 readonly isSymlink: boolean;
122+ readonly hasTools: boolean;
123+}
124+
125+export interface CustomToolsDirectorySourceShape {
126+ readonly toolFiles: readonly string[];
127+ readonly skipped: readonly string[];
128+ readonly hasPackageJson: boolean;
122129 }
123130
124131 export interface CustomToolsDirectoryListing {
125132 readonly path: string;
126133 readonly parent: string | null;
127134 readonly dirs: readonly CustomToolsDirectoryEntry[];
135+ readonly source: CustomToolsDirectorySourceShape;
128136 }
129137
130138 export class CustomToolsClientError extends Data.TaggedError("CustomToolsClientError")<{
packages/plugins/apps/src/react/custom-tools-client.web.test.ts+69−7
@@ -17,7 +17,11 @@ import appsClientPlugin, { · 16 unmodified lines
1717 type AppSourceRecord,
1818 type CustomToolsFetch,
1919 } from "./plugin-client";
20−import { sourcePanelModel, syncNoticeFromResult } from "./source-panel-model";
20+import {
21+ directorySourceVerdict,
22+ sourcePanelModel,
23+ syncNoticeFromResult,
24+} from "./source-panel-model";
2125 import { directoryBrowserRows } from "./source-panel-model";
2226
2327 const jsonResponse = (body: unknown, init?: ResponseInit): Response =>
@@ -105,7 +109,10 @@ describe("custom tools console client", () => { · 81 unmodified lines
105109 return jsonResponse({
106110 path: "/Users/ada/tools",
107111 parent: "/Users/ada",
108− dirs: [{ name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false }],
112+ dirs: [
113+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true },
114+ ],
115+ source: { toolFiles: ["greet.ts"], skipped: [], hasPackageJson: true },
109116 });
110117 }
111118 return jsonResponse({ error: "not found" }, { status: 404 });
@@ -136,7 +143,7 @@ describe("custom tools console client", () => { · 24 unmodified lines
136143 expect(syncStatusLabel(synced)).toBe("Already up to date.");
137144 expect(removed).toEqual({ removed: true });
138145 expect(dirs.dirs).toEqual([
139− { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
146+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: true },
140147 ]);
141148 expect(calls.map((call) => `${call.init?.method ?? "GET"} ${call.url}`)).toEqual([
142149 "POST /api/apps/sources",
@@ -176,17 +183,72 @@ describe("custom tools console client", () => { · 33 unmodified lines
176183 path: "/Users/ada/tools",
177184 parent: "/Users/ada",
178185 dirs: [
179− { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
180− { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true },
186+ { name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false, hasTools: false },
187+ { name: "linked", path: "/Users/ada/tools/linked", isSymlink: true, hasTools: false },
181188 ],
189+ source: { toolFiles: [], skipped: [], hasPackageJson: false },
182190 }),
183191 ).toEqual([
184192 { kind: "parent", name: "..", path: "/Users/ada" },
185− { kind: "dir", name: "alpha", path: "/Users/ada/tools/alpha", isSymlink: false },
186− { kind: "dir", name: "linked", path: "/Users/ada/tools/linked", isSymlink: true },
193+ {
194+ kind: "dir",
195+ name: "alpha",
196+ path: "/Users/ada/tools/alpha",
197+ isSymlink: false,
198+ hasTools: false,
199+ },
200+ {
201+ kind: "dir",
202+ name: "linked",
203+ path: "/Users/ada/tools/linked",
204+ isSymlink: true,
205+ hasTools: false,
206+ },
187207 ]);
188208 });
189209
210+ it("models directory source verdicts", () => {
211+ expect(
212+ directorySourceVerdict({
213+ path: "/repo",
214+ parent: null,
215+ dirs: [{ name: "tools", path: "/repo/tools", isSymlink: false, hasTools: false }],
216+ source: { toolFiles: [], skipped: [], hasPackageJson: false },
217+ }),
218+ ).toEqual({
219+ type: "empty-tools",
220+ message: "tools/ has no tool files (tools/<name>.ts)",
221+ });
222+ expect(
223+ directorySourceVerdict({
224+ path: "/repo",
225+ parent: null,
226+ dirs: [],
227+ source: { toolFiles: [], skipped: [], hasPackageJson: false },
228+ }),
229+ ).toEqual({
230+ type: "missing-tools",
231+ message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.",
232+ });
233+ expect(
234+ directorySourceVerdict({
235+ path: "/repo",
236+ parent: null,
237+ dirs: [],
238+ source: {
239+ toolFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"],
240+ skipped: [],
241+ hasPackageJson: true,
242+ },
243+ }),
244+ ).toEqual({
245+ type: "valid",
246+ message: "6 tools found",
247+ visibleTools: ["a", "b", "c", "d", "e"],
248+ moreCount: 1,
249+ });
250+ });
251+
190252 it("formats failed sync diagnostics by stage", () => {
191253 const errors = formatSyncErrors({
192254 status: "failed",
packages/plugins/apps/src/react/source-panel-model.ts+41−0
@@ -34,8 +34,19 @@ export type DirectoryBrowserRow = · 33 unmodified lines
3434 readonly name: string;
3535 readonly path: string;
3636 readonly isSymlink: boolean;
37+ readonly hasTools: boolean;
3738 };
3839
40+export type DirectorySourceVerdict =
41+ | {
42+ readonly type: "valid";
43+ readonly message: string;
44+ readonly visibleTools: readonly string[];
45+ readonly moreCount: number;
46+ }
47+ | { readonly type: "empty-tools"; readonly message: string }
48+ | { readonly type: "missing-tools"; readonly message: string };
49+
3950 const shortRef = (ref: string | undefined): string => (ref ? ref.slice(0, 12) : "Not synced");
4051
4152 export const formatRelativeSyncTime = (timestamp: number, now = Date.now()): string => {
@@ -111,5 +122,35 @@ export const directoryBrowserRows = ( · 69 unmodified lines
111122 name: dir.name,
112123 path: dir.path,
113124 isSymlink: dir.isSymlink,
125+ hasTools: dir.hasTools,
114126 })),
115127 ];
128+
129+const toolNameFromFile = (file: string): string => file.replace(/\.[^.]+$/, "");
130+
131+export const directorySourceVerdict = (
132+ listing: CustomToolsDirectoryListing,
133+): DirectorySourceVerdict => {
134+ const source = listing.source;
135+ if (source.toolFiles.length > 0) {
136+ const toolNames = source.toolFiles.map(toolNameFromFile);
137+ const visibleTools = toolNames.slice(0, 5);
138+ const moreCount = Math.max(0, toolNames.length - visibleTools.length);
139+ return {
140+ type: "valid",
141+ message: `${toolsCountLabel(source.toolFiles.length)} found`,
142+ visibleTools,
143+ moreCount,
144+ };
145+ }
146+ if (listing.dirs.some((dir) => dir.name === "tools" && !dir.isSymlink)) {
147+ return {
148+ type: "empty-tools",
149+ message: "tools/ has no tool files (tools/<name>.ts)",
150+ };
151+ }
152+ return {
153+ type: "missing-tools",
154+ message: "No tools/ folder. Pick a folder containing tools/<name>.ts files.",
155+ };
156+};
packages/plugins/apps/src/source/app-source.test.ts+49−0
@@ -486,6 +486,54 @@ describe("local-directory app sources", () => { · 485 unmodified lines
486486 expect(hidden.dirs.map((entry) => entry.name)).toEqual([".hidden", "alpha"]);
487487 });
488488
489+ it("reports app source shape and subdirectories with tools folders", async () => {
490+ const root = await mkdtemp();
491+ const valid = join(root, "valid-app");
492+ const emptyTools = join(root, "empty-tools");
493+ const noTools = join(root, "no-tools");
494+ const nested = join(root, "parent-with-tools");
495+ await mkdir(join(valid, "tools"), { recursive: true });
496+ await mkdir(join(valid, "workflows"), { recursive: true });
497+ await mkdir(join(valid, "ui"), { recursive: true });
498+ await mkdir(join(valid, "skills"), { recursive: true });
499+ await mkdir(join(emptyTools, "tools"), { recursive: true });
500+ await mkdir(noTools, { recursive: true });
501+ await mkdir(join(nested, "tools"), { recursive: true });
502+ await writeFile(join(valid, "package.json"), "{}");
503+ await writeFile(join(valid, "tools", "a.ts"), "export default {};");
504+ await writeFile(join(valid, "tools", "b.tsx"), "export default {};");
505+ await writeFile(join(valid, "tools", "bad_name.ts"), "export default {};");
506+
507+ const parentListing = await run(listLocalDirectoryDirs({ path: root }));
508+ const validListing = await run(listLocalDirectoryDirs({ path: valid }));
509+ const emptyListing = await run(listLocalDirectoryDirs({ path: emptyTools }));
510+ const noToolsListing = await run(listLocalDirectoryDirs({ path: noTools }));
511+
512+ expect(parentListing.dirs).toEqual(
513+ expect.arrayContaining([
514+ expect.objectContaining({ name: "valid-app", hasTools: true }),
515+ expect.objectContaining({ name: "empty-tools", hasTools: true }),
516+ expect.objectContaining({ name: "no-tools", hasTools: false }),
517+ expect.objectContaining({ name: "parent-with-tools", hasTools: true }),
518+ ]),
519+ );
520+ expect(validListing.source).toEqual({
521+ toolFiles: ["a.ts", "b.tsx"],
522+ skipped: ["workflows", "ui", "skills"],
523+ hasPackageJson: true,
524+ });
525+ expect(emptyListing.source).toEqual({
526+ toolFiles: [],
527+ skipped: [],
528+ hasPackageJson: false,
529+ });
530+ expect(noToolsListing.source).toEqual({
531+ toolFiles: [],
532+ skipped: [],
533+ hasPackageJson: false,
534+ });
535+ });
536+
489537 it("rejects parent traversal in directory listings", async () => {
490538 const exit = await Effect.runPromiseExit(listLocalDirectoryDirs({ path: "/tmp/../bad" }));
491539 expect(Exit.isFailure(exit)).toBe(true);
@@ -502,6 +550,7 @@ describe("local-directory app sources", () => { · 10 unmodified lines
502550 name: "linked",
503551 path: join(root, "linked"),
504552 isSymlink: true,
553+ hasTools: false,
505554 });
506555
507556 const followed = await Effect.runPromiseExit(
packages/plugins/apps/src/source/local-directory-source.ts+74−2
@@ -7,6 +7,7 @@ import { dirname, isAbsolute, resolve, sep } from "node:path"; · 6 unmodified lines
77
88 import { Effect, Schema } from "effect";
99
10+import { RESERVED_ARTIFACT_DIRS, toolKeyFromPath } from "../pipeline/discover";
1011 import { PublishError, enforcePublishLimits, type PublishFile } from "../pipeline/publish";
1112 import { AppSourceError, type AppSourceSnapshot } from "./app-source";
1213 import {
@@ -33,12 +34,20 @@ export interface LocalDirectoryDirEntry { · 20 unmodified lines
3334 readonly name: string;
3435 readonly path: string;
3536 readonly isSymlink: boolean;
37+ readonly hasTools: boolean;
38+}
39+
40+export interface LocalDirectorySourceShape {
41+ readonly toolFiles: readonly string[];
42+ readonly skipped: readonly string[];
43+ readonly hasPackageJson: boolean;
3644 }
3745
3846 export interface LocalDirectoryDirsResult {
3947 readonly path: string;
4048 readonly parent: string | null;
4149 readonly dirs: readonly LocalDirectoryDirEntry[];
50+ readonly source: LocalDirectorySourceShape;
4251 }
4352
4453 const sha256 = (bytes: Uint8Array | string): string =>
@@ -193,6 +202,60 @@ const descriptionFor = ( · 148 unmodified lines
193202 return typeof description === "string" ? description : undefined;
194203 });
195204
205+const lstatOrNull = (path: string): Effect.Effect<Awaited<ReturnType<typeof lstat>> | null> =>
206+ Effect.tryPromise({
207+ try: () => lstat(path),
208+ catch: (cause) =>
209+ new AppSourceError({ message: "failed to stat local-directory path", path, cause }),
210+ }).pipe(Effect.catch(() => Effect.succeed(null)));
211+
212+const hasNonSymlinkDirectory = (path: string): Effect.Effect<boolean> =>
213+ Effect.gen(function* () {
214+ const entry = yield* lstatOrNull(path);
215+ return entry !== null && entry.isDirectory() && !entry.isSymbolicLink();
216+ });
217+
218+const hasNonSymlinkFile = (path: string): Effect.Effect<boolean> =>
219+ Effect.gen(function* () {
220+ const entry = yield* lstatOrNull(path);
221+ return entry !== null && entry.isFile() && !entry.isSymbolicLink();
222+ });
223+
224+const listLocalDirectorySourceShape = (
225+ root: string,
226+): Effect.Effect<LocalDirectorySourceShape, AppSourceError> =>
227+ Effect.gen(function* () {
228+ const toolsPath = `${root}${sep}tools`;
229+ const toolsStat = yield* lstatOrNull(toolsPath);
230+ const toolFiles: string[] = [];
231+ if (toolsStat?.isDirectory() && !toolsStat.isSymbolicLink()) {
232+ const toolEntries = yield* Effect.tryPromise({
233+ try: () => readdir(toolsPath, { withFileTypes: true }),
234+ catch: (cause) =>
235+ new AppSourceError({
236+ message: "failed to read local-directory tools folder",
237+ path: toolsPath,
238+ cause,
239+ }),
240+ });
241+ for (const entry of toolEntries) {
242+ if (!entry.isFile()) continue;
243+ if (toolKeyFromPath(`tools/${entry.name}`) === null) continue;
244+ toolFiles.push(entry.name);
245+ }
246+ toolFiles.sort((a, b) => a.localeCompare(b));
247+ }
248+ const skipped: string[] = [];
249+ for (const name of RESERVED_ARTIFACT_DIRS) {
250+ if (yield* hasNonSymlinkDirectory(`${root}${sep}${name}`)) skipped.push(name);
251+ }
252+ return {
253+ toolFiles: toolFiles.slice(0, 20),
254+ skipped,
255+ hasPackageJson: yield* hasNonSymlinkFile(`${root}${sep}package.json`),
256+ };
257+ });
258+
196259 export const fetchLocalDirectoryAppSource = (
197260 input: LocalDirectoryAppSourceInput,
198261 ): Effect.Effect<LocalDirectoryAppSourceSnapshot, AppSourceError | PublishError> =>
@@ -247,6 +310,7 @@ export const listLocalDirectoryDirs = ( · 48 unmodified lines
247310 }),
248311 });
249312 const dirs: LocalDirectoryDirEntry[] = [];
313+ const source = yield* listLocalDirectorySourceShape(root);
250314 for (const name of entries) {
251315 if (!input.includeHidden && name.startsWith(".")) continue;
252316 const child = `${root}${sep}${name}`;
@@ -261,7 +325,12 @@ export const listLocalDirectoryDirs = ( · 8 unmodified lines
261325 }).pipe(Effect.catch(() => Effect.succeed(null)));
262326 if (!entryStat) continue;
263327 if (entryStat.isDirectory()) {
264− dirs.push({ name, path: child, isSymlink: false });
328+ dirs.push({
329+ name,
330+ path: child,
331+ isSymlink: false,
332+ hasTools: yield* hasNonSymlinkDirectory(`${child}${sep}tools`),
333+ });
265334 continue;
266335 }
267336 if (!entryStat.isSymbolicLink()) continue;
@@ -274,7 +343,9 @@ export const listLocalDirectoryDirs = ( · 6 unmodified lines
274343 cause,
275344 }),
276345 }).pipe(Effect.catch(() => Effect.succeed(null)));
277− if (targetStat?.isDirectory()) dirs.push({ name, path: child, isSymlink: true });
346+ if (targetStat?.isDirectory()) {
347+ dirs.push({ name, path: child, isSymlink: true, hasTools: false });
348+ }
278349 }
279350 dirs.sort((a, b) => a.name.localeCompare(b.name));
280351 const parent = dirname(root);
@@ -282,6 +353,7 @@ export const listLocalDirectoryDirs = ( · 1 unmodified line
282353 path: root,
283354 parent: parent === root ? null : parent,
284355 dirs,
356+ source,
285357 };
286358 });
287359