|
1 | | -import { existsSync, lstatSync, readdirSync, statSync } from "node:fs"; |
| 1 | +import { existsSync, readdirSync, statSync } from "node:fs"; |
2 | 2 | import { basename, dirname, join } from "node:path"; |
3 | 3 | import { computeSkillFootprint } from "../shared/skill-footprint"; |
4 | 4 | import type { ParsedSkillMd } from "./parser"; |
@@ -64,6 +64,48 @@ export function discoverSkillDirs(root: string): SkillCandidate[] { |
64 | 64 | return candidates; |
65 | 65 | } |
66 | 66 |
|
| 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 | + |
67 | 109 | function detectCollection(skillDir: string, skillsRoot: string): string | undefined { |
68 | 110 | if (isSymlink(skillDir)) { |
69 | 111 | const c = collectionFromRealPath(skillDir, skillsRoot); |
@@ -164,21 +206,7 @@ function scanInheritedRoot( |
164 | 206 | dedup: Map<string, Skill>, |
165 | 207 | provenance: Record<string, Record<string, unknown>>, |
166 | 208 | ): 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)) { |
182 | 210 | const canonical = resolveCanonical(skillDir); |
183 | 211 | const skillMd = join(canonical, "SKILL.md"); |
184 | 212 | if (!existsSync(skillMd)) continue; |
@@ -226,21 +254,7 @@ function scanSkillMdRoot( |
226 | 254 | dedup: Map<string, Skill>, |
227 | 255 | provenance: Record<string, Record<string, unknown>>, |
228 | 256 | ): 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)) { |
244 | 258 | const canonical = resolveCanonical(skillDir); |
245 | 259 | const skillMd = join(canonical, "SKILL.md"); |
246 | 260 | if (!existsSync(skillMd)) continue; |
|
0 commit comments