skill-locator.ts
3040 bytes
1import { constants } from "node:fs";
2import { join } from "node:path";
3import type { FileSystem } from "../ports/fs.ts";
4import type { LocatedSkillFile, SkillSource } from "../types.ts";
5import { getSkillRoots } from "./pi-paths.ts";
6
7export interface SkillLocator {
8 findSkillFiles(cwd: string): Promise<LocatedSkillFile[]>;
9}
10
11export class DefaultSkillLocator implements SkillLocator {
12 constructor(private readonly fs: FileSystem) {}
13
14 async findSkillFiles(cwd: string): Promise<LocatedSkillFile[]> {
15 const roots = getSkillRoots(cwd);
16 const files: LocatedSkillFile[] = [];
17 const seen = new Set<string>();
18
19 for (const root of roots) {
20 if (!(await this.fs.access(root.path))) continue;
21 const found = await this.scanSkillDir(root.path, root.path, root.source, root.includeRootMarkdownFiles);
22 for (const file of found) {
23 if (seen.has(file.filePath)) continue;
24 seen.add(file.filePath);
25 files.push(file);
26 }
27 }
28
29 return files.sort((a, b) => a.filePath.localeCompare(b.filePath));
30 }
31
32 private async scanSkillDir(
33 dir: string,
34 root: string,
35 source: SkillSource,
36 includeRootMarkdownFiles: boolean,
37 ): Promise<LocatedSkillFile[]> {
38 const out: LocatedSkillFile[] = [];
39 let entries: Awaited<ReturnType<FileSystem["readdir"]>>;
40 try {
41 entries = await this.fs.readdir(dir);
42 } catch {
43 return out;
44 }
45
46 const skillEntry = entries.find((entry) => entry.name === "SKILL.md");
47 if (skillEntry) {
48 const filePath = join(dir, "SKILL.md");
49 if (await this.isFile(filePath, skillEntry)) {
50 out.push({ filePath, source, editable: await this.isWritable(filePath) });
51 }
52 return out;
53 }
54
55 for (const entry of entries) {
56 if (entry.name.startsWith(".")) continue;
57 if (entry.name === "node_modules") continue;
58
59 const fullPath = join(dir, entry.name);
60 if (await this.isDirectory(fullPath, entry)) {
61 out.push(...(await this.scanSkillDir(fullPath, root, source, false)));
62 continue;
63 }
64
65 if (includeRootMarkdownFiles && entry.name.endsWith(".md") && (await this.isFile(fullPath, entry))) {
66 out.push({ filePath: fullPath, source, editable: await this.isWritable(fullPath) });
67 }
68 }
69
70 return out;
71 }
72
73 private async isDirectory(path: string, entry: { isDirectory: boolean; isSymbolicLink: boolean }): Promise<boolean> {
74 if (entry.isDirectory) return true;
75 if (!entry.isSymbolicLink) return false;
76 try {
77 return (await this.fs.stat(path)).isDirectory;
78 } catch {
79 return false;
80 }
81 }
82
83 private async isFile(path: string, entry: { isFile: boolean; isSymbolicLink: boolean }): Promise<boolean> {
84 if (entry.isFile) return true;
85 if (!entry.isSymbolicLink) return false;
86 try {
87 return (await this.fs.stat(path)).isFile;
88 } catch {
89 return false;
90 }
91 }
92
93 private async isWritable(path: string): Promise<boolean> {
94 return this.fs.access(path, constants.R_OK | constants.W_OK);
95 }
96}