summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 19:56:33 +0900
committerAdam Malczewski <[email protected]>2026-06-21 19:56:33 +0900
commitac69e5f8bea9377887a6f89ff362c6be0db1c874 (patch)
tree645afb475cdf97546441a8da2966b74f5b514955
parentdfecc38b394bc01825463a0f169df752382768ff (diff)
downloaddispatch-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.ts114
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 };
}