/** * scripts/generate-docs/lib/skill-parser.ts * * Walks every `templates/skills/**\/SKILL.md`, parses the YAML frontmatter via * gray-matter, and returns a normalised `Skill[]` ready for Handlebars context * building. Slugs and phase buckets come from the shared * `templates/skills/lib/skill-slug.ts` helpers so that the doc generator * cannot drift from the installer. */ import { readFileSync } from 'node:fs'; import { join, relative, dirname, basename } from 'node:path'; import { sync as globSync } from 'glob'; import matter from 'gray-matter'; import { remapSkillPath, phaseFromAuthoringPath, } from '../../../templates/skills/lib/skill-slug'; const SKILLS_ROOT = join(__dirname, '..', '..', '..', 'templates', 'skills'); export interface Skill { /** Slash-command slug (e.g. `ba-audit-actors`, `efcore`, `backend-controller`) */ slug: string; /** Frontmatter `name:` field (canonical command name) */ name: string; /** Frontmatter `description:` (flattened to a single string) */ description: string; /** Phase bucket: `business-analyse`, `development/backend`, `lifecycle`, … */ phase: string; /** Optional `argument-hint:` from frontmatter */ argumentHint: string | null; /** Optional `allowed-tools:` list (mixed string or comma-separated YAML) */ allowedTools: string[]; /** Optional `model:` (e.g. `haiku`) */ model: string | null; /** Optional `group:` (e.g. `H` for ba-develop) */ group: string | null; /** Path to the source SKILL.md relative to repo root, for debugging */ sourcePath: string; /** First non-empty body line, useful for hover tooltips */ firstBodyLine: string; } /** * Flatten a YAML scalar (which gray-matter may yield as a multi-line string * with embedded newlines from `>` block scalars) into a single, trimmed line. */ function flatten(value: unknown): string { if (value == null) return ''; if (typeof value !== 'string') return String(value); return value.replace(/\s+/g, ' ').trim(); } /** * Coerce `allowed-tools` to a string array. YAML accepts both list and * comma-separated forms; we normalise to a single shape. */ function toToolList(value: unknown): string[] { if (!value) return []; if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean); return String(value) .split(',') .map((s) => s.trim()) .filter(Boolean); } /** * Extract the first non-empty, non-heading line from the markdown body. * Used as a short summary on cards when the YAML description is unhelpfully * long. */ function firstBodyLine(body: string): string { for (const raw of body.split('\n')) { const line = raw.trim(); if (!line) continue; if (line.startsWith('#')) continue; // skip headings if (line.startsWith('>')) continue; // skip blockquotes if (line.startsWith('---')) continue; // skip horizontal rules return line.length > 200 ? line.slice(0, 197) + '…' : line; } return ''; } /** * Parse a single SKILL.md given its absolute path. Returns `null` for files * that the installer would not deploy (BA `_workflow/` companions, BA index). */ export function parseSkillFile(absPath: string): Skill | null { const rel = relative(SKILLS_ROOT, absPath).replace(/\\/g, '/'); const remapped = remapSkillPath(rel); if (remapped === null) return null; const raw = readFileSync(absPath, 'utf-8'); const parsed = matter(raw); const data = parsed.data ?? {}; const name = flatten(data.name) || basename(dirname(rel)); const description = flatten(data.description); const phase = typeof data.phase === 'string' && data.phase.trim() ? data.phase.trim() : phaseFromAuthoringPath(rel); // Derive slug: prefer the remapped folder name (matches what the installer // deploys); for nested skills (e.g. `development/backend/controller/`), use // the YAML `name:` since only the first folder is registered by Claude Code. const remappedFolder = dirname(remapped).split('/')[0]; const slug = name || remappedFolder; return { slug, name, description, phase, argumentHint: flatten(data['argument-hint']) || null, allowedTools: toToolList(data['allowed-tools']), model: flatten(data.model) || null, group: flatten(data.group) || null, sourcePath: `templates/skills/${rel}`, firstBodyLine: firstBodyLine(parsed.content), }; } /** * Walk the entire `templates/skills/` tree, parse every SKILL.md, drop the * ones the installer would not deploy, and return them sorted by slug for * deterministic output (the snapshot tests rely on this). */ export function parseAllSkills(): Skill[] { const pattern = join(SKILLS_ROOT, '**', 'SKILL.md').replace(/\\/g, '/'); const files = globSync(pattern, { absolute: true, ignore: '**/node_modules/**' }); const skills: Skill[] = []; for (const file of files) { const parsed = parseSkillFile(file); if (parsed) skills.push(parsed); } skills.sort((a, b) => a.slug.localeCompare(b.slug)); return skills; } /** * Group skills by their `phase` field, returning a deterministically-ordered * `Record`. Used by Handlebars helpers downstream. */ export function groupByPhase(skills: Skill[]): Record { const out: Record = {}; for (const s of skills) { (out[s.phase] ??= []).push(s); } // Sort keys for stable rendering return Object.fromEntries( Object.entries(out).sort(([a], [b]) => a.localeCompare(b)) ); } // CLI entry: `npx tsx scripts/generate-docs/lib/skill-parser.ts` // Prints a summary so we can verify the parser sees every expected skill. if (require.main === module) { const skills = parseAllSkills(); const groups = groupByPhase(skills); // eslint-disable-next-line no-console console.log(`Parsed ${skills.length} skills from templates/skills/`); for (const [phase, list] of Object.entries(groups)) { // eslint-disable-next-line no-console console.log(` [${phase}] ${list.length}`); for (const s of list) { // eslint-disable-next-line no-console console.log(` /${s.slug} ${s.description.slice(0, 80)}`); } } }