feat(agent-skills): add generator with idempotent skill md generation + prune

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: <!-- skill:custom-start --> ... <!-- skill:custom-end -->
- Generated comment: per D24 spec
This commit is contained in:
diegosouzapw
2026-05-27 21:54:42 -03:00
parent 44b248314b
commit f86e905efb

View File

@@ -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 =
"<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->";
const CUSTOM_START_MARKER = "<!-- skill:custom-start -->";
const CUSTOM_END_MARKER = "<!-- skill:custom-end -->";
// ── 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<typeof areaMap.get>[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<typeof familyMap.get>[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 `<!-- skill:custom-start --> ... <!-- skill:custom-end -->`
* 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<GeneratorReport> {
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;
}