#!/usr/bin/env bun /** * Generate SKILL.md files from .tmpl templates. * * Pipeline: * read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md * * Supports --dry-run: generate to memory, exit 1 if different from committed file. * Used by skill:check and CI freshness checks. */ import { COMMAND_DESCRIPTIONS } from '../browse/src/commands'; import { SNAPSHOT_FLAGS } from '../browse/src/snapshot'; import * as fs from 'fs'; import * as path from 'path'; const ROOT = path.resolve(import.meta.dir, '..'); const DRY_RUN = process.argv.includes('--dry-run'); // ─── Placeholder Resolvers ────────────────────────────────── function generateCommandReference(): string { // Group commands by category const groups = new Map>(); for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { const list = groups.get(meta.category) || []; list.push({ command: cmd, description: meta.description, usage: meta.usage }); groups.set(meta.category, list); } // Category display order const categoryOrder = [ 'Navigation', 'Reading', 'Interaction', 'Inspection', 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', ]; const sections: string[] = []; for (const category of categoryOrder) { const commands = groups.get(category); if (!commands || commands.length === 0) continue; // Sort alphabetically within category commands.sort((a, b) => a.command.localeCompare(b.command)); sections.push(`### ${category}`); sections.push('| Command | Description |'); sections.push('|---------|-------------|'); for (const cmd of commands) { const display = cmd.usage ? `\`${cmd.usage}\`` : `\`${cmd.command}\``; sections.push(`| ${display} | ${cmd.description} |`); } sections.push(''); } return sections.join('\n').trimEnd(); } function generateSnapshotFlags(): string { const lines: string[] = [ 'The snapshot is your primary tool for understanding and interacting with pages.', '', '```', ]; for (const flag of SNAPSHOT_FLAGS) { const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; lines.push(`${label.padEnd(10)}${flag.description}`); } lines.push('```'); lines.push(''); lines.push('Combine flags: `$B snapshot -i -a -C -o /tmp/annotated.png`'); lines.push(''); lines.push('After snapshot, use @refs everywhere:'); lines.push('```bash'); lines.push('$B click @e3 $B fill @e4 "value" $B hover @e1'); lines.push('$B html @e2 $B css @e5 "color" $B attrs @e6'); lines.push('$B click @c1 # cursor-interactive ref (from -C)'); lines.push('```'); lines.push(''); lines.push('Refs are invalidated on navigation — run `snapshot` again after `goto`.'); return lines.join('\n'); } const RESOLVERS: Record string> = { COMMAND_REFERENCE: generateCommandReference, SNAPSHOT_FLAGS: generateSnapshotFlags, }; // ─── Template Processing ──────────────────────────────────── const GENERATED_HEADER = `\n\n`; function processTemplate(tmplPath: string): { outputPath: string; content: string } { const tmplContent = fs.readFileSync(tmplPath, 'utf-8'); const relTmplPath = path.relative(ROOT, tmplPath); const outputPath = tmplPath.replace(/\.tmpl$/, ''); // Replace placeholders let content = tmplContent.replace(/\{\{(\w+)\}\}/g, (match, name) => { const resolver = RESOLVERS[name]; if (!resolver) throw new Error(`Unknown placeholder {{${name}}} in ${relTmplPath}`); return resolver(); }); // Check for any remaining unresolved placeholders const remaining = content.match(/\{\{(\w+)\}\}/g); if (remaining) { throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`); } // Prepend generated header (after frontmatter) const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)); const fmEnd = content.indexOf('---', content.indexOf('---') + 3); if (fmEnd !== -1) { const insertAt = content.indexOf('\n', fmEnd) + 1; content = content.slice(0, insertAt) + header + content.slice(insertAt); } else { content = header + content; } return { outputPath, content }; } // ─── Main ─────────────────────────────────────────────────── function findTemplates(): string[] { const templates: string[] = []; const candidates = [ path.join(ROOT, 'SKILL.md.tmpl'), path.join(ROOT, 'browse', 'SKILL.md.tmpl'), ]; for (const p of candidates) { if (fs.existsSync(p)) templates.push(p); } return templates; } let hasChanges = false; for (const tmplPath of findTemplates()) { const { outputPath, content } = processTemplate(tmplPath); const relOutput = path.relative(ROOT, outputPath); if (DRY_RUN) { const existing = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : ''; if (existing !== content) { console.log(`STALE: ${relOutput}`); hasChanges = true; } else { console.log(`FRESH: ${relOutput}`); } } else { fs.writeFileSync(outputPath, content); console.log(`GENERATED: ${relOutput}`); } } if (DRY_RUN && hasChanges) { console.error('\nGenerated SKILL.md files are stale. Run: bun run gen:skill-docs'); process.exit(1); }