From f4ef151494a574e5a84be2bb8a27f457f88801c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:34:00 +0000 Subject: [PATCH 1/7] feat(help): per-command help, help/version word-forms, did-you-mean Makes `forge --help` real (it was advertised in the banner but silently dropped by each command's flag filter) by intercepting --help/-h centrally in the dispatcher before command dispatch. Adds word forms `forge help [cmd]` and `forge version`, and a "did you mean" suggestion on unknown commands via a character-bigram suggest() over the existing setOverlap. COMMANDS entries may now be a summary string OR a rich {summary,usage,flags, examples,env} object; both coexist and are read through commandSummary/commandHelp (printHelp, the only value-reader, goes through commandSummary). docs_check reads only Object.keys(COMMANDS), so the migration is safe. High-traffic commands (init/sync/doctor/update) migrated to rich help; the rest stay strings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Ebv4QDTqzPzgApBW2KUsU --- CHANGELOG.md | 11 + src/cli.js | 618 ++++++++++++++++++++++++++++++++++------------ src/commands.js | 143 +++++++++-- src/help.js | 51 ++++ src/math.js | 33 +++ test/help.test.js | 54 ++++ 6 files changed, 738 insertions(+), 172 deletions(-) create mode 100644 src/help.js create mode 100644 test/help.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d4575..813f7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Per-command help + word forms.** `forge --help` / `-h` now works for + every command (intercepted centrally in the dispatcher, no longer silently dropped), + alongside the word forms `forge help [command]` and `forge version`. Help renders from + the same `COMMANDS` table the docs check reconciles: entries may now be a plain summary + string OR a rich `{summary, usage, flags, examples, env}` object (both coexist; the + high-traffic commands are migrated), read through new `commandSummary`/`commandHelp` + helpers. An unknown command now suggests the nearest match ("did you mean …?") via a + character-bigram `suggest()` over the existing `setOverlap` similarity. + ## [0.20.0] - 2026-07-17 ### Added diff --git a/src/cli.js b/src/cli.js index 55a61ea..0c34933 100755 --- a/src/cli.js +++ b/src/cli.js @@ -4,17 +4,21 @@ import { BRAND } from "./brand.js"; // The command surface lives in commands.js as data — docs_check.js reconciles the // README/GUIDE tables against the same table this help is rendered from. -import { COMMANDS, GROUPS } from "./commands.js"; +import { COMMANDS, commandSummary, GROUPS } from "./commands.js"; +import { printCommandHelp } from "./help.js"; +import { suggest } from "./math.js"; // Color is capability-gated (FORCE_COLOR > NO_COLOR > TERM=dumb > TTY) — piped // output stays byte-plain, so nothing downstream ever parses an escape code. import { bar, heading as fmtHeading, paint, table } from "./fmt.js"; -const printVersion = () => console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.version}`); +const printVersion = () => + console.log(`${BRAND.brand} (${BRAND.pkg}) v${BRAND.version}`); // Per-command title lines ("Forge — …") are branding chrome, not results. They // print only when asked (`--verbose` or FORGE_VERBOSE=1); by default a command emits // just its output. The `--help`/`--version` banner is unaffected. -const VERBOSE = process.argv.includes("--verbose") || process.env.FORGE_VERBOSE === "1"; +const VERBOSE = + process.argv.includes("--verbose") || process.env.FORGE_VERBOSE === "1"; const heading = (text) => { if (VERBOSE) console.log(fmtHeading(text)); }; @@ -26,7 +30,8 @@ function printHelp() { for (const [group, cmds] of Object.entries(GROUPS)) { console.log(`${group}:`); for (const name of cmds) { - if (COMMANDS[name]) console.log(` ${name.padEnd(12)} ${COMMANDS[name]}`); + if (COMMANDS[name]) + console.log(` ${name.padEnd(12)} ${commandSummary(name)}`); } console.log(); } @@ -36,8 +41,27 @@ function printHelp() { async function run(argv) { const [cmd] = argv; - if (!cmd || cmd === "-h" || cmd === "--help") return printHelp(); - if (cmd === "-v" || cmd === "--version") return printVersion(); + // Top-level help/version — flag AND word forms (`forge help`, `forge version`). + if (!cmd || cmd === "-h" || cmd === "--help" || (cmd === "help" && !argv[1])) + return printHelp(); + if (cmd === "-v" || cmd === "--version" || cmd === "version") + return printVersion(); + // Per-command help by word form: `forge help `. + if (cmd === "help") { + process.exitCode = printCommandHelp(argv[1]); + return; + } + // Central interception: `forge --help|-h` for ANY real command, BEFORE dispatch, + // so every command gets uniform help without each branch parsing its own flag (the + // gap the old banner advertised but never delivered). cortex-mcp is exempt (a server). + if ( + cmd !== "cortex-mcp" && + cmd in COMMANDS && + argv.slice(1).some((a) => a === "--help" || a === "-h") + ) { + process.exitCode = printCommandHelp(cmd); + return; + } if (cmd === "cortex-mcp") { const { serve } = await import("./cortex_mcp.js"); // stdio MCP server for other tools serve(); @@ -45,7 +69,9 @@ async function run(argv) { } if (cmd === "brand") { const { brand, cli, pkg, version, layers } = BRAND; - return console.log(JSON.stringify({ brand, cli, pkg, version, layers }, null, 2)); + return console.log( + JSON.stringify({ brand, cli, pkg, version, layers }, null, 2), + ); } if (cmd === "init") { const { init } = await import("./init.js"); @@ -54,32 +80,45 @@ async function run(argv) { targetRoot: process.cwd(), noSettings, }); - const wrote = report.filter((r) => r.action === "written").map((r) => r.target); - heading(`${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`); - console.log(` emitted: ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); + const wrote = report + .filter((r) => r.action === "written") + .map((r) => r.target); + heading( + `${BRAND.brand} init — this repo now speaks every AI tool from one source.\n`, + ); + 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\``, ); if (settings?.action === "merged" && "added" in settings) { - console.log(` settings: merged ${settings.added.join(", ")} into ${settings.path}`); + console.log( + ` settings: merged ${settings.added.join(", ")} 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)"); } if (detected) { - console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); + 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( + ` 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 { applyUpdate, applyUpdateTo, updateStatus } = + await import("./update.js"); const json = argv.includes("--json"); const toIdx = argv.indexOf("--to"); if (toIdx !== -1) { @@ -92,7 +131,8 @@ async function run(argv) { ? ` 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 if (r.instruction) + console.log(` ${r.reason}:\n ${r.instruction}`); else { console.log(` ${r.reason}`); process.exitCode = 1; @@ -108,7 +148,9 @@ async function run(argv) { ` 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\`.`); + console.log( + ` ${s.behind} commit(s) behind ${s.upstream} — run \`${BRAND.cli} update\`.`, + ); else console.log(` up to date (v${s.current}).`); return; } @@ -135,9 +177,13 @@ async function run(argv) { 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; + 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)."); + console.log( + " no known stack detected (no package.json/go.mod/Cargo.toml/… found).", + ); return; } row("languages", s.languages); @@ -162,14 +208,24 @@ async function run(argv) { process.exitCode = 1; return; } - heading(`${BRAND.brand} radar — dependency currency (rings from registry evidence)\n`); + 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)."); + 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"; + 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( @@ -185,7 +241,8 @@ async function run(argv) { n, `${d.installed ?? "?"}→${d.latest ?? "?"}`, bar(d.score ?? 0), - (d.reasons ?? []).slice(0, 2).join("; ") || paint("no risk signals", "dim"), + (d.reasons ?? []).slice(0, 2).join("; ") || + paint("no risk signals", "dim"), ]; }); console.log(table(rows)); @@ -196,7 +253,8 @@ async function run(argv) { ); 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")}`); + for (const s of r.skipped ?? []) + console.log(` ${paint(`${s.language}: ${s.reason}`, "dim")}`); return; } if (cmd === "catalog") { @@ -204,16 +262,21 @@ async function run(argv) { 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)}`); + 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