Parent directory

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}