discover-skills.ts
75 lines · scripts/discover-skills.ts at 3f79d81
1/**
2 * Shared discovery for SKILL.md and .tmpl files.
3 * Scans root + one level of subdirs, skipping node_modules/.git/dist.
4 */
6import * as fs from 'fs';
7import * as path from 'path';
8import type { HostConfig } from './host-config';
10const SKIP = new Set(['node_modules', '.git', 'dist']);
12/** The generator and its coverage checks use the same include-minus-skip rule. */
13export function includesSkill(host: HostConfig, skillDir: string): boolean {
14 const { includeSkills, skipSkills } = host.generation;
15 return (!includeSkills?.length || includeSkills.includes(skillDir))
16 && !skipSkills?.includes(skillDir);
17}
19function subdirs(root: string): string[] {
20 return fs.readdirSync(root, { withFileTypes: true })
21 .filter(d => d.isDirectory() && !d.name.startsWith('.') && !SKIP.has(d.name))
22 .map(d => d.name);
23}
25export function discoverTemplates(root: string): Array<{ tmpl: string; output: string }> {
26 const dirs = ['', ...subdirs(root)];
27 const results: Array<{ tmpl: string; output: string }> = [];
28 for (const dir of dirs) {
29 const rel = dir ? `${dir}/SKILL.md.tmpl` : 'SKILL.md.tmpl';
30 if (fs.existsSync(path.join(root, rel))) {
31 results.push({ tmpl: rel, output: rel.replace(/\.tmpl$/, '') });
32 }
33 }
34 return results;
35}
37/**
38 * Discover on-demand section templates: `<skill>/sections/*.md.tmpl`.
39 *
40 * Returns the relative tmpl path, its generated output path (`.tmpl` stripped),
41 * and the owning skill directory so the generator can build a TemplateContext
42 * with the PARENT skill's name (not "sections") — see processSectionTemplate.
43 *
44 * Scans one level of subdirs (same depth as discoverTemplates), looking only
45 * inside a `sections/` child. Skills without a sections/ dir contribute nothing,
46 * so this is a no-op for every skill that hasn't been carved.
47 */
48export function discoverSectionTemplates(
49 root: string,
50): Array<{ tmpl: string; output: string; skillDir: string }> {
51 const results: Array<{ tmpl: string; output: string; skillDir: string }> = [];
52 for (const dir of subdirs(root)) {
53 const sectionsDir = path.join(root, dir, 'sections');
54 if (!fs.existsSync(sectionsDir) || !fs.statSync(sectionsDir).isDirectory()) continue;
55 for (const entry of fs.readdirSync(sectionsDir, { withFileTypes: true })) {
56 if (!entry.isFile() || !entry.name.endsWith('.md.tmpl')) continue;
57 const rel = `${dir}/sections/${entry.name}`;
58 results.push({ tmpl: rel, output: rel.replace(/\.tmpl$/, ''), skillDir: dir });
59 }
60 }
61 // Deterministic order so CI freshness checks don't flap on FS iteration order.
62 return results.sort((a, b) => a.tmpl.localeCompare(b.tmpl));
63}
65export function discoverSkillFiles(root: string): string[] {
66 const dirs = ['', ...subdirs(root)];
67 const results: string[] = [];
68 for (const dir of dirs) {
69 const rel = dir ? `${dir}/SKILL.md` : 'SKILL.md';
70 if (fs.existsSync(path.join(root, rel))) {
71 results.push(rel);
72 }
73 }
74 return results;
75}