- Add generated engine-tools reference and refresh extension/capabilities docs from source definitions - Enhance sync-fusion-skill-tools script to verify and maintain skill documentation consistency - Expand skill-sync tests and enforce sync:fusion-skill:check in the workspace test pipeline - Add FN-2569 changeset documenting the published @runfusion/fusion patch update
509 lines
14 KiB
JavaScript
509 lines
14 KiB
JavaScript
#!/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 =
|
|
"<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
|
|
const SKILL_END = "<!-- END: tool-categories -->";
|
|
|
|
const EXT_TOOLS_BEGIN =
|
|
"<!-- BEGIN: extension-tools (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
|
|
const EXT_TOOLS_END = "<!-- END: extension-tools -->";
|
|
|
|
const CAP_TABLE_BEGIN =
|
|
"<!-- BEGIN: fusion-capabilities-tool-table (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
|
|
const CAP_TABLE_END = "<!-- END: fusion-capabilities-tool-table -->";
|
|
|
|
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Agent", "Skills", "Other"];
|
|
const CATEGORY_LABELS = {
|
|
Task: "Task tools",
|
|
GitHub: "GitHub tools",
|
|
Mission: "Mission tools",
|
|
Agent: "Agent tools",
|
|
Skills: "Skills tools",
|
|
Other: "Other tools",
|
|
};
|
|
|
|
const CATEGORY_HEADERS = {
|
|
Task: "## Task Tools",
|
|
GitHub: "## GitHub Tools",
|
|
Mission: "## Mission Tools",
|
|
Agent: "## Agent Tools",
|
|
Skills: "## Skills 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_agent_")) return "Agent";
|
|
if (name.startsWith("fn_skills_")) return "Skills";
|
|
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();
|