/** * generator.ts — idempotent SKILL.md generator for all 42 agent skills. * * Usage (library): * import { generateAgentSkills, buildSkillMarkdown } from "@/lib/agentSkills/generator"; * * Usage (CLI): * node scripts/skills/generate-agent-skills.mjs [--apply] [--prune] [--only=id1,id2] [--json] * * Safety contract: * - Default: dryRun=true, prune=false (no filesystem writes). * - dryRun=false is required for any actual writes. * - prune=true AND dryRun=false is required to delete orphan directories. * - F9 is responsible for running apply; F3 only implements + tests the generator. */ import fs from "node:fs"; import path from "node:path"; import { getCatalog, refreshCatalog } from "./catalog"; import { parseOpenapi } from "./openapiParser"; import { parseCliRegistry } from "./cliRegistryParser"; import type { AgentSkill, GeneratorOptions, GeneratorReport } from "./types"; import type { ParsedOpenapi } from "./openapiParser"; import type { ParsedCliRegistry } from "./cliRegistryParser"; // ── Constants ───────────────────────────────────────────────────────────────── const GENERATED_COMMENT = ""; const CUSTOM_START_MARKER = ""; const CUSTOM_END_MARKER = ""; // ── Types ───────────────────────────────────────────────────────────────────── interface BuildSources { openapi: ParsedOpenapi; cliRegistry: ParsedCliRegistry; } // ── Frontmatter helpers ─────────────────────────────────────────────────────── /** * Serializes frontmatter to YAML front-matter block. * Handles descriptions with colons or special characters safely. */ function serializeFrontmatter(fm: { name: string; description: string }): string { // Use block scalar for description if it contains colons, quotes, or newlines const needsQuote = (v: string): boolean => /[:\n"']/.test(v) || v.startsWith(" "); // Escape order matters for double-quoted YAML scalars: backslash FIRST (so the // escapes we add below are not themselves re-escaped), then the double-quote, // then collapse real newlines into the \n escape sequence. const escDq = (v: string): string => v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const nameStr = needsQuote(fm.name) ? `"${escDq(fm.name)}"` : fm.name; const descStr = needsQuote(fm.description) ? `"${escDq(fm.description).replace(/\n/g, "\\n")}"` : fm.description; return `---\nname: ${nameStr}\ndescription: ${descStr}\n---\n`; } /** * Extracts the custom block content from an existing SKILL.md string. * Returns null if no custom block is found. */ function extractCustomBlock(content: string): string | null { const startIdx = content.indexOf(CUSTOM_START_MARKER); const endIdx = content.indexOf(CUSTOM_END_MARKER); if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return null; return content.slice(startIdx, endIdx + CUSTOM_END_MARKER.length); } // ── Body builders ───────────────────────────────────────────────────────────── function buildApiBody(skill: AgentSkill, sources: BuildSources): string { const areaMap = sources.openapi.areas; const ops = areaMap.get(skill.area as Parameters[0]) ?? []; const lines: string[] = []; lines.push("## Overview\n"); lines.push(skill.description); lines.push(""); lines.push("## Authentication\n"); lines.push( "All requests require a valid Bearer token or session cookie. " + "Obtain a token via `POST /api/auth/login` or configure `REQUIRE_API_KEY=false` for local development.", ); lines.push(""); lines.push("## Endpoints\n"); if (ops.length === 0) { lines.push("_No endpoints mapped for this area yet._"); } else { for (const op of ops) { lines.push(`### ${op.method} ${op.path}\n`); if (op.summary) { lines.push(`${op.summary}`); lines.push(""); } if (op.description) { lines.push(op.description); lines.push(""); } // Minimal curl example const curlMethod = op.method === "GET" ? "" : `-X ${op.method} `; lines.push("```bash"); lines.push( `curl ${curlMethod}https://localhost:20128${op.path} \\`, ); lines.push(' -H "Authorization: Bearer $OMNIROUTE_TOKEN"'); if (["POST", "PUT", "PATCH"].includes(op.method)) { lines.push(' -H "Content-Type: application/json" \\'); lines.push(" -d '{}'"); } lines.push("```"); lines.push(""); } } lines.push("## Payloads\n"); lines.push( "See the full OpenAPI specification at `GET /api/openapi/spec` or " + "`docs/openapi.yaml` for detailed request/response schemas.", ); lines.push(""); return lines.join("\n"); } function buildCliBody(skill: AgentSkill, sources: BuildSources): string { const familyMap = sources.cliRegistry.families; const cmds = familyMap.get(skill.area as Parameters[0]) ?? []; const lines: string[] = []; lines.push("## Overview\n"); lines.push(skill.description); lines.push(""); lines.push("## Quick install\n"); lines.push("```bash"); lines.push("npm install -g omniroute # or: npx omniroute"); lines.push("omniroute --version"); lines.push("```"); lines.push(""); lines.push("## Subcommands\n"); if (cmds.length === 0) { lines.push("_No CLI subcommands mapped for this family yet._"); lines.push(""); } else { for (const cmd of cmds) { lines.push(`### \`${cmd.name}\`\n`); if (cmd.description) { lines.push(cmd.description); lines.push(""); } if (cmd.flags.length > 0) { lines.push("**Flags:**\n"); for (const flag of cmd.flags) { lines.push(`- \`${flag}\``); } lines.push(""); } lines.push("**Example:**\n"); lines.push("```bash"); lines.push(`omniroute ${cmd.name}`); lines.push("```"); lines.push(""); } } return lines.join("\n"); } // ── Core builder ───────────────────────────────────────────────────────────── /** * Generates the full SKILL.md string (frontmatter + comment + body) for a given skill. * * If `existingContent` is provided, any ` ... ` * block found in it is re-injected after the generated body (marker preservation / D24). */ export function buildSkillMarkdown( skillId: string, sources: BuildSources, existingContent?: string, ): { frontmatter: { name: string; description: string }; body: string } { const skill = getCatalog().find((s) => s.id === skillId); if (!skill) { throw new Error(`buildSkillMarkdown: skill "${skillId}" not found in catalog`); } const fm = { name: skill.id, description: skill.description.slice(0, 2000), }; const bodyLines = skill.category === "api" ? buildApiBody(skill, sources) : buildCliBody(skill, sources); // Re-inject custom block if present in existing content let customBlock = ""; if (existingContent) { const extracted = extractCustomBlock(existingContent); if (extracted) { customBlock = `\n${extracted}\n`; } } const body = GENERATED_COMMENT + "\n\n" + bodyLines + customBlock; return { frontmatter: fm, body }; } /** * Assembles the full file content string from frontmatter + body. */ function assembleFileContent(fm: { name: string; description: string }, body: string): string { return serializeFrontmatter(fm) + body; } // ── Main generator ───────────────────────────────────────────────────────────── /** * Runs the full generator. Idempotent. * * - dryRun=true (default): returns report but writes nothing to disk. * - dryRun=false: writes/updates SKILL.md files as needed. * - prune=true: detects orphan directories in `skills/` not in the catalog. * - dryRun=true + prune=true: lists orphans, no deletions. * - dryRun=false + prune=true: deletes orphan directories. */ export async function generateAgentSkills(opts: GeneratorOptions): Promise { const { dryRun = true, prune = false, outputDir = "skills", onlyIds } = opts; const outputBase = path.resolve(process.cwd(), outputDir); const catalog = getCatalog(); const catalogIds = new Set(catalog.map((s) => s.id)); // Filter to onlyIds if provided const skillsToProcess = onlyIds ? catalog.filter((s) => onlyIds.includes(s.id)) : catalog; // Lazily parse sources (only once per generator run) let _openapi: ParsedOpenapi | null = null; let _cliRegistry: ParsedCliRegistry | null = null; function getSources(): BuildSources { if (!_openapi) { try { _openapi = parseOpenapi(); } catch { _openapi = { paths: new Map(), areas: new Map() }; } } if (!_cliRegistry) { try { _cliRegistry = parseCliRegistry(); } catch { _cliRegistry = { commands: new Map(), families: new Map() }; } } return { openapi: _openapi, cliRegistry: _cliRegistry }; } const report: GeneratorReport = { generated: [], unchanged: [], pruned: [], orphansDetected: [], errors: [], }; // ── Detect orphans ────────────────────────────────────────────────────────── if (prune) { let dirs: string[] = []; try { dirs = fs .readdirSync(outputBase, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name); } catch { // outputDir doesn't exist yet — no orphans } for (const dir of dirs) { if (!catalogIds.has(dir)) { report.orphansDetected.push(dir); if (!dryRun) { try { fs.rmSync(path.join(outputBase, dir), { recursive: true, force: true }); report.pruned.push(dir); } catch (err) { report.errors.push({ id: dir, error: `Failed to prune: ${err instanceof Error ? err.message : String(err)}`, }); } } } } } // ── Generate / compare each skill ────────────────────────────────────────── const sources = getSources(); for (const skill of skillsToProcess) { try { const skillDir = path.join(outputBase, skill.id); const skillFile = path.join(skillDir, "SKILL.md"); // Read existing content for marker preservation let existingContent: string | undefined; try { existingContent = fs.readFileSync(skillFile, "utf-8"); } catch { existingContent = undefined; } const { frontmatter, body } = buildSkillMarkdown(skill.id, sources, existingContent); const newContent = assembleFileContent(frontmatter, body); // Compare with existing to detect actual changes if (existingContent !== undefined && existingContent === newContent) { report.unchanged.push(skill.id); continue; } if (dryRun) { // In dry-run: classify as generated (would write) but don't touch disk report.generated.push(skill.id); } else { // Apply: ensure directory + write file fs.mkdirSync(skillDir, { recursive: true }); fs.writeFileSync(skillFile, newContent, "utf-8"); report.generated.push(skill.id); } } catch (err) { report.errors.push({ id: skill.id, error: err instanceof Error ? err.message : String(err), }); } } // After writing, refresh catalog cache so subsequent calls reflect new files if (!dryRun && report.generated.length > 0) { refreshCatalog(); } return report; } // Exported for unit testing only — keep the internal helpers reachable without // widening the public surface. export const __testing = { serializeFrontmatter };