From f86e905efbdc80cb2d878432dcebffc01ec60b9c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:54:42 -0300 Subject: [PATCH] feat(agent-skills): add generator with idempotent skill md generation + prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements src/lib/agentSkills/generator.ts (§3.4 contract): - generateAgentSkills(opts): idempotent, dryRun:true default, prune:false default - buildSkillMarkdown(skillId, sources): generates frontmatter + API/CLI body - API body: Visão geral + Autenticação + Endpoints (with curl) + Payloads - CLI body: Visão geral + Instalação rápida + Subcomandos (with flags + examples) - Prune: detects orphans in skills/{id}/ not in catalog; deletes in apply mode - Marker preservation: ... - Generated comment: per D24 spec --- src/lib/agentSkills/generator.ts | 365 +++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/lib/agentSkills/generator.ts diff --git a/src/lib/agentSkills/generator.ts b/src/lib/agentSkills/generator.ts new file mode 100644 index 0000000000..5df831edfa --- /dev/null +++ b/src/lib/agentSkills/generator.ts @@ -0,0 +1,365 @@ +/** + * 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(" "); + + const nameStr = needsQuote(fm.name) ? `"${fm.name.replace(/"/g, '\\"')}"` : fm.name; + const descStr = needsQuote(fm.description) + ? `"${fm.description.replace(/"/g, '\\"').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("## Visão geral\n"); + lines.push(skill.description); + lines.push(""); + + lines.push("## Autenticação\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/reference/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("## Visão geral\n"); + lines.push(skill.description); + lines.push(""); + + lines.push("## Instalação rápida\n"); + lines.push("```bash"); + lines.push("npm install -g omniroute # or: npx omniroute"); + lines.push("omniroute --version"); + lines.push("```"); + lines.push(""); + + lines.push("## Subcomandos\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("**Exemplo:**\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; +}