Skip to content

Commit 93db073

Browse files
authored
Merge pull request #24 from beautyfree/fix/nested-skill-detection
fix(scanner): detect skills in nested directories
2 parents f740611 + 4f6289d commit 93db073

1 file changed

Lines changed: 45 additions & 31 deletions

File tree

src/main/scanner.ts

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, lstatSync, readdirSync, statSync } from "node:fs";
1+
import { existsSync, readdirSync, statSync } from "node:fs";
22
import { basename, dirname, join } from "node:path";
33
import { computeSkillFootprint } from "../shared/skill-footprint";
44
import type { ParsedSkillMd } from "./parser";
@@ -64,6 +64,48 @@ export function discoverSkillDirs(root: string): SkillCandidate[] {
6464
return candidates;
6565
}
6666

67+
/** Max directory depth walked when discovering nested skill packages. */
68+
const MAX_SCAN_DEPTH = 8;
69+
70+
/**
71+
* Recursively collect directories that contain a `SKILL.md`, supporting nested
72+
* skill packages (e.g. `skills/Pkg/skills/className/leaf/SKILL.md`). Descent
73+
* stops at the first `SKILL.md` so a skill's own resource subfolders are not
74+
* mistaken for separate skills. Follows symlinks, skips `.git`, depth-capped.
75+
*/
76+
function collectSkillRoots(root: string): string[] {
77+
const found: string[] = [];
78+
79+
function walk(dir: string, depth: number): void {
80+
if (depth > MAX_SCAN_DEPTH) return;
81+
let entries: string[];
82+
try {
83+
entries = readdirSync(dir);
84+
} catch {
85+
return;
86+
}
87+
for (const name of entries) {
88+
if (name === ".git") continue;
89+
const path = join(dir, name);
90+
let st;
91+
try {
92+
st = statSync(path); // follow symlinks so symlinked skill dirs resolve
93+
} catch {
94+
continue;
95+
}
96+
if (!st.isDirectory()) continue;
97+
if (existsSync(join(path, "SKILL.md"))) {
98+
found.push(path);
99+
continue; // a skill dir is a leaf — don't descend into its resources
100+
}
101+
walk(path, depth + 1);
102+
}
103+
}
104+
105+
walk(root, 0);
106+
return found;
107+
}
108+
67109
function detectCollection(skillDir: string, skillsRoot: string): string | undefined {
68110
if (isSymlink(skillDir)) {
69111
const c = collectionFromRealPath(skillDir, skillsRoot);
@@ -164,21 +206,7 @@ function scanInheritedRoot(
164206
dedup: Map<string, Skill>,
165207
provenance: Record<string, Record<string, unknown>>,
166208
): void {
167-
let entries: string[];
168-
try {
169-
entries = readdirSync(root);
170-
} catch {
171-
return;
172-
}
173-
for (const name of entries) {
174-
const skillDir = join(root, name);
175-
if (!existsSync(skillDir)) continue;
176-
try {
177-
const st = lstatSync(skillDir);
178-
if (!st.isDirectory() && !st.isSymbolicLink()) continue;
179-
} catch {
180-
continue;
181-
}
209+
for (const skillDir of collectSkillRoots(root)) {
182210
const canonical = resolveCanonical(skillDir);
183211
const skillMd = join(canonical, "SKILL.md");
184212
if (!existsSync(skillMd)) continue;
@@ -226,21 +254,7 @@ function scanSkillMdRoot(
226254
dedup: Map<string, Skill>,
227255
provenance: Record<string, Record<string, unknown>>,
228256
): void {
229-
let entries: string[];
230-
try {
231-
entries = readdirSync(root);
232-
} catch {
233-
return;
234-
}
235-
for (const name of entries) {
236-
const skillDir = join(root, name);
237-
if (!existsSync(skillDir)) continue;
238-
try {
239-
const st = lstatSync(skillDir);
240-
if (!st.isDirectory() && !st.isSymbolicLink()) continue;
241-
} catch {
242-
continue;
243-
}
257+
for (const skillDir of collectSkillRoots(root)) {
244258
const canonical = resolveCanonical(skillDir);
245259
const skillMd = join(canonical, "SKILL.md");
246260
if (!existsSync(skillMd)) continue;

0 commit comments

Comments
 (0)