From 2361246fb7c08420b18a16b79974eff06ef09487 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:01:25 +0000 Subject: [PATCH] refactor(cli): split the run() god-function into a dispatch table (H3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The former ~2,420-line run() (a flat 44-branch `if (cmd === …)` chain, 97% of src/cli.js) is now a ~44-line dispatcher: it looks the command up in a HANDLERS map and delegates. Each command is a module-scope `async (argv, cmd)` handler — independently navigable and mergeable, ending the merge-conflict-magnet problem. Behavior is byte-identical: same argv in, same stdout/stderr/exit-code out. The pre-dispatch middleware (--help interception, first-run hint, cortex-mcp), the unknown-command fallback, the process.exitCode error model, and the main-module import guard are all preserved. Handler signatures are trimmed to the params each body actually uses, so no unused-parameter warnings are introduced. Verified: full spawn-based suite 1069 pass / 0 fail / 2 skip, biome 0 errors, tsc 0 errors, `docs check` green, and importing the package root still does not execute the CLI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C7htTiesXKLvAUtv6ET2jz --- CHANGELOG.md | 11 + src/cli.js | 4267 +++++++++++++++++++++++++------------------------- 2 files changed, 2144 insertions(+), 2134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c7a4e..b6b5d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **Split the `run()` god-function in `src/cli.js` into a dispatch table (H3).** The former + ~2,420-line `run()` (a flat 44-branch `if (cmd === …)` chain) is now a ~44-line dispatcher + that looks the command up in a `HANDLERS` map; each command is an independently navigable, + independently mergeable module-scope `async` handler. Behavior is byte-identical — the same + argv in, the same stdout/stderr/exit-code out (the full spawn-based suite is unchanged and + green) — the pre-dispatch middleware (`--help` interception, first-run hint, `cortex-mcp`), + the unknown-command fallback, `process.exitCode` error model, and the main-module import + guard are all preserved. + ## [0.26.0] - 2026-07-20 ### Added diff --git a/src/cli.js b/src/cli.js index 40e3ad6..5ea44f2 100755 --- a/src/cli.js +++ b/src/cli.js @@ -62,6 +62,11 @@ function printHelp() { console.log(`Run \`${BRAND.cli} --help\` for details.`); } +// Command dispatch table: name → async handler `(argv, cmd) => …`. Populated at module +// load by the per-command handler declarations below. run() looks a command up here. +/** @type {Record unknown>} */ +const HANDLERS = {}; + async function run(argv) { const [cmd] = argv; // Top-level help/version — flag AND word forms (`forge help`, `forge version`). @@ -89,189 +94,179 @@ async function run(argv) { serve(); return; } - if (cmd === "brand") { - const { brand, cli, pkg, version, layers } = BRAND; - return console.log(JSON.stringify({ brand, cli, pkg, version, layers }, null, 2)); - } - if (cmd === "init") { - const { init } = await import("./init.js"); - const noSettings = argv.includes("--no-settings"); - // Test/plumbing override of the merge target — not user-facing surface. - const settingsPath = process.env.FORGE_SETTINGS_PATH || undefined; - // RA-11: the settings merge touches GLOBAL state — say so before reporting it, - // on merged AND unchanged runs, with the opt-out and the reversal path. - const consentLine = (path) => - console.log( - ` settings: merging ${BRAND.brand} hooks into ${path} (GLOBAL — affects all repos). ` + - `Skip with --no-settings; reverse with \`${BRAND.cli} init --remove-settings\`.`, - ); - // --remove-settings: reverse the merge (RA-17) — strip every template-shaped hook, - // permission, statusline, and the _forge marker; user-owned entries stay untouched. - if (argv.includes("--remove-settings")) { - const { removeForgeSettings } = await import("./init.js"); - const r = removeForgeSettings({ settingsPath }); - heading(`${BRAND.brand} init — remove managed settings\n`); - if (r.action === "removed") { - console.log(` settings: removed ${r.removed.join(", ")} from ${r.path}`); - console.log(` backup: ${r.backup}`); - } else if (r.action === "noop") { - console.log(` settings: nothing to remove (${r.reason})`); - } else { - console.error(` settings: NOT modified — ${r.path}: ${r.reason}`); - process.exitCode = 1; - } - return; - } - // --settings-only: wire hooks + permissions into ~/.claude/settings.json ONLY (the - // idempotent, marker-guarded merge install.sh calls) — no repo emit, no AGENTS.md. - if (argv.includes("--settings-only")) { - // ME-22: heading + disclosure precede the merge — onSettingsNotice fires inside init - // BEFORE mergeSettings touches the file, never after. - heading(`${BRAND.brand} init — settings merge only\n`); - const { settings } = init({ - settingsOnly: true, - noSettings, - settingsPath, - onSettingsNotice: consentLine, - }); - if ( - (settings?.action === "merged" || settings?.action === "created") && - "added" in settings - ) { - const verb = settings.action === "created" ? "created" : "merged"; - const what = settings.added.length ? settings.added.join(", ") : "defaults"; - console.log(` settings: ${verb} ${what} into ${settings.path}`); - } else if (settings?.action === "unchanged" && "path" in settings) { - console.log(` settings: already up to date (${settings.path})`); - } else if (settings?.action === "skipped") { - console.log(" settings: skipped (--no-settings)"); - } else if (settings?.action === "error") { - // RA-04: a refused/failed merge must FAIL the command, not silently exit 0 — - // install.sh keys its "install incomplete" path off this exit code. - console.error(` settings: FAILED — ${settings.path}: ${settings.reason}`); - process.exitCode = 1; - } - return; - } - const profileIdx = argv.indexOf("--profile"); - const profile = profileIdx >= 0 ? argv[profileIdx + 1] : undefined; - // ME-22: emit the GLOBAL-settings disclosure BEFORE the merge mutates the file. init - // forwards `onSettingsNotice` to mergeSettings, which fires it right before touching - // ~/.claude/settings.json — so the notice always precedes the mutation it describes. - heading(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); - const { - report, - bytes, - settings, - detected, - profile: profileResult, - } = /** @type {any} */ ( - init({ - targetRoot: process.cwd(), - noSettings, - profile, - settingsPath, - onSettingsNotice: consentLine, - }) + // Dispatch table: migrated commands resolve here; run() is the thin dispatcher. + const handler = HANDLERS[cmd]; + if (handler) return handler(argv, cmd); + if (!(cmd in COMMANDS)) { + const near = suggest(cmd, Object.keys(COMMANDS)); + console.error( + `Unknown command: ${cmd}${near ? ` — did you mean \`${BRAND.cli} ${near}\`?` : ""}\n` + + `Run \`${BRAND.cli} --help\` to see commands.`, ); - if (profileResult?.error) { - console.error(` ${profileResult.error}`); - process.exitCode = 1; - return; - } - const wrote = report.filter((r) => r.action === "written").map((r) => r.target); - console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); + process.exitCode = 1; + return; + } + // A command is registered in COMMANDS but has no handler wired yet. Exit non-zero + // so scripts (and CI) treat "recognized but unimplemented" as a failure, not success. + console.error(`${BRAND.cli} ${cmd}: not wired yet — coming in a later build phase.`); + process.exitCode = 1; +} + +// ─────────────────────────── command handlers ─────────────────────────────── +// One async function per command, registered into HANDLERS above; run() dispatches +// to them. Each is independently navigable and mergeable (was one 2.4k-line run()). + +HANDLERS.brand = async () => { + const { brand, cli, pkg, version, layers } = BRAND; + return console.log(JSON.stringify({ brand, cli, pkg, version, layers }, null, 2)); +}; +HANDLERS.init = async (argv) => { + const { init } = await import("./init.js"); + const noSettings = argv.includes("--no-settings"); + // Test/plumbing override of the merge target — not user-facing surface. + const settingsPath = process.env.FORGE_SETTINGS_PATH || undefined; + // RA-11: the settings merge touches GLOBAL state — say so before reporting it, + // on merged AND unchanged runs, with the opt-out and the reversal path. + const consentLine = (path) => console.log( - ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, + ` settings: merging ${BRAND.brand} hooks into ${path} (GLOBAL — affects all repos). ` + + `Skip with --no-settings; reverse with \`${BRAND.cli} init --remove-settings\`.`, ); - // ME-19: sync is non-transactional — if a target failed mid-emit, say so instead of - // implying every tool is ready, and fail the command. - const failedTargets = report.filter((r) => r.action === "error"); - if (failedTargets.length) { - console.error( - ` status: PARTIAL — ${failedTargets.length} target(s) failed: ${failedTargets - .map((r) => r.tool) - .join(", ")} (re-run \`${BRAND.cli} sync\` after fixing)`, - ); + // --remove-settings: reverse the merge (RA-17) — strip every template-shaped hook, + // permission, statusline, and the _forge marker; user-owned entries stay untouched. + if (argv.includes("--remove-settings")) { + const { removeForgeSettings } = await import("./init.js"); + const r = removeForgeSettings({ settingsPath }); + heading(`${BRAND.brand} init — remove managed settings\n`); + if (r.action === "removed") { + console.log(` settings: removed ${r.removed.join(", ")} from ${r.path}`); + console.log(` backup: ${r.backup}`); + } else if (r.action === "noop") { + console.log(` settings: nothing to remove (${r.reason})`); + } else { + console.error(` settings: NOT modified — ${r.path}: ${r.reason}`); process.exitCode = 1; } - if (profileResult?.profile) { - console.log(` profile: ${profileResult.profile} → .forge/forge.config.json`); - if (profileResult.deprecated) { - console.error( - ` warning: profile "${profileResult.deprecated}" is deprecated — treated as "${profileResult.profile}"`, - ); - } - } + return; + } + // --settings-only: wire hooks + permissions into ~/.claude/settings.json ONLY (the + // idempotent, marker-guarded merge install.sh calls) — no repo emit, no AGENTS.md. + if (argv.includes("--settings-only")) { + // ME-22: heading + disclosure precede the merge — onSettingsNotice fires inside init + // BEFORE mergeSettings touches the file, never after. + heading(`${BRAND.brand} init — settings merge only\n`); + const { settings } = init({ + settingsOnly: true, + noSettings, + settingsPath, + onSettingsNotice: consentLine, + }); if ((settings?.action === "merged" || settings?.action === "created") && "added" in settings) { const verb = settings.action === "created" ? "created" : "merged"; const what = settings.added.length ? settings.added.join(", ") : "defaults"; console.log(` settings: ${verb} ${what} into ${settings.path}`); - if ("backup" in settings && settings.backup) - console.log(` backup: ${settings.backup}`); } else if (settings?.action === "unchanged" && "path" in settings) { console.log(` settings: already up to date (${settings.path})`); } else if (settings?.action === "skipped") { console.log(" settings: skipped (--no-settings)"); } else if (settings?.action === "error") { - // RA-04: repo files above were still emitted, but the settings merge FAILED — - // surface it on stderr and fail the command instead of a quiet exit 0. - console.error(` settings: FAILED — ${settings.reason} (repo files were still emitted)`); + // RA-04: a refused/failed merge must FAIL the command, not silently exit 0 — + // install.sh keys its "install incomplete" path off this exit code. + console.error(` settings: FAILED — ${settings.path}: ${settings.reason}`); process.exitCode = 1; } - if (detected) { - console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); - } else { - console.log( - ` provider: none detected — set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or LITELLM_BASE_URL`, - ); - } - console.log(` active: tools · crew · guards → \`${BRAND.cli} catalog\``); - console.log(` verify: \`${BRAND.cli} doctor\``); return; } - if (cmd === "update") { - const { applyUpdate, applyUpdateTo, updateStatus } = await import("./update.js"); - const json = argv.includes("--json"); - const toIdx = argv.indexOf("--to"); - if (toIdx !== -1) { - const r = applyUpdateTo(argv[toIdx + 1], {}); - if (json) return console.log(JSON.stringify(r, null, 2)); - heading(`${BRAND.brand} update — pin\n`); - if (r.ok) - console.log( - r.changed - ? ` pinned ${r.before} → ${r.after} (${r.tag}). ${r.note}` - : ` already at ${r.tag}. ${r.note}`, - ); - else if (r.instruction) console.log(` ${r.reason}:\n ${r.instruction}`); - else { - console.log(` ${r.reason}`); - process.exitCode = 1; - } - return; - } - if (argv.includes("--check")) { - const s = updateStatus({}); - if (json) return console.log(JSON.stringify(s, null, 2)); - heading(`${BRAND.brand} update — check\n`); - if (s.unknown) - console.log( - ` on v${s.current} (${s.mode}) — can't compare to upstream${s.network === "offline" ? " (offline)" : ""}; update with your installer.`, - ); - else if (s.behind > 0) - console.log(` ${s.behind} commit(s) behind ${s.upstream} — run \`${BRAND.cli} update\`.`); - else console.log(` up to date (v${s.current}).`); - return; + const profileIdx = argv.indexOf("--profile"); + const profile = profileIdx >= 0 ? argv[profileIdx + 1] : undefined; + // ME-22: emit the GLOBAL-settings disclosure BEFORE the merge mutates the file. init + // forwards `onSettingsNotice` to mergeSettings, which fires it right before touching + // ~/.claude/settings.json — so the notice always precedes the mutation it describes. + heading(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); + const { + report, + bytes, + settings, + detected, + profile: profileResult, + } = /** @type {any} */ ( + init({ + targetRoot: process.cwd(), + noSettings, + profile, + settingsPath, + onSettingsNotice: consentLine, + }) + ); + if (profileResult?.error) { + console.error(` ${profileResult.error}`); + process.exitCode = 1; + return; + } + const wrote = report.filter((r) => r.action === "written").map((r) => r.target); + console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); + console.log( + ` source: AGENTS.md (${bytes} B) — edit rules in source/, re-run \`${BRAND.cli} sync\``, + ); + // ME-19: sync is non-transactional — if a target failed mid-emit, say so instead of + // implying every tool is ready, and fail the command. + const failedTargets = report.filter((r) => r.action === "error"); + if (failedTargets.length) { + console.error( + ` status: PARTIAL — ${failedTargets.length} target(s) failed: ${failedTargets + .map((r) => r.tool) + .join(", ")} (re-run \`${BRAND.cli} sync\` after fixing)`, + ); + process.exitCode = 1; + } + if (profileResult?.profile) { + console.log(` profile: ${profileResult.profile} → .forge/forge.config.json`); + if (profileResult.deprecated) { + console.error( + ` warning: profile "${profileResult.deprecated}" is deprecated — treated as "${profileResult.profile}"`, + ); } - const r = applyUpdate({}); + } + if ((settings?.action === "merged" || settings?.action === "created") && "added" in settings) { + const verb = settings.action === "created" ? "created" : "merged"; + const what = settings.added.length ? settings.added.join(", ") : "defaults"; + console.log(` settings: ${verb} ${what} into ${settings.path}`); + if ("backup" in settings && settings.backup) + console.log(` backup: ${settings.backup}`); + } else if (settings?.action === "unchanged" && "path" in settings) { + console.log(` settings: already up to date (${settings.path})`); + } else if (settings?.action === "skipped") { + console.log(" settings: skipped (--no-settings)"); + } else if (settings?.action === "error") { + // RA-04: repo files above were still emitted, but the settings merge FAILED — + // surface it on stderr and fail the command instead of a quiet exit 0. + console.error(` settings: FAILED — ${settings.reason} (repo files were still emitted)`); + process.exitCode = 1; + } + if (detected) { + console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); + } else { + console.log( + ` provider: none detected — set ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or LITELLM_BASE_URL`, + ); + } + console.log(` active: tools · crew · guards → \`${BRAND.cli} catalog\``); + console.log(` verify: \`${BRAND.cli} doctor\``); + return; +}; +HANDLERS.update = async (argv) => { + const { applyUpdate, applyUpdateTo, updateStatus } = await import("./update.js"); + const json = argv.includes("--json"); + const toIdx = argv.indexOf("--to"); + if (toIdx !== -1) { + const r = applyUpdateTo(argv[toIdx + 1], {}); if (json) return console.log(JSON.stringify(r, null, 2)); - heading(`${BRAND.brand} update\n`); + heading(`${BRAND.brand} update — pin\n`); if (r.ok) console.log( r.changed - ? ` updated ${r.before} → ${r.after}. ${r.note}` - : ` already up to date (v${BRAND.version}).`, + ? ` pinned ${r.before} → ${r.after} (${r.tag}). ${r.note}` + : ` already at ${r.tag}. ${r.note}`, ); else if (r.instruction) console.log(` ${r.reason}:\n ${r.instruction}`); else { @@ -280,2214 +275,2218 @@ async function run(argv) { } return; } - if (cmd === "stack") { - // Dynamic: read THIS repo's manifests and report its real stack (not a static menu). - const { detectStack } = await import("./stack.js"); - const s = detectStack(process.cwd()); - if (argv.includes("--json")) return console.log(JSON.stringify(s, null, 2)); - heading(`${BRAND.brand} stack — detected from this repo's manifests\n`); - const row = (label, arr) => - arr.length ? console.log(` ${`${label}:`.padEnd(11)} ${arr.join(", ")}`) : undefined; - if (!s.languages.length) { - console.log(" no known stack detected (no package.json/go.mod/Cargo.toml/… found)."); - return; + if (argv.includes("--check")) { + const s = updateStatus({}); + if (json) return console.log(JSON.stringify(s, null, 2)); + heading(`${BRAND.brand} update — check\n`); + if (s.unknown) + console.log( + ` on v${s.current} (${s.mode}) — can't compare to upstream${s.network === "offline" ? " (offline)" : ""}; update with your installer.`, + ); + else if (s.behind > 0) + console.log(` ${s.behind} commit(s) behind ${s.upstream} — run \`${BRAND.cli} update\`.`); + else console.log(` up to date (v${s.current}).`); + return; + } + const r = applyUpdate({}); + if (json) return console.log(JSON.stringify(r, null, 2)); + heading(`${BRAND.brand} update\n`); + if (r.ok) + console.log( + r.changed + ? ` updated ${r.before} → ${r.after}. ${r.note}` + : ` already up to date (v${BRAND.version}).`, + ); + else if (r.instruction) console.log(` ${r.reason}:\n ${r.instruction}`); + else { + console.log(` ${r.reason}`); + process.exitCode = 1; + } + return; +}; +HANDLERS.stack = async (argv) => { + // Dynamic: read THIS repo's manifests and report its real stack (not a static menu). + const { detectStack } = await import("./stack.js"); + const s = detectStack(process.cwd()); + if (argv.includes("--json")) return console.log(JSON.stringify(s, null, 2)); + heading(`${BRAND.brand} stack — detected from this repo's manifests\n`); + const row = (label, arr) => + arr.length ? console.log(` ${`${label}:`.padEnd(11)} ${arr.join(", ")}`) : undefined; + if (!s.languages.length) { + console.log(" no known stack detected (no package.json/go.mod/Cargo.toml/… found)."); + return; + } + row("languages", s.languages); + row("frameworks", s.frameworks); + row("pkg mgrs", s.packageManagers); + row("test", s.testCommands); + row("tools", s.tools); + row("notes", s.notes); + console.log(` ${"evidence:".padEnd(11)} ${s.evidence.join(", ")}`); + return; +}; +HANDLERS.radar = async (argv) => { + // Dependency-currency rings from live registry evidence (mizan — every ring ships its + // evidence; missing evidence never upgrades a dep). Injectable/cached/offline-honest. + const { radarScan } = await import("./radar.js"); + const offline = argv.includes("--offline"); + const refresh = argv.includes("--refresh"); + const r = await radarScan(process.cwd(), { offline, refresh }); + if (argv.includes("--json")) return console.log(JSON.stringify(r, null, 2)); + if (!r.ok) { + console.log(`${BRAND.brand} radar — ${r.reason}`); + process.exitCode = 1; + return; + } + heading(`${BRAND.brand} radar — dependency currency (rings from registry evidence)\n`); + const deps = r.deps ?? {}; + const names = Object.keys(deps); + if (!names.length) { + console.log(" no Node dependencies to probe (no package.json deps found)."); + } else { + const roleFor = (ring) => + ring === "adopt" ? "ok" : ring === "trial" ? "accent" : ring === "hold" ? "err" : "warn"; + // Order by ring severity, then by usage (stakes), then name — the risky, load-bearing first. + const rank = { hold: 0, assess: 1, trial: 2, adopt: 3 }; + names.sort( + (a, b) => + (rank[deps[a].ring] ?? 9) - (rank[deps[b].ring] ?? 9) || + (deps[b].usage ?? 0) - (deps[a].usage ?? 0) || + (a < b ? -1 : 1), + ); + const rows = names.map((n) => { + const d = deps[n]; + return [ + paint(d.ring, roleFor(d.ring)), + n, + `${d.installed ?? "?"}→${d.latest ?? "?"}`, + bar(d.score ?? 0), + (d.reasons ?? []).slice(0, 2).join("; ") || paint("no risk signals", "dim"), + ]; + }); + console.log(table(rows)); + } + if (r.stale) + console.log( + `\n ${paint(`served from cache (${Math.round(r.ageH)}h old) — ${offline ? "--offline" : "registry unreachable"}`, "dim")}`, + ); + else if (r.source === "cache") + console.log(`\n ${paint("served from cache (within TTL)", "dim")}`); + for (const s of r.skipped ?? []) console.log(` ${paint(`${s.language}: ${s.reason}`, "dim")}`); + return; +}; +HANDLERS.catalog = async () => { + const { catalog } = await import("./init.js"); + const c = catalog(); + heading(`${BRAND.brand} catalog — Start Here\n`); + console.log(" TOOLS (model-invoked skills)"); + for (const t of c.tools) console.log(` ${t.name.padEnd(18)} ${t.why.slice(0, 66)}`); + console.log(`\n CREW (isolated sub-agents) ${c.crew.join(" · ")}`); + console.log(` GUARDS (enforced hooks) ${c.guards.join(" · ")}`); + if (c.taste?.length) + console.log( + ` TASTE (design directions) ${c.taste.join(" · ")} → \`${BRAND.cli} taste