diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 19:56:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 19:56:33 +0900 |
| commit | ac69e5f8bea9377887a6f89ff362c6be0db1c874 (patch) | |
| tree | 645afb475cdf97546441a8da2966b74f5b514955 | |
| parent | dfecc38b394bc01825463a0f169df752382768ff (diff) | |
| download | dispatch-ac69e5f8bea9377887a6f89ff362c6be0db1c874.tar.gz dispatch-ac69e5f8bea9377887a6f89ff362c6be0db1c874.zip | |
feat(skills): recursive skill discovery — scan subdirectories
scanSkillsDir now recurses into subdirectories (e.g. ~/.skills/general/,
~/.skills/tech/), not just the top level. The load_skill execute path
also searches recursively for the named .md file. Duplicate names are
deduped (first found wins; top-level before nested). 42 tests pass.
| -rw-r--r-- | packages/skills/src/load-skill.ts | 114 |
1 files changed, 79 insertions, 35 deletions
diff --git a/packages/skills/src/load-skill.ts b/packages/skills/src/load-skill.ts index 4d37997..ef7fc23 100644 --- a/packages/skills/src/load-skill.ts +++ b/packages/skills/src/load-skill.ts @@ -15,30 +15,71 @@ export interface SkillsDeps { } /** - * Scan a .skills directory and return discovered skill entries. + * Scan a .skills directory (recursively) and return discovered skill entries. + * Subdirectories are traversed; `.md` files at any depth are included. + * If two files share the same name, the first one found wins (top-level + * before subdirs, alphabetical within a level). * Returns an empty array on any error (fail-open). */ export async function scanSkillsDir(dir: string): Promise<readonly SkillEntry[]> { - try { - const entries = await readdir(dir, { encoding: "utf8", withFileTypes: true }); - const skills: SkillEntry[] = []; - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".md")) { - continue; + async function scan(d: string): Promise<SkillEntry[]> { + try { + const entries = await readdir(d, { encoding: "utf8", withFileTypes: true }); + const skills: SkillEntry[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const subSkills = await scan(join(d, entry.name)); + for (const sub of subSkills) { + if (!skills.some((s) => s.name === sub.name)) { + skills.push(sub); + } + } + } else if (entry.isFile() && entry.name.endsWith(".md")) { + const name = entry.name.slice(0, -3); + if (skills.some((s) => s.name === name)) continue; + try { + const content = await readFile(join(d, entry.name), "utf8"); + const meta = parseSkillMeta(content); + skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined }); + } catch { + skills.push({ name }); + } + } } - const name = entry.name.slice(0, -3); - try { - const content = await readFile(join(dir, entry.name), "utf8"); - const meta = parseSkillMeta(content); - skills.push({ name, summary: meta.hasMeta ? meta.summary : undefined }); - } catch { - skills.push({ name }); + return skills; + } catch { + return []; + } + } + return scan(dir); +} + +/** + * Recursively search a directory for a skill file by name. + * Returns the full path of the first `{name}.md` found, or null if not found. + * Top-level files are found before nested ones (readdir order within each level). + */ +async function findSkillFile(dir: string, name: string): Promise<string | null> { + async function search(d: string): Promise<string | null> { + try { + const entries = await readdir(d, { encoding: "utf8", withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name === `${name}.md`) { + return join(d, entry.name); + } } + for (const entry of entries) { + if (entry.isDirectory()) { + const found = await search(join(d, entry.name)); + if (found !== null) return found; + } + } + return null; + } catch { + return null; } - return skills; - } catch { - return []; } + return search(dir); } /** @@ -81,36 +122,39 @@ export function createLoadSkillTool(deps: SkillsDeps): ToolContract { const cwdSkillsDir = join(effectiveBase, ".skills"); const homeSkillsDir = join(resolve(homeDir), ".skills"); - const cwdPath = join(cwdSkillsDir, `${rawName}.md`); - const homePath = join(homeSkillsDir, `${rawName}.md`); - - const resolvedCwdPath = resolve(cwdPath); - const resolvedHomePath = resolve(homePath); - - if (!isPathWithinDir(resolvedCwdPath, cwdSkillsDir)) { - return { content: "Error: Invalid skill path.", isError: true }; - } - if (!isPathWithinDir(resolvedHomePath, homeSkillsDir)) { - return { content: "Error: Invalid skill path.", isError: true }; - } - - let content: string | null = null; + let filePath: string | null = null; try { - content = await readFile(resolvedCwdPath, "utf8"); + filePath = await findSkillFile(cwdSkillsDir, rawName); } catch { // Cwd miss — try home } - if (content === null) { + if (filePath === null) { try { - content = await readFile(resolvedHomePath, "utf8"); + filePath = await findSkillFile(homeSkillsDir, rawName); } catch { // Both miss } } - if (content === null) { + if (filePath === null) { + return { content: `Error: unknown skill: ${rawName}`, isError: true }; + } + + const resolvedPath = resolve(filePath); + if ( + !isPathWithinDir(resolvedPath, cwdSkillsDir) && + !isPathWithinDir(resolvedPath, homeSkillsDir) + ) { + return { content: "Error: Invalid skill path.", isError: true }; + } + + let content: string; + + try { + content = await readFile(resolvedPath, "utf8"); + } catch { return { content: `Error: unknown skill: ${rawName}`, isError: true }; } |
