#!/usr/bin/env node /** * Syncs Fusion skill docs from the canonical pi extension registrations. * * Full flow: * packages/cli/src/extension.ts * → scripts/sync-fusion-skill-tools.mjs * → packages/cli/skill/fusion/SKILL.md (tool categories) * → packages/cli/skill/fusion/references/extension-tools.md (tool sections + params) * → packages/cli/skill/fusion/references/fusion-capabilities.md (tool table) * * Usage: * node scripts/sync-fusion-skill-tools.mjs * node scripts/sync-fusion-skill-tools.mjs --check */ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts"); const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md"); const extensionToolsPath = resolve( repoRoot, "packages/cli/skill/fusion/references/extension-tools.md", ); const capabilitiesPath = resolve( repoRoot, "packages/cli/skill/fusion/references/fusion-capabilities.md", ); const SKILL_BEGIN = ""; const SKILL_END = ""; const EXT_TOOLS_BEGIN = ""; const EXT_TOOLS_END = ""; const CAP_TABLE_BEGIN = ""; const CAP_TABLE_END = ""; const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Goal", "Agent", "Skills", "Insight", "Other"]; const CATEGORY_LABELS = { Task: "Task tools", GitHub: "GitHub tools", Mission: "Mission tools", Goal: "Goal tools", Agent: "Agent tools", Skills: "Skills tools", Insight: "Insight tools", Other: "Other tools", }; const CATEGORY_HEADERS = { Task: "## Task Tools", GitHub: "## GitHub Tools", Mission: "## Mission Tools", Goal: "## Goal Tools", Agent: "## Agent Tools", Skills: "## Skills Tools", Insight: "## Insight Tools", Other: "## Other Tools", }; function categorize(name) { if (name.includes("github")) return "GitHub"; if (name.startsWith("fn_task_")) return "Task"; if ( name.startsWith("fn_mission_") || name.startsWith("fn_milestone_") || name.startsWith("fn_slice_") || name.startsWith("fn_feature_") ) { return "Mission"; } if (name.startsWith("fn_goal_")) return "Goal"; if ( name.startsWith("fn_agent_") || name === "fn_list_agents" || name === "fn_delegate_task" ) { return "Agent"; } if (name.startsWith("fn_skills_")) return "Skills"; if (name.startsWith("fn_insight_")) return "Insight"; return "Other"; } function parseStringLiterals(expression) { const literals = []; const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"/g; for (const match of expression.matchAll(regex)) { literals.push(match[1].replace(/\\n/g, " ").replace(/\\"/g, '"')); } return literals; } function normalizeWhitespace(text) { return text.replace(/\s+/g, " ").trim(); } function findMatchingBrace(source, openIndex) { let depth = 0; let inSingle = false; let inDouble = false; let inTemplate = false; let inLineComment = false; let inBlockComment = false; let escaped = false; for (let i = openIndex; i < source.length; i++) { const char = source[i]; const next = source[i + 1]; if (inLineComment) { if (char === "\n") inLineComment = false; continue; } if (inBlockComment) { if (char === "*" && next === "/") { inBlockComment = false; i++; } continue; } if (inSingle || inDouble || inTemplate) { if (escaped) { escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (inSingle && char === "'") inSingle = false; else if (inDouble && char === '"') inDouble = false; else if (inTemplate && char === "`") inTemplate = false; continue; } if (char === "/" && next === "/") { inLineComment = true; i++; continue; } if (char === "/" && next === "*") { inBlockComment = true; i++; continue; } if (char === "'") { inSingle = true; continue; } if (char === '"') { inDouble = true; continue; } if (char === "`") { inTemplate = true; continue; } if (char === "{") depth++; if (char === "}") { depth--; if (depth === 0) return i; } } throw new Error("Unbalanced braces while parsing extension.ts"); } function splitTopLevelProperties(objectBody) { const props = []; let start = 0; let depthParen = 0; let depthBrace = 0; let depthBracket = 0; let inSingle = false; let inDouble = false; let inTemplate = false; let inLineComment = false; let inBlockComment = false; let escaped = false; for (let i = 0; i < objectBody.length; i++) { const ch = objectBody[i]; const next = objectBody[i + 1]; if (inLineComment) { if (ch === "\n") inLineComment = false; continue; } if (inBlockComment) { if (ch === "*" && next === "/") { inBlockComment = false; i++; } continue; } if (inSingle || inDouble || inTemplate) { if (escaped) { escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (inSingle && ch === "'") inSingle = false; else if (inDouble && ch === '"') inDouble = false; else if (inTemplate && ch === "`") inTemplate = false; continue; } if (ch === "/" && next === "/") { inLineComment = true; i++; continue; } if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; } if (ch === "'") { inSingle = true; continue; } if (ch === '"') { inDouble = true; continue; } if (ch === "`") { inTemplate = true; continue; } if (ch === "(") depthParen++; else if (ch === ")") depthParen--; else if (ch === "{") depthBrace++; else if (ch === "}") depthBrace--; else if (ch === "[") depthBracket++; else if (ch === "]") depthBracket--; if ( ch === "," && depthParen === 0 && depthBrace === 0 && depthBracket === 0 ) { const prop = objectBody.slice(start, i).trim(); if (prop) props.push(prop); start = i + 1; } } const tail = objectBody.slice(start).trim(); if (tail) props.push(tail); return props; } function extractDescription(block) { const descMatch = block.match(/description:\s*([\s\S]*?),\s*promptSnippet:/); if (!descMatch) { const inline = block.match(/description:\s*([\s\S]*?),\s*parameters:/); if (!inline) return ""; return normalizeWhitespace(parseStringLiterals(inline[1]).join(" ")); } return normalizeWhitespace(parseStringLiterals(descMatch[1]).join(" ")); } function mapType(raw) { const value = raw.trim(); if (/^Type\.String\(/.test(value)) return "string"; if (/^Type\.Number\(/.test(value)) return "number"; if (/^Type\.Boolean\(/.test(value)) return "boolean"; if (/^Type\.Array\(/.test(value)) return "array"; if (/^Type\.Union\(/.test(value)) return "union"; if (/^StringEnum\(/.test(value)) return "string(enum)"; if (/^Type\.Null\(/.test(value)) return "null"; return "unknown"; } function parseParameters(block) { const paramsStart = block.indexOf("parameters:"); if (paramsStart === -1) return []; const objectStart = block.indexOf("Type.Object(", paramsStart); if (objectStart === -1) return []; const braceStart = block.indexOf("{", objectStart); if (braceStart === -1) return []; const braceEnd = findMatchingBrace(block, braceStart); const body = block.slice(braceStart + 1, braceEnd); const params = []; for (const prop of splitTopLevelProperties(body)) { const match = prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/); if (!match) continue; const [, name, rawValue] = match; const optional = /^Type\.Optional\(/.test(rawValue.trim()); const inner = optional ? rawValue.trim().replace(/^Type\.Optional\(/, "").replace(/\)\s*$/, "") : rawValue.trim(); const descMatch = rawValue.match(/description:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); const description = descMatch ? normalizeWhitespace(descMatch[1].replace(/\\n/g, " ")) : ""; params.push({ name, type: mapType(inner), required: !optional, description, }); } return params; } function extractTools(source) { const tools = []; const registerToken = "pi.registerTool("; let fromIndex = 0; while (true) { const start = source.indexOf(registerToken, fromIndex); if (start === -1) break; const braceStart = source.indexOf("{", start); const braceEnd = findMatchingBrace(source, braceStart); const block = source.slice(braceStart, braceEnd + 1); const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/); if (nameMatch) { const name = nameMatch[1]; const labelMatch = block.match(/label:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/); const label = labelMatch ? labelMatch[1] : ""; const description = extractDescription(block); const parameters = parseParameters(block); tools.push({ name, label, description, parameters }); } fromIndex = braceEnd + 1; } if (tools.length === 0) { throw new Error(`No fn_* tool registrations found in ${extensionPath}.`); } return tools; } function buildSkillCategoriesBlock(tools) { const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []])); for (const tool of tools) { grouped.get(categorize(tool.name)).push(tool.name); } const lines = [SKILL_BEGIN]; for (const category of CATEGORY_ORDER) { const names = grouped.get(category); if (!names.length) continue; lines.push( `- **${CATEGORY_LABELS[category]}** — ${names.map((n) => `\`${n}\``).join(", ")}`, ); } lines.push(SKILL_END); return lines.join("\n"); } function buildExtensionToolsBlock(tools) { const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []])); for (const tool of tools) { grouped.get(categorize(tool.name)).push(tool); } const lines = [EXT_TOOLS_BEGIN, ""]; for (const category of CATEGORY_ORDER) { const categoryTools = grouped.get(category); if (!categoryTools.length) continue; lines.push(CATEGORY_HEADERS[category]); lines.push(""); for (const tool of categoryTools) { lines.push(`### ${tool.name}`); lines.push(""); lines.push(tool.description || tool.label || "No description provided."); lines.push(""); if (tool.parameters.length === 0) { lines.push("No parameters."); } else { lines.push("| Parameter | Type | Required | Description |"); lines.push("|-----------|------|----------|-------------|"); for (const param of tool.parameters) { lines.push( `| \`${param.name}\` | ${param.type} | ${param.required ? "✓" : "—"} | ${param.description || ""} |`, ); } } lines.push(""); } } lines.push(EXT_TOOLS_END); return lines.join("\n"); } function buildCapabilitiesTableBlock(tools) { const lines = [ CAP_TABLE_BEGIN, "| Tool | Purpose |", "|------|---------|", ]; for (const tool of tools) { lines.push(`| \`${tool.name}\` | ${tool.description || tool.label || ""} |`); } lines.push(CAP_TABLE_END); return lines.join("\n"); } function replaceBlock(content, beginMarker, endMarker, newBlock, filePath) { const beginIdx = content.indexOf(beginMarker); const endIdx = content.indexOf(endMarker); if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { throw new Error( `Could not find markers in ${filePath}: ${beginMarker} ... ${endMarker}`, ); } const endLineEnd = endIdx + endMarker.length; return content.slice(0, beginIdx) + newBlock + content.slice(endLineEnd); } function main() { const checkOnly = process.argv.includes("--check"); const extensionSource = readFileSync(extensionPath, "utf-8"); const tools = extractTools(extensionSource); const files = [ { path: skillPath, begin: SKILL_BEGIN, end: SKILL_END, block: buildSkillCategoriesBlock(tools), label: "SKILL.md tool-categories", }, { path: extensionToolsPath, begin: EXT_TOOLS_BEGIN, end: EXT_TOOLS_END, block: buildExtensionToolsBlock(tools), label: "extension-tools.md", }, { path: capabilitiesPath, begin: CAP_TABLE_BEGIN, end: CAP_TABLE_END, block: buildCapabilitiesTableBlock(tools), label: "fusion-capabilities.md tool table", }, ]; let changedCount = 0; const driftLabels = []; for (const file of files) { const current = readFileSync(file.path, "utf-8"); const updated = replaceBlock(current, file.begin, file.end, file.block, file.path); if (updated !== current) { changedCount++; driftLabels.push(file.label); if (!checkOnly) { writeFileSync(file.path, updated); } } } if (checkOnly) { if (changedCount > 0) { console.error( `[sync-fusion-skill-tools] Drift detected in ${driftLabels.join(", ")}.\n` + "Run `node scripts/sync-fusion-skill-tools.mjs` to regenerate synced docs.", ); process.exit(1); } return; } if (changedCount === 0) { console.log( `[sync-fusion-skill-tools] All generated docs are up to date (${tools.length} tools).`, ); return; } console.log( `[sync-fusion-skill-tools] Updated ${changedCount} file(s) from extension.ts (${tools.length} tools).`, ); } main();