/** * 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; 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. "") and curly braces (expressions). const map: Record = { "<": "<", ">": ">", "{": "{", "}": "}" }; 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 `\n ${body.join("\n ") || "—"}\n`; } // 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>>> = { 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 { const out: Record = {}; 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): 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( "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.", "", ); 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(`${esc(ex.argsNote)}`, ""); lines.push(`**You get back:** ${esc(ex.returns)}`, ""); if (ex.caution) lines.push(`${esc(ex.caution)}`, ""); } 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(`**In plain terms:** ${esc(entry.gloss)}`, ""); } 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("This tool takes no parameters.", ""); } 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(); 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(`${present.length} tool${present.length === 1 ? "" : "s"}. Generated from the live MCP tool schemas — do not edit by hand; run \`npm run docs:gen\`.`); 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( "**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).", ); 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[].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); });