comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
750 lines (702 loc) • 32.5 kB
text/typescript
/**
* Self-documenting tool reference generator.
*
* Boots the real MCP server with a capturing mock, reads every registered
* tool's name + description + zod input schema, and emits Mintlify MDX pages
* (grouped by category) plus the matching `navigation` tab in docs/docs.json.
*
* Run: npm run docs:gen (which sets COMFYUI_URL so config.ts skips its
* network port-probe at import time).
*
* Re-run any time tools change — the Tool Reference stays in sync with code.
*/
import {
mkdirSync,
writeFileSync,
readFileSync,
readdirSync,
existsSync,
mkdtempSync,
renameSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
// zod 4 ships JSON Schema conversion natively (z.toJSONSchema) — no zod-to-json-schema.
import { registerAllTools } from "../src/tools/index.js";
import { TOOL_DOC_EXAMPLES, type ToolDocEntry } from "./tool-doc-examples.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const docsRoot = join(repoRoot, "docs");
const toolsDir = join(docsRoot, "tools");
const docsJsonPath = join(docsRoot, "docs.json");
// ---------------------------------------------------------------------------
// Capture registered tools via a mock McpServer.
// ---------------------------------------------------------------------------
interface CapturedTool {
name: string;
description: string;
shape: z.ZodRawShape;
}
const captured: CapturedTool[] = [];
const mockServer = {
// The codebase registers tools as tool(name, description, zodShape, handler).
tool(name: string, a?: unknown, b?: unknown, _c?: unknown) {
if (typeof a === "string" && b && typeof b === "object") {
captured.push({ name, description: a, shape: b as z.ZodRawShape });
} else if (typeof a === "string") {
captured.push({ name, description: a, shape: {} });
} else if (a && typeof a === "object") {
captured.push({ name, description: "", shape: a as z.ZodRawShape });
} else {
captured.push({ name, description: "", shape: {} });
}
// Return a RegisteredTool-like stub.
return { update() {}, remove() {}, enable() {}, disable() {} };
},
};
// ---------------------------------------------------------------------------
// Category grouping (ordered). Each category becomes one MDX reference page.
// ---------------------------------------------------------------------------
const CATEGORIES: Array<{
group: string;
slug: string;
icon: string;
description: string;
tools: string[];
}> = [
{
group: "Image & Audio Generation",
slug: "image-generation",
icon: "image",
description: "High-level text-to-image, audio, video and 3D generation, conditioned image variants, and the two post-processing passes — all nine actions of one tool since 0.50.0 slice 16.",
tools: [
"generate_image",
],
},
{
group: "Workflow Execution",
slug: "workflow-execution",
icon: "play",
description: "Enqueue workflows — one at a time, from a named template, or as a batch — and inspect the queue, jobs, history, and system stats.",
tools: [
// 0.50.0 slice 16 folded the re-run, run-from-URL and template
// entrypoints into `enqueue_workflow`, and the diagnosis + local
// settings-history views into `get_history`.
"enqueue_workflow", "get_system_stats", "queue", "get_history",
"calculate",
// Batch execution: submit many prompts under one batch_id, then poll/await it.
"batch",
],
},
{
group: "Workflow Authoring",
slug: "workflow-authoring",
icon: "pen-ruler",
description: "Build, modify, validate, and visualize ComfyUI workflows.",
// 0.50.0 slice 14: create_workflow absorbed modify/validate/node_info and
// visualize_workflow absorbed the hierarchical, mermaid and DSL conversions,
// so this group is two entries rather than nine. query/prompt_director moved
// to Workflow Library with the rest of get_workflow's read actions.
tools: ["create_workflow", "visualize_workflow"],
},
{
group: "Workflow Library",
slug: "workflow-library",
icon: "folder-open",
description: "Save, load, strip/slice, analyze, and extract workflows.",
tools: ["get_workflow", "save_workflow"],
},
{
group: "Assets & Images",
slug: "assets-images",
icon: "images",
description: "View, convert, and upload generated images; analyze colors; stage outputs as inputs; upload media inputs; browse outputs.",
tools: [
// 0.50.0 slice 15 folded twelve names into these two: `get_image` is the
// read/inspect half (get, view, list_outputs, convert, plus the colour
// measure and the asset-registry reads) and `upload_image` the write half
// (image, video, audio, stage, output).
"get_image", "upload_image",
],
},
{
group: "Models",
slug: "models",
icon: "box",
description: "Search (HuggingFace + CivitAI), download, list, and remove models; resolve a workflow's missing models with VRAM-aware candidates; manage embeddings and VRAM.",
tools: [
// 0.50.0 slice 11 folded fourteen model tools into two: download_model
// (8 actions) and list_local_models (6). Both survivors keep their names,
// so this list simply shrinks.
"download_model", "list_local_models",
"clear_vram",
"model_metadata",
],
},
{
group: "Custom Nodes",
slug: "custom-nodes",
icon: "puzzle",
description: "Discover, install, update, snapshot, bisect, scaffold, and publish custom node packs.",
tools: [
// 0.50.0 slice 12 folded twenty names into these three: registry
// discovery into `search_custom_nodes`, Manager lifecycle into
// `install_custom_node`, the author loop into `node_pack`.
"search_custom_nodes", "install_custom_node", "node_pack",
"node_snapshot", "bisect",
],
},
{
group: "API Nodes",
slug: "api-nodes",
icon: "cloud",
description: "Discover and run hosted partner / API nodes (comfy.org).",
tools: ["list_api_nodes"],
},
{
group: "Install & Environment",
slug: "install-environment",
icon: "wrench",
description: "Install/update ComfyUI and the sidebar panel, self-update the MCP server, apply a setup manifest, manage workspaces, inspect the environment, configure ComfyUI-Manager, report issues.",
tools: [
"install_comfyui",
"apply_manifest", "workspace",
"report_issue",
"kitchen",
],
},
{
group: "Process Control",
slug: "process-control",
icon: "power",
description: "Start, stop, and restart the ComfyUI process.",
tools: ["restart_comfyui"],
},
{
group: "Defaults",
// Slug deliberately unchanged: it is the published URL. The GROUP outgrew its name when
// stats and skills moved elsewhere (stats onto get_history in 0.50.0 slice 16, skills to
// the Skills & Knowledge page), leaving a page titled for three subjects that documents
// one tool. Renaming the slug too would 404 the existing page for a cosmetic win.
slug: "defaults-stats-skills",
icon: "sliders",
description: "Generation defaults and ComfyUI frontend UI settings. Stats now live on get_system_stats and get_history; skills on the Skills & Knowledge page.",
tools: [
"get_defaults",
],
},
{
group: "RunPod (cloud GPU)",
slug: "runpod",
icon: "server",
description: "Deploy, connect, monitor, and stop RunPod cloud GPU pods, and switch rendering between your local rig and a pod — the tools behind the panel/mobile RunPod control panel.",
// 0.50.0 slice 8 folded the eleven runpod_* tools into these two.
tools: ["runpod", "runpod_watch"],
},
{
group: "LoRA Training",
slug: "training",
icon: "graduation-cap",
description: "Train character LoRAs (FLUX.1-dev) via ostris ai-toolkit — locally in a GPU Docker image or on a rented RunPod pod — with a crash-safe job registry and streamed progress.",
// 0.50.0 slice 10 folded eighteen train_* tools into three, split by
// work-domain: the datasets a run consumes, the jobs that consume them, and
// the trainer machinery itself.
tools: ["train_prepare_dataset", "train_start", "train_doctor"],
},
{
group: "Apps (micro-apps)",
slug: "apps",
icon: "layout-grid",
description: "List, inspect, and run the panel's micro-apps — named, one-click workflow apps with exposed inputs — for canvas-less clients (mobile).",
tools: ["apps"],
},
{
group: "comfy-cli",
slug: "comfy-cli",
icon: "terminal",
description: "Drive the official comfy-cli — managed server lifecycle, jobs, loaded-node search, workflow validation/execution, uploads/downloads, model discovery, and official agent skills.",
tools: ["comfy_cli"],
},
];
// Hand-written reference pages (custom prose, NOT generated from the tool schemas).
// docs:gen appends them to the nav and treats their tools as "covered" (so they
// don't trip the uncategorized warning), but NEVER overwrites their .mdx. Use this
// for narrative pages that document a set of tools more richly than the generated
// skeletons — e.g. skills-knowledge, which explains the skills/packs/templates +
// cost-guardrail tools together.
const HAND_WRITTEN_PAGES: Array<{ slug: string; tools: string[] }> = [
{
slug: "skills-knowledge",
// One tool since 0.50.0 slice 9: the nine knowledge tools folded into
// `list_packs`, whose ten actions this page documents by hand.
tools: ["list_packs"],
},
];
// ---------------------------------------------------------------------------
// JSON Schema → MDX rendering helpers
// ---------------------------------------------------------------------------
type JsonSchema = {
type?: string | string[];
description?: string;
enum?: unknown[];
default?: unknown;
items?: JsonSchema;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
properties?: Record<string, JsonSchema>;
required?: string[];
};
function typeLabel(s: JsonSchema): string {
if (s.enum) return "enum";
if (s.anyOf || s.oneOf) {
const parts = (s.anyOf ?? s.oneOf ?? []).map(typeLabel);
return [...new Set(parts)].join(" | ") || "union";
}
if (s.type === "array") return `${s.items ? typeLabel(s.items) : "any"}[]`;
if (Array.isArray(s.type)) return s.type.filter((t) => t !== "null").join(" | ");
return s.type ?? "any";
}
function esc(text: string): string {
// Keep MDX happy: collapse INLINE whitespace per line but preserve line breaks —
// a newline-separated list in a tool description must stay a list in the docs,
// not render as one long paragraph — and escape characters MDX would parse as
// JSX — angle brackets (e.g. "<COMFYUI_PATH>") and curly braces (expressions).
const map: Record<string, string> = { "<": "<", ">": ">", "{": "{", "}": "}" };
return text
.split("\n")
.map((line) => line.replace(/\s+/g, " ").trim())
.join("\n")
.replace(/[<>{}]/g, (m) => map[m]);
}
function renderParam(name: string, schema: JsonSchema, required: boolean): string {
const attrs = [`path="${name}"`, `type="${typeLabel(schema)}"`];
if (required) attrs.push("required");
if (schema.default !== undefined) {
attrs.push(`default="${String(schema.default).replace(/"/g, """)}"`);
}
const body: string[] = [];
if (schema.description) body.push(esc(schema.description));
if (schema.enum) {
// The `action` field gets its options rendered as the CALL FORM rather than
// bare values. Two reasons, and the first is the reader's: on the 0.50.0
// surface `action` is the only required field on most tools, so `action:"get"`
// is the thing to copy, where a bare `get` still has to be assembled. The
// second is mechanical — several folds reused a retired TOOL name as an
// action name, and a bare option value in generated prose is then
// indistinguishable from an instruction to call a tool that 404s. The call
// form says which it is, in the one syntax the dead-name gate recognises
// repo-wide. Any other enum field keeps the plain value list.
const opt = (e: unknown) => (name === "action" ? `\`action:"${String(e)}"\`` : `\`${String(e)}\``);
body.push(`Options: ${schema.enum.map(opt).join(", ")}.`);
}
// A description and its `Options:` list go on SEPARATE lines. Joining them with a
// space is not merely cosmetic here: the dead-name gate reasons PER LINE, and its
// exemptions cover one occurrence of a name per line on purpose (a line carrying two
// mentions is ambiguous, so it fails closed and "must be split"). Welding the two
// pieces together manufactures exactly that ambiguity — a description that mentions a
// retired name as history, glued to an `action:"…"` option that legitimately contains
// it, becomes one unsplittable line the gate cannot accept and no author can fix
// without editing generated output. Emitting them separately keeps each piece
// individually judgeable, which is what the per-line rule assumes.
return `<ParamField ${attrs.join(" ")}>\n ${body.join("\n ") || "—"}\n</ParamField>`;
}
// Most examples can be derived from JSON Schema required fields. Flat action
// schemas deliberately keep action-specific required fields optional in that
// schema (the MCP SDK does not render discriminated unions correctly), so they
// need a complete, valid representative call here instead of an unusable
// action-only skeleton.
const EXAMPLE_ARG_OVERRIDES: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
model_metadata: {
action: "read",
category: "loras",
name: "my_model.safetensors",
},
workspace: {
action: "set_default",
path: "/opt/ComfyUI",
},
// 0.50.0 slice 10. Without these the generated skeletons are `{"action": …}`
// alone, which every one of these tools rejects with a missing-field error —
// an example a reader copies and watches fail. train_doctor needs no override:
// its default action:"doctor" takes no other parameters and is a valid call.
train_prepare_dataset: {
action: "prepare",
name: "aria_character",
items: [{ path: "/photos/aria_01.png", caption: "ohwx person, side profile, window light" }],
defaultCaption: "ohwx person",
},
train_start: {
action: "start",
name: "aria_character",
datasetPath: "/home/me/.comfyui-mcp/training/datasets/aria_character",
trigger: "ohwx person",
},
};
function exampleArgs(toolName: string, jsonSchema: JsonSchema): Record<string, unknown> {
const out: Record<string, unknown> = {};
const props = jsonSchema.properties ?? {};
const required = new Set(jsonSchema.required ?? []);
for (const [name, s] of Object.entries(props)) {
if (!required.has(name)) continue;
if (s.enum) out[name] = s.enum[0];
else if (typeLabel(s).startsWith("string") || typeLabel(s) === "enum") out[name] = `<${name}>`;
else if (s.type === "number" || s.type === "integer") out[name] = 0;
else if (s.type === "boolean") out[name] = true;
else if (s.type === "array") out[name] = [];
else out[name] = `<${name}>`;
}
return { ...out, ...EXAMPLE_ARG_OVERRIDES[toolName] };
}
function firstSentence(desc: string): string {
const m = desc.match(/^.*?[.!?](\s|$)/);
return (m ? m[0] : desc).trim();
}
/**
* Validate every curated example in scripts/tool-doc-examples.ts against the
* tool's REAL zod schema, and fail generation if any of them is wrong.
*
* This exists because a documented call that does not typecheck is worse than no
* example at all: a reader copies it, it 400s, and they conclude the tool is
* broken. Prose cannot be unit-tested, so this is where examples get tested.
*
* Three distinct failures, each caught deliberately:
*
* 1. A key for a tool that no longer exists. During the consolidation tools are
* being renamed and folded together; an example still keyed by a retired name
* must become a loud failure, not a silently-ignored map entry.
*
* 2. A field that is not in the schema. zod objects STRIP unknown keys by
* default, so `safeParse` alone happily accepts `{"filenmae": "x"}` and
* reports success — the misspelling would ship. Hence the explicit key check
* against the JSON Schema's properties, before parsing.
*
* 3. A field with the wrong type or a missing required one. That is what
* `safeParse` is for, run against the same shape the MCP SDK advertises.
*/
function validateExamples(byName: Map<string, CapturedTool>): void {
const problems: string[] = [];
for (const [toolName, entry] of Object.entries(TOOL_DOC_EXAMPLES)) {
const tool = byName.get(toolName);
if (!tool) {
problems.push(
`${toolName}: no such tool is registered — it was probably renamed or ` +
`consolidated. Update or remove its entry in scripts/tool-doc-examples.ts.`,
);
continue;
}
const schema = z.object(tool.shape);
const json = z.toJSONSchema(schema, { reused: "inline", io: "input" }) as JsonSchema;
const known = new Set(Object.keys(json.properties ?? {}));
entry.examples.forEach((ex, i) => {
const where = `${toolName} example #${i + 1} ("${ex.ask}")`;
const unknownKeys = Object.keys(ex.args).filter((k) => !known.has(k));
if (unknownKeys.length > 0) {
problems.push(
`${where}: field(s) ${unknownKeys.map((k) => `\`${k}\``).join(", ")} ` +
`do not exist on this tool. Known fields: ${[...known].join(", ") || "(none)"}.`,
);
// Still parse below: a typo'd key is stripped, so the parse would pass
// and hide any OTHER problem in the same example.
}
const parsed = schema.safeParse(ex.args);
if (!parsed.success) {
const detail = parsed.error.issues
.map((iss) => `${iss.path.join(".") || "(root)"}: ${iss.message}`)
.join("; ");
problems.push(`${where}: does not satisfy the tool's schema — ${detail}`);
}
});
}
if (problems.length > 0) {
throw new Error(
`[gen-tool-docs] ${problems.length} documented example(s) do not match the live tool ` +
`schemas:\n ${problems.join("\n ")}\n` +
`Fix the example in scripts/tool-doc-examples.ts — do NOT relax this check. ` +
`An example that does not typecheck will be copied by a reader and will fail.`,
);
}
}
/** Render the curated examples for one tool, or the generated skeleton if it has none. */
function renderExamples(t: CapturedTool, json: JsonSchema, entry?: ToolDocEntry): string[] {
const lines: string[] = [];
if (!entry || entry.examples.length === 0) {
lines.push("### Example", "");
lines.push(
"<Note>No worked example yet — the call below is a skeleton generated from the " +
"required parameters. Real examples live in `scripts/tool-doc-examples.ts`; " +
"contributions welcome.</Note>",
"",
);
lines.push(
"```json",
JSON.stringify({ tool: t.name, arguments: exampleArgs(t.name, json) }, null, 2),
"```",
"",
);
return lines;
}
lines.push(entry.examples.length === 1 ? "### Example" : "### Examples", "");
for (const ex of entry.examples) {
// The ASK comes first on purpose. The audience for this page mostly does not
// type JSON at anything — they say a sentence to an agent and it makes the
// call. Leading with the sentence shows which request reaches this tool;
// leading with the JSON implies a calling convention they will never use.
lines.push(`**You say:** ${esc(ex.ask)}`, "");
lines.push(
"```json",
JSON.stringify({ tool: t.name, arguments: ex.args }, null, 2),
"```",
"",
);
if (ex.argsNote) lines.push(`<Note>${esc(ex.argsNote)}</Note>`, "");
lines.push(`**You get back:** ${esc(ex.returns)}`, "");
if (ex.caution) lines.push(`<Warning>${esc(ex.caution)}</Warning>`, "");
}
return lines;
}
function renderTool(t: CapturedTool): string {
// `io: "input"` matches what the MCP SDK advertises to clients
// (sdk/server/zod-json-schema-compat.js defaults pipeStrategy to 'input').
//
// Omitting it meant OUTPUT semantics, under which a field with `.default()` is
// always present and therefore listed as required. So every defaulted parameter was
// documented as REQUIRED while the real tools/list schema said optional — 25
// parameters across 18 tools, e.g. comfy_cli.detail, declared
// `.optional().default("env")` and rendered `required default="env"`. A reader
// supplying every "required" field is doing needless work; a model reading it may
// refuse to call the tool without them. The docs-freshness gate stayed green
// throughout because it faithfully regenerated the same wrong answer.
const json = z.toJSONSchema(z.object(t.shape), {
reused: "inline",
io: "input",
}) as JsonSchema;
const props = json.properties ?? {};
const required = new Set(json.required ?? []);
const paramNames = Object.keys(props);
const entry = TOOL_DOC_EXAMPLES[t.name];
const lines: string[] = [];
lines.push(`## ${t.name}`, "");
lines.push(esc(t.description), "");
// The gloss is ADDITIVE. The description above is written for model dispatch —
// it disambiguates this tool from its neighbours in the terms a model needs,
// which is why it talks about context cost and about which tool NOT to pick.
// Rewriting it for readability measurably degrades tool choice (#557/#654), so
// a human-facing sentence goes here, next to it, instead of over it.
if (entry?.gloss) {
lines.push(`<Tip>**In plain terms:** ${esc(entry.gloss)}</Tip>`, "");
}
if (paramNames.length > 0) {
lines.push("### Parameters", "");
for (const name of paramNames) {
lines.push(renderParam(name, props[name], required.has(name)));
}
lines.push("");
} else {
lines.push("<Note>This tool takes no parameters.</Note>", "");
}
lines.push(...renderExamples(t, json, entry));
lines.push("---", "");
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Don't let a developer's local autoloaded workflows (COMFYUI_WORKFLOWS_DIR
// or ~/.comfyui-mcp/workflows) register and overwrite built-in tool docs —
// point autoload at an empty temp dir so only built-in tools are captured.
process.env.COMFYUI_WORKFLOWS_DIR = mkdtempSync(
join(tmpdir(), "comfyui-mcp-docs-"),
);
await registerAllTools(mockServer as never);
const byName = new Map(captured.map((t) => [t.name, t]));
// Before ANY page is rendered — a bad example must abort the run, not be
// written into 16 files and then reported.
validateExamples(byName);
const mapped = new Set<string>();
mkdirSync(toolsDir, { recursive: true });
const navPages: string[] = [];
// Pages are BUFFERED, not written as they are rendered, so the fatal checks
// below run before anything touches docs/. Writing inside the loop meant an
// unmapped-tool throw left the tree half-regenerated: some pages updated, some
// not, and a `git diff` that mixes the real change with debris from a failed
// run. Generation is now all-or-nothing with respect to those checks.
const pending: Array<{ path: string; content: string }> = [];
for (const cat of CATEGORIES) {
const present = cat.tools.filter((n) => byName.has(n));
present.forEach((n) => mapped.add(n));
if (present.length === 0) continue;
const page: string[] = [];
page.push("---");
page.push(`title: "${cat.group}"`);
page.push(`description: "${cat.description}"`);
page.push(`icon: "${cat.icon}"`);
page.push("---");
page.push("");
page.push(`<Info>${present.length} tool${present.length === 1 ? "" : "s"}. Generated from the live MCP tool schemas — do not edit by hand; run \`npm run docs:gen\`.</Info>`);
page.push("");
// Every page carries this. The JSON below is what the AGENT sends; readers
// arriving from a search result were being shown a calling convention they
// will never type, with nothing on the page to say so.
page.push(
"<Tip>**You don't type these calls.** Ask your agent for what you want in " +
"ordinary English — it chooses the tool and fills in the arguments. The JSON " +
"on this page is what it sends. New here? Start with " +
"[Using the tools](/using-tools).</Tip>",
);
page.push("");
for (const name of present) page.push(renderTool(byName.get(name)!));
pending.push({ path: join(toolsDir, `${cat.slug}.mdx`), content: page.join("\n") });
navPages.push(`tools/${cat.slug}`);
}
// Append hand-written reference pages to the nav. Their .mdx is hand-maintained,
// never written here.
//
// Existence is checked BEFORE their tools count as covered. The old order marked
// them mapped unconditionally and only warned about a missing page, so every tool
// on a hand-written page that did not exist was exempted from the fatal check
// below while having no documentation at all — a hole shaped exactly like the one
// that check was added to close.
const missingHandWritten: string[] = [];
for (const hw of HAND_WRITTEN_PAGES) {
if (existsSync(join(toolsDir, `${hw.slug}.mdx`))) {
hw.tools.forEach((n) => mapped.add(n));
navPages.push(`tools/${hw.slug}`);
} else {
missingHandWritten.push(`docs/tools/${hw.slug}.mdx (${hw.tools.length} tool(s))`);
}
}
if (missingHandWritten.length > 0) {
throw new Error(
`[gen-tool-docs] ${missingHandWritten.length} hand-written page(s) are referenced by ` +
`HAND_WRITTEN_PAGES but do not exist, so their tools would be undocumented AND the nav ` +
`would link nowhere:\n ${missingHandWritten.join("\n ")}\n` +
`Create the page, or move those tools into a generated CATEGORIES entry.`,
);
}
// FATAL, not a warning: an unmapped tool is silently absent from the published
// Tool Reference, and a warning in a passing build is a warning nobody reads —
// that is how 18 tools (all the batch/template tools and most of train_*) went
// undocumented. Failing here means adding a tool forces a docs decision, and
// during the 181 -> 29 consolidation it means a consolidated tool cannot land
// without its reference page.
//
// Do NOT "fix" a failure here by bulk-adding names to a category you haven't
// read — that recreates the warning with extra steps. Put the tool where a
// reader would look for it, or add it to HAND_WRITTEN_PAGES.
const unmapped = captured.map((t) => t.name).filter((n) => !mapped.has(n));
if (unmapped.length > 0) {
throw new Error(
`[gen-tool-docs] ${unmapped.length} tool(s) are not in any CATEGORIES entry or HAND_WRITTEN_PAGES, ` +
`so they would be missing from the Tool Reference:\n ${unmapped.join("\n ")}\n` +
`Assign each one in scripts/gen-tool-docs.ts.`,
);
}
// An .mdx in docs/tools/ that no longer belongs to any category or hand-written
// page is still PUBLISHED — it just falls out of the nav, so it documents a
// surface nobody can navigate to and nothing regenerates. Phase 5 restructures
// every category, which is exactly when orphans appear. Reported, never deleted:
// removing a file the author may have hand-written is not this script's call.
// Derived from the pages actually GENERATED this run, not from every declared
// CATEGORY. A category whose tools have all been removed hits the `continue`
// above, so no page is written for it — but listing it as "expected" anyway kept
// its now-stale page permanently exempt: still published, still unreachable from
// the nav, and never regenerated. Phase 5 empties categories wholesale, so this
// is the common case, not an edge one.
const expectedPages = new Set([
...pending.map((w) => basename(w.path)),
...HAND_WRITTEN_PAGES.map((h) => `${h.slug}.mdx`),
]);
const orphans = readdirSync(toolsDir)
.filter((f) => f.endsWith(".mdx") && !expectedPages.has(f))
.sort();
if (orphans.length > 0) {
throw new Error(
`[gen-tool-docs] ${orphans.length} page(s) in docs/tools/ belong to no CATEGORIES entry ` +
`or HAND_WRITTEN_PAGES, so they are published but unreachable from the nav:\n ` +
`${orphans.join("\n ")}\n` +
`Delete them, or add the owning entry back to scripts/gen-tool-docs.ts.`,
);
}
// docs.json's SHAPE is validated before any page is written. Buffering the pages
// was only half the fix: the generator still wrote all 16 of them and then threw
// on a malformed navigation.tabs, leaving exactly the partial-regeneration debris
// the buffering was introduced to prevent.
type LangEntry = { language?: string; default?: boolean; tabs?: unknown };
let docsJson:
| { navigation?: { tabs?: unknown; languages?: LangEntry[] } }
| undefined;
/**
* Where the English tabs live. Once docs.json is localized, `navigation.tabs` moves under
* `navigation.languages[<default>].tabs` — the Tool Reference is English-only and belongs to
* that entry, not to a translated one. Returning the holder rather than the array keeps the
* splice below writing through to the real object in both shapes.
*/
const tabsHolder = (): { tabs?: unknown } | undefined => {
const nav = docsJson?.navigation;
if (!nav) return undefined;
if (Array.isArray(nav.languages)) {
return nav.languages.find((l) => l.default) ?? nav.languages[0];
}
return nav;
};
if (existsSync(docsJsonPath)) {
docsJson = JSON.parse(readFileSync(docsJsonPath, "utf-8"));
const tabsValue = tabsHolder()?.tabs;
if (docsJson!.navigation && !Array.isArray(tabsValue)) {
throw new Error(
"docs.json navigation.tabs is not an array — aborting so we don't corrupt the config.",
);
}
// Each ENTRY is validated too, not just the container. `{"tabs":[null]}` satisfied
// Array.isArray, so all 16 pages were written and the code then threw dereferencing
// `t.tab` — leaving exactly the partial-regeneration debris this pre-write check was
// introduced to prevent. A shape check that stops at the outer type is not a shape
// check.
if (Array.isArray(tabsValue)) {
const bad = tabsValue.findIndex(
(t) => !t || typeof t !== "object" || typeof (t as { tab?: unknown }).tab !== "string",
);
if (bad >= 0) {
throw new Error(
`docs.json navigation.tabs[${bad}] is not an object with a string "tab" — ` +
`aborting before any page is written.`,
);
}
}
}
// Every check passed — now the tree may change.
for (const { path, content } of pending) writeFileSync(path, content);
// Splice the generated "Tools" tab into docs.json (preserve everything else).
if (docsJson) {
// Shape already validated above, before any page was written.
const holder = tabsHolder();
const tabs: Array<{ tab: string; groups?: unknown[] }> =
(holder?.tabs as Array<{ tab: string; groups?: unknown[] }>) ?? [];
const toolsTab = {
tab: "Tool Reference",
groups: [{ group: "Tools", pages: navPages }],
};
const idx = tabs.findIndex((t) => t.tab === "Tool Reference");
if (idx >= 0) tabs[idx] = toolsTab;
else tabs.push(toolsTab);
// Write back through the holder so the localized shape updates the default language's
// tabs in place, rather than resurrecting a top-level navigation.tabs beside them.
if (holder) holder.tabs = tabs;
else docsJson.navigation = { ...docsJson.navigation, tabs };
// Write atomically (temp + rename) so a crash mid-write can't leave a
// half-written docs.json.
const tmp = `${docsJsonPath}.tmp`;
writeFileSync(tmp, JSON.stringify(docsJson, null, 2) + "\n");
renameSync(tmp, docsJsonPath);
}
console.log(
`[gen-tool-docs] wrote ${navPages.length} reference pages covering ${mapped.size}/${captured.length} tools.`,
);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});