mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
365
src/lib/agentSkills/generator.ts
Normal file
365
src/lib/agentSkills/generator.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user