diff --git a/.claude/hooks/block-prose-punctuation.sh b/.claude/hooks/block-prose-punctuation.sh index 1039cb635..0d22b877d 100755 --- a/.claude/hooks/block-prose-punctuation.sh +++ b/.claude/hooks/block-prose-punctuation.sh @@ -264,7 +264,7 @@ fi # positives (a wrongly blocked write), the same tradeoff as the rules above: # e.g. a sentence-ending "built on webjs." is not flagged (trailing period), # and the `bin/webjs.js` "webjs commands:" usage banner may rarely trip it. -webjs_cli='create|dev|start|test|check|db|ui|doctor|types|typecheck|mcp|vendor|help|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' +webjs_cli='create|dev|start|test|check|routes|db|ui|doctor|types|typecheck|mcp|vendor|help|version|add|init|generate|migrate|push|studio|seed|pin|unpin|list|audit|outdated|update|view|diff|info|build' # Scan copy: drop fenced code blocks, inline code spans, and emphasis markers # so a `webjs` inside code is never considered and **webjs** still matches. @@ -288,9 +288,11 @@ brand_hits=$(printf '%s\n' "$brand_scan" \ if [ -n "$brand_hits" ]; then # Drop lines whose "webjs" is a `webjs ` CLI reference. The # trailing class also admits a closing quote (" or ') so a package.json - # script value ending in a bare subcommand is a command, not brand prose. + # script value ending in a bare subcommand is a command, not brand prose, + # and an HTML tag boundary (< or >) so a docs `

webjs routes

` or + # `webjs types` reads as a command, not brand prose. offending=$(printf '%s\n' "$brand_hits" \ - | grep -vE "webjs[[:space:]]+(${webjs_cli})([[:space:]]|[.,:;)\"']|\$)" 2>/dev/null || true) + | grep -vE "webjs[[:space:]]+(${webjs_cli})([[:space:]]|[.,:;)\"'<>]|\$)" 2>/dev/null || true) if [ -n "$offending" ]; then cat >&2 <<'EOF' BLOCKED: lowercase "webjs" naming the brand in prose. diff --git a/AGENTS.md b/AGENTS.md index b5fafa9f8..ff4c09b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,9 +462,12 @@ webjs dev [--port N] [--no-hot] # dev server with live reload (node --watch o webjs start [--port N] # prod server; source IS the runtime, plain HTTP/1.1 (reverse-proxy for TLS + HTTP/2). Runs webjs.start.before first (#550) webjs test [--server] [--browser] [--watch] webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop +webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs mcp # read-only MCP: routes, actions (RPC hashes), components, check -webjs doctor # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail +webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory); non-zero exit on a hard fail. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code); --strict also fails the exit on warnings (#975) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) +webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) +webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1 webjs typecheck [tsc args...] # the project's own tsc --noEmit webjs create [--template api|saas] webjs db # wraps drizzle-kit (+ runs db/seed.server.ts) diff --git a/docs/app/docs/configuration/page.ts b/docs/app/docs/configuration/page.ts index 164624ebb..b00852496 100644 --- a/docs/app/docs/configuration/page.ts +++ b/docs/app/docs/configuration/page.ts @@ -36,6 +36,31 @@ webjs db push # drizzle-kit push webjs db studio # drizzle-kit studio webjs db seed # run db/seed.server.ts +

webjs routes

+
webjs routes                    # a grouped tree of pages + route handlers
+webjs routes --table            # aligned KIND / PATH / METHODS / FILE columns
+webjs routes --table --no-headers   # same, without the header row (pipe-friendly)
+webjs routes --json             # structured JSON (matches the MCP list_routes tool)
+

Prints the route table to stdout: every page (path, owner file, dynamic params) and every route.{js,ts} handler (path, owner file, HTTP methods). It reuses the same route walker that backs the typed-routes generator and the dev server, so it always reflects exactly what the framework will serve. The --json shape is byte-identical to the read-only MCP list_routes tool, so an agent gets the same data whether it shells out or calls the MCP.

+ +

webjs doctor

+
webjs doctor            # human-readable project-health checklist
+webjs doctor --json     # structured results (each with a stable code) + a summary
+webjs doctor --strict   # also fail the exit on warnings, not just hard failures
+

Verifies project health: the Node version floor, erasableSyntaxOnly, .env drift, vendor-pin freshness, importmap coherence, @webjsdev/* version coherence, framework resolvability, the git hook, and a page/layout elision advisory. Each result carries a stable machine code (for example NODE_VERSION, TSCONFIG_ERASABLE, IMPORTMAP_COHERENCE) so an agent branches on the failure kind, not the message text. The --json payload is an object { results, summary } (the results array holds the per-check objects, each with its code). By default the exit is non-zero only on a hard toolchain failure; --strict also fails on warnings, so it can gate a fully-clean fix loop the way webjs check --json does.

+ +

webjs version

+
webjs version           # print the installed @webjsdev/cli version
+webjs --version  /  -v  # the same, flag form
+

Prints the installed @webjsdev/cli version, so an agent can detect the toolchain version before relying on a command.

+ +

webjs help

+
webjs help              # the full command banner
+webjs help routes       # usage + summary + an Options table + Examples for one command
+webjs --help  /  -h     # the banner (flag form)
+webjs routes --help     # one command's help (flag form)
+

webjs help <command> prints that command's exact usage line, a one-line summary, an Options table documenting every flag, and worked examples, so you (or an agent) read the real invocation instead of guessing flags. The --help / -h flag forms are equivalent: bare at the top level for the banner, or after a command for that command's help. Commands that wrap an external tool (typecheck to tsc, db to drizzle-kit, ui to @webjsdev/ui) forward --help to that tool. An unknown help topic exits non-zero.

+

tsconfig.json

Optional but recommended for editor + CI type-checking:

{
diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md
index 0c36ddecd..53ca62dda 100644
--- a/packages/cli/AGENTS.md
+++ b/packages/cli/AGENTS.md
@@ -123,13 +123,16 @@ README.md                npm-facing package readme.
 | `webjs start` | `startServer({ dev: false })`, plain HTTP/1.1 (front a reverse proxy for TLS + HTTP/2). Shares the dev framework-resolve preflight above (#954). |
 | `webjs test [--server\|--browser]` | Runtime-native test runner (#570): server tests run under `node --test` on Node and `bun test` on Bun (`bun --test` is invalid), dispatched on `process.versions.bun`; browser tests run the app's resolved `@web/test-runner` (`wtr`) bin via `process.execPath` (no `npx`). |
 | `webjs check [--rules] [--json]` | `checkConventions()` from `@webjsdev/server/check`. `--rules` lists the checks. `--json` emits the structured violations + a summary count as JSON (via `projectCheck` from `@webjsdev/mcp/check-report`, the same projector the MCP `check` tool uses, #415), so an agent in a loop consumes structured data instead of regex-scraping stdout; the non-zero exit on violations is preserved. Report-only: each violation carries a prose `fix` hint, but there is no `--fix` autofix flag (the rules either rewrite code or rename files, so an automatic codemod is not safe) |
+| `webjs routes [--json\|--table] [--no-headers]` | Prints the route table to stdout (#975): every page (path, owner file, dynamic params) and every `route.{js,ts}` handler (path, owner file, HTTP methods). Reuses `buildRouteTable` from `@webjsdev/server` (the ONE walker, shared with `webjs types` + the dev server) and the shared `projectRoutes` projector from `@webjsdev/mcp/routes-report`, so `--json` is byte-identical to the MCP `list_routes` tool (the same split as `check --json` / `check-report.js`). Default is a grouped tree; `--table` is aligned KIND/PATH/METHODS/FILE columns and `--no-headers` drops the header row for piping. Read-only. Tests: `test/cli/routes.test.mjs` |
 | `webjs mcp` | Delegates to `runMcpServer()` from the standalone `@webjsdev/mcp` package (#415; full surface in `packages/mcp/AGENTS.md`). A read-only MCP stdio server: INTROSPECTION (`list_routes` / `list_actions` / `list_components` / `check`), KNOWLEDGE (`init` primer, `docs`, `resources`, `prompts`), and a `source` tool. The scaffold's `.claude.json` registers the server directly as `{ "command": "npx", "args": ["@webjsdev/mcp"] }` (mountable in any MCP host, e.g. Cursor `.cursor/mcp.json`); `webjs mcp` stays as a back-compat alias. STDOUT is the JSON-RPC channel (diagnostics go to stderr) |
-| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence, a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail the exit. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. Tests: `test/cli/doctor.test.mjs` |
+| `webjs doctor` | `runDoctorChecks()` from `lib/doctor.js`. A project-health checklist over existing signals (Node major, tsconfig `erasableSyntaxOnly`, `.env` drift vs `.env.example`, vendor-pin freshness, the `.gitignore` keeping `.webjs/vendor/` committable (`vendor-gitignore`, moved here from `webjs check` in #461 as a warn since it is a project-config concern, not source correctness), `@webjsdev/*` version coherence, a framework-resolve probe (#954: `checkFrameworkResolves` + the exported `frameworkResolves` helper WARN when `@webjsdev/core` cannot be resolved FROM the app dir via a directory-relative `createRequire` probe, naming the fresh-git-worktree-without-node_modules cause and the fix; silent PASS when it resolves, so a healthy app is untouched), importmap coherence, git pre-commit hook, and a page/layout elision advisory (#646: `checkElisionCarriers` runs `@webjsdev/server`'s `analyzeAppElision` and WARNS, naming the first client-effecting blocker, for each page/layout that ships whole instead of being elided as a carrier; advisory-only, skipped when elision is off or there is no `app/`)). PURE checks render with a `[pass]` / `[warn]` / `[fail]` marker; non-zero exit iff a HARD check fails (Node below the floor, or `erasableSyntaxOnly` missing in an existing tsconfig), so CI can gate. Warns (drift / staleness) never fail the exit. Each `DoctorResult` carries a stable SCREAMING_SNAKE `code` (#975, the `DOCTOR_CODES` map; e.g. `NODE_VERSION`, `TSCONFIG_ERASABLE`, `IMPORTMAP_COHERENCE`) so an agent branches on the failure KIND, not the message text. `--json` emits `{ results, summary }` where `results` is the raw `DoctorResult[]` (each carrying a `code`) and `summary` is `{ pass, warn, fail, strict, ok }` (the same array-under-a-key convention as `check --json`); `--strict` also fails the exit on warnings (not just hard failures), so doctor can gate a fully-clean fix loop. The only network touch (pin freshness, plus the importmap-coherence live resolve) is best-effort: a fetch failure is a warn, never a hard fail. The importmap-coherence check (#450) runs `@webjsdev/server`'s `checkImportmapCoherence` IDENTICALLY over the live importmap AND the vendored `.webjs/vendor/importmap.json`, warning when a pinned package needs a newer version of another pinned package than is pinned (the #446 skew class); it reads dependency metadata from the already-installed node_modules manifests (no network of its own) and degrades to "could not verify" when a manifest is unavailable. An onboarding/setup-verify tool, NOT a scaffold-CI hard gate. Tests: `test/cli/doctor.test.mjs` |
 | `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup |
 | `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one |
 | `webjs create  [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `--runtime bun` (or `bun create webjs`, auto-detected) emits a Bun-flavored app (#541): `dev`/`start` scripts force `bun --bun`, `bun.lock`, a pure `oven/bun:1` Dockerfile + bun-install CI, and bun-command agent docs. Orthogonal to `--template` (invariant 1 stays exactly 3 templates). |
 | `webjs db ` | Runs the app's resolved `drizzle-kit` bin via `process.execPath` (no codegen step; `generate` is schema-to-SQL). Resolves the bin from the app's node_modules + spawns it with the current runtime (no `npx`, #570), so it works on Node and Bun, including a Node-less `oven/bun` image. `webjs db seed` runs the app's `db/seed.server.ts` directly. |
 | `webjs ui ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) |
+| `webjs version` | Prints the installed `@webjsdev/cli` version (#975, `readCliVersion()` reads the package's own package.json). Also reachable as the top-level `webjs --version` / `-v` flag, handled at the top of `main()` before the Node preflight so it works on an old Node. Tests: `test/cli/help.test.mjs` |
+| `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, an **Options** table (each flag + a universal `-h, --help` row, matching the Remix CLI's per-command Options section), and an Examples block from the `HELP` map in `bin/webjs.js` (#975), so an agent reads the exact invocation instead of guessing flags. An unknown help topic prints an error + the banner and **exits 1** (`printCommandHelp` returns false). The `--help` / `-h` FLAG forms are equivalent and handled at the top of `main()` (before the Node preflight, so they work on an old Node): `webjs --help` / `-h` prints the banner; `webjs  --help` / `-h` prints that command's help and short-circuits the body. Commands that forward args to an external CLI (`HELP_FLAG_PASSTHROUGH` = `typecheck` to tsc, `db` to drizzle-kit, `ui` to `@webjsdev/ui`) are excluded so the wrapped tool's own `--help` reaches it; an unrecognised command is not intercepted either, so it hits the Unknown-command error (exit 1). Tests: `test/cli/help.test.mjs` |
 
 ## UI subcommand: proxies to `@webjsdev/ui`
 
diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js
index 1d4c78482..81905fd8c 100755
--- a/packages/cli/bin/webjs.js
+++ b/packages/cli/bin/webjs.js
@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 import { resolve, join, dirname } from 'node:path';
 import { spawn } from 'node:child_process';
+import { readFileSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { resolveBin } from '../lib/resolve-bin.js';
 import { dbGenerateTtyHint } from '../lib/db-hints.js';
@@ -11,6 +12,11 @@ import { planDevSupervisor } from '../lib/dev-supervisor.js';
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const [cmd, ...rest] = process.argv.slice(2);
 
+// A `--help`/`-h` or `--version`/`-v` request (top-level or after a subcommand)
+// must not be gated by the Node-version preflight, so it works on an old Node.
+const wantsHelp = cmd === '--help' || cmd === '-h' || rest.includes('--help') || rest.includes('-h');
+const wantsVersion = cmd === '--version' || cmd === '-v' || cmd === 'version';
+
 // Node-version preflight (issue #238), INLINE and dependency-free.
 // This MUST run before any `import @webjsdev/server`: importing the server
 // package links `src/dev.js`, which references Node 24+ builtins, so on an old
@@ -19,8 +25,9 @@ const [cmd, ...rest] = process.argv.slice(2);
 // `../lib/node-preflight.js`, which imports nothing), depending only on
 // `process.versions.node`. The richer `assertNodeVersion` import inside main()
 // stays as belt-and-suspenders for the link-ok (>= 22.13) cases.
-// `help` / no-arg is exempt so a user on an old Node can still read usage.
-if (cmd !== 'help' && cmd !== undefined) {
+// `help` / no-arg / any `--help`|`-h` / `version`|`--version`|`-v` request is
+// exempt so a user on an old Node can still read usage and the version.
+if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
   let engines = '>=24.0.0';
   try {
     const { readFileSync } = await import('node:fs');
@@ -47,8 +54,10 @@ const USAGE = `webjs commands:
   webjs start [--port 8080]                       Start production server (serves source directly, no build step)
   webjs test  [--server|--browser]                 Run server + browser tests
   webjs check [--json]                            Run correctness checks (--json emits structured violations)
+  webjs routes [--json|--table] [--no-headers]    Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header
   webjs mcp                                       Start the read-only MCP server (routes / actions / components / check)
-  webjs doctor                                    Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision)
+  webjs doctor [--json] [--strict]                Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision).
+                                                  --json emits the structured results (with stable codes). --strict also fails the exit on warnings
   webjs types                                     Generate .webjs/routes.d.ts (typed Route union + per-route params)
   webjs typecheck [tsc args...]                   Type-check the app with the project's tsc --noEmit (non-zero on errors)
   webjs create  [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]  Scaffold a new webjs app
@@ -70,7 +79,189 @@ const USAGE = `webjs commands:
                                                   --download: also downloads bundles for offline production
   webjs vendor unpin                         Remove a specific package from the pin file
   webjs vendor list                               Show pinned packages with versions and URLs
-  webjs help                                      Show this help`;
+  webjs version                                   Print the installed @webjsdev/cli version (also: webjs --version / -v)
+  webjs help [command]                            Show this help, or per-command usage + examples (e.g. webjs help routes).
+                                                  The flag forms work too: webjs --help / -h for this banner, webjs  --help / -h for one command`;
+
+/**
+ * Per-command help: usage line, one-line summary, an Options table, and an
+ * Examples block (#975). `webjs help ` (and `webjs  --help`) renders
+ * this so an agent sees the exact flags + worked examples instead of guessing
+ * from the one-line USAGE row. Keyed by the top-level command; `db` / `ui` /
+ * `vendor` document their subcommand shape. Every entry that takes flags lists
+ * them in `options` so the flag surface is machine-readable, matching the Remix
+ * CLI's per-command Options section.
+ * @type {Record, examples: string[] }>}
+ */
+const HELP = {
+  dev: {
+    usage: 'webjs dev [--port ] [--no-hot]',
+    summary: 'Start the dev server with live reload (source is the runtime, no build step).',
+    options: [
+      { flag: '--port ', description: 'Port to listen on (else PORT, else 8080).' },
+      { flag: '--no-hot', description: 'Run in-process, without the hot-reload supervisor.' },
+    ],
+    examples: ['webjs dev', 'webjs dev --port 3000', 'webjs dev --no-hot'],
+  },
+  start: {
+    usage: 'webjs start [--port ]',
+    summary: 'Start the production server (serves source directly, plain HTTP/1.1).',
+    options: [
+      { flag: '--port ', description: 'Port to listen on (else PORT, else 8080).' },
+    ],
+    examples: ['webjs start', 'webjs start --port 8080', 'PORT=8080 webjs start'],
+  },
+  test: {
+    usage: 'webjs test [--server] [--browser] [--watch]',
+    summary: 'Run the app test suites (server-side node:test and/or browser via web-test-runner).',
+    options: [
+      { flag: '--server', description: 'Run only the server-side tests.' },
+      { flag: '--browser', description: 'Run only the browser tests.' },
+      { flag: '--watch', description: 'Re-run on change.' },
+    ],
+    examples: ['webjs test', 'webjs test --server', 'webjs test --browser --watch'],
+  },
+  check: {
+    usage: 'webjs check [--rules] [--json]',
+    summary: 'Run the correctness checks (report-only, no autofix). Exits non-zero on any violation.',
+    options: [
+      { flag: '--json', description: 'Emit the structured violations + summary as JSON (agent-friendly).' },
+      { flag: '--rules', description: 'List the correctness rules instead of running them.' },
+    ],
+    examples: ['webjs check', 'webjs check --json', 'webjs check --rules'],
+  },
+  routes: {
+    usage: 'webjs routes [--json | --table] [--no-headers]',
+    summary: 'Print the route table: each page/route path, its owner file, and (for route handlers) its HTTP methods.',
+    options: [
+      { flag: '--json', description: 'Emit { pages, apis } as JSON (byte-identical to the MCP list_routes tool).' },
+      { flag: '--table', description: 'Flat aligned KIND / PATH / METHODS / FILE columns.' },
+      { flag: '--no-headers', description: 'Omit the header row (only with --table); easier to pipe.' },
+    ],
+    examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'],
+  },
+  doctor: {
+    usage: 'webjs doctor [--json] [--strict]',
+    summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
+    options: [
+      { flag: '--json', description: 'Emit the DoctorResult[] (with stable codes) + a summary as JSON.' },
+      { flag: '--strict', description: 'Also fail the exit on warnings, not just hard failures.' },
+    ],
+    examples: ['webjs doctor', 'webjs doctor --json', 'webjs doctor --strict', 'webjs doctor --json --strict'],
+  },
+  types: {
+    usage: 'webjs types',
+    summary: 'Generate .webjs/routes.d.ts (the typed Route href union + per-route params).',
+    examples: ['webjs types'],
+  },
+  typecheck: {
+    usage: 'webjs typecheck [tsc args...]',
+    summary: "Type-check the app with the project's own tsc --noEmit. Extra args pass through to tsc.",
+    examples: ['webjs typecheck', 'webjs typecheck --watch'],
+  },
+  create: {
+    usage: 'webjs create  [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]',
+    summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.',
+    options: [
+      { flag: '--template ', description: 'full-stack (default), api, or saas.' },
+      { flag: '--db ', description: 'sqlite (default) or postgres.' },
+      { flag: '--runtime ', description: 'node (default) or bun.' },
+      { flag: '--no-install', description: 'Skip the package-manager install step.' },
+    ],
+    examples: [
+      'webjs create my-app',
+      'webjs create my-api --template api',
+      'webjs create my-saas --template saas --db postgres',
+      'webjs create my-app --runtime bun',
+    ],
+  },
+  db: {
+    usage: 'webjs db ',
+    summary: 'Database tasks (wraps drizzle-kit); seed runs db/seed.server.ts.',
+    examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'],
+  },
+  ui: {
+    usage: 'webjs ui  [names...]',
+    summary: 'AI-first component library CLI. Requires @webjsdev/ui installed in the project.',
+    examples: ['webjs ui init', 'webjs ui add button card', 'webjs ui list'],
+  },
+  vendor: {
+    usage: 'webjs vendor  [--from ] [--download]',
+    summary: 'Pin client-side npm packages into .webjs/vendor/importmap.json.',
+    options: [
+      { flag: '--from ', description: 'jspm (default), jsdelivr, unpkg, or skypack.' },
+      { flag: '--download', description: 'Also download the bundles for offline production (with pin).' },
+    ],
+    examples: ['webjs vendor pin', 'webjs vendor pin --download', 'webjs vendor list', 'webjs vendor outdated'],
+  },
+  mcp: {
+    usage: 'webjs mcp',
+    summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).',
+    examples: ['webjs mcp'],
+  },
+  version: {
+    usage: 'webjs version',
+    summary: 'Print the installed @webjsdev/cli version. Also available as webjs --version / -v.',
+    examples: ['webjs version', 'webjs --version'],
+  },
+};
+
+/**
+ * Render `webjs help ` to stdout: usage, summary, an Options table (when
+ * the command takes flags), and Examples, in the Remix-CLI section shape. A
+ * universal `-h, --help` option row is appended so every command advertises it.
+ * Returns true if the command was found, false otherwise (so the caller can
+ * exit non-zero on an unknown help topic, matching the Remix CLI).
+ * @param {string} name
+ * @returns {boolean}
+ */
+function printCommandHelp(name) {
+  const h = HELP[name];
+  if (!h) {
+    console.error(`Unknown help topic "${name}".\n`);
+    console.error(USAGE);
+    return false;
+  }
+  console.log(`Usage: ${h.usage}\n`);
+  console.log(`  ${h.summary}\n`);
+  // A command that forwards to an external tool does NOT show webjs's own help
+  // for `--help`; word its `-h, --help` row to say so, rather than the generic
+  // "Show this help." which would be false for those three commands.
+  const tool = HELP_FLAG_PASSTHROUGH_TOOL[name];
+  const helpRow = tool
+    ? { flag: '-h, --help', description: `Forwarded to ${tool} (this command wraps it).` }
+    : { flag: '-h, --help', description: 'Show this help.' };
+  const options = [...(h.options || []), helpRow];
+  const width = Math.max(...options.map((o) => o.flag.length));
+  console.log('Options:');
+  for (const o of options) console.log(`  ${o.flag.padEnd(width)}  ${o.description}`);
+  console.log('\nExamples:');
+  for (const ex of h.examples) console.log(`  ${ex}`);
+  return true;
+}
+
+/**
+ * Commands that forward their remaining args to an external CLI, so a trailing
+ * `--help` / `-h` should reach THAT tool's own help, not webjs's. Maps the
+ * command to the tool it wraps (used both to skip the help-flag intercept and
+ * to word the `-h, --help` row in that command's help accurately).
+ * @type {Record}
+ */
+const HELP_FLAG_PASSTHROUGH_TOOL = { typecheck: 'tsc', db: 'drizzle-kit', ui: '@webjsdev/ui' };
+const HELP_FLAG_PASSTHROUGH = new Set(Object.keys(HELP_FLAG_PASSTHROUGH_TOOL));
+
+/**
+ * The installed `@webjsdev/cli` version, read from this package's own
+ * package.json. Falls back to `0.0.0` if unreadable (never throws).
+ * @returns {string}
+ */
+function readCliVersion() {
+  try {
+    return JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version || '0.0.0';
+  } catch {
+    return '0.0.0';
+  }
+}
 
 /** @param {string[]} args */
 function flag(args, name, def) {
@@ -116,12 +307,34 @@ async function startDevParallelTasks(commands, cwd) {
 }
 
 async function main() {
-  // Preflight: webjs needs Node 24+ (built-in TS strip + recursive fs.watch).
+  // `--version` / `-v` (top level): print the installed CLI version and exit.
+  if (cmd === '--version' || cmd === '-v') {
+    console.log(readCliVersion());
+    return;
+  }
+  // `--help` / `-h` (#975): a top-level flag (webjs --help / -h) prints the
+  // banner; the same flag AFTER a subcommand (webjs routes --help) prints that
+  // command's help and short-circuits it. Handled before the Node preflight so
+  // it works even on an old Node. A command that forwards its args to an
+  // external CLI (typecheck/db/ui, in HELP_FLAG_PASSTHROUGH) is skipped so the
+  // tool's own --help still reaches it; an UNRECOGNISED command is not
+  // intercepted either, so it falls through to the Unknown-command error
+  // (exit 1) rather than a silent 0.
+  if (cmd === '--help' || cmd === '-h') {
+    console.log(USAGE);
+    return;
+  }
+  if (cmd && HELP[cmd] && !HELP_FLAG_PASSTHROUGH.has(cmd) && (rest.includes('--help') || rest.includes('-h'))) {
+    printCommandHelp(cmd);
+    return;
+  }
+
+  // Node preflight: WebJs needs Node 24+ (built-in TS strip + recursive fs.watch).
   // Run before any subcommand so an older Node fails fast with a clear,
   // actionable message naming the found + required version, exiting non-zero
-  // instead of crashing cryptically later. `help` is exempt so a user on an
-  // old Node can still read usage.
-  if (cmd !== 'help' && cmd !== undefined) {
+  // instead of crashing cryptically later. `help` / a help or version request
+  // is exempt so a user on an old Node can still read usage and the version.
+  if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
     const { assertNodeVersion } = await import('@webjsdev/server');
     assertNodeVersion({ onFail: 'exit' });
   }
@@ -461,14 +674,6 @@ async function main() {
       // app's concern, not a broken toolchain).
       const { runDoctorChecks } = await import('../lib/doctor.js');
       const results = await runDoctorChecks(process.cwd());
-      const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' };
-      console.log('webjs doctor: project-health checklist\n');
-      for (const r of results) {
-        console.log(`  ${marker[r.status]} ${r.name}`);
-        console.log(`    ${r.message}`);
-        if (r.fix && r.status !== 'pass') console.log(`    Fix: ${r.fix}`);
-        console.log();
-      }
       const counts = results.reduce((acc, r) => {
         acc[r.status] = (acc[r.status] || 0) + 1;
         return acc;
@@ -476,15 +681,121 @@ async function main() {
       const pass = counts.pass || 0;
       const warn = counts.warn || 0;
       const fail = counts.fail || 0;
+      // `--strict` also fails the exit on warnings, so an agent can gate on a
+      // fully-clean toolchain (drift / staleness / pin freshness) in a fix loop,
+      // not just on a hard toolchain break. Default keeps warnings non-fatal.
+      const strict = rest.includes('--strict');
+      const failing = fail > 0 || (strict && warn > 0);
+
+      // --json emits the raw DoctorResult[] (each carries a stable `code`) plus
+      // a summary, so an agent consumes structured data instead of scraping the
+      // text. Shape mirrors `check --json`: a top-level array-bearing object
+      // with a `summary` count. The non-zero exit is preserved (an agent gates
+      // on the exit code AND parses the report).
+      if (rest.includes('--json')) {
+        console.log(JSON.stringify({
+          results,
+          summary: { pass, warn, fail, strict, ok: !failing },
+        }));
+        if (failing) process.exit(1);
+        break;
+      }
+
+      const marker = { pass: '[pass]', warn: '[warn]', fail: '[fail]' };
+      console.log('webjs doctor: project-health checklist\n');
+      for (const r of results) {
+        console.log(`  ${marker[r.status]} ${r.name} (${r.code})`);
+        console.log(`    ${r.message}`);
+        if (r.fix && r.status !== 'pass') console.log(`    Fix: ${r.fix}`);
+        console.log();
+      }
       console.log(`  ${pass} passed, ${warn} warning(s), ${fail} failed.`);
-      if (fail > 0) {
-        console.error(
-          `\nwebjs doctor: ${fail} hard check(s) failed. Fix the toolchain issue(s) above.`,
-        );
+      if (failing) {
+        const reason = fail > 0
+          ? `${fail} hard check(s) failed. Fix the toolchain issue(s) above.`
+          : `${warn} warning(s) found and --strict was set.`;
+        console.error(`\nwebjs doctor: ${reason}`);
         process.exit(1);
       }
       break;
     }
+    case 'routes': {
+      // Print the app route table to stdout (#975): every page (path, owner
+      // file, dynamic params) and every route.{js,ts} API handler (path, owner
+      // file, HTTP methods). Reuses the ONE route walker (`buildRouteTable`, the
+      // same walk that backs `webjs types` and the dev server) and the shared
+      // `projectRoutes` projector, so `--json` is byte-identical to the MCP
+      // `list_routes` tool. Read-only: no module load, no autofix.
+      const { buildRouteTable } = await import('@webjsdev/server');
+      const { projectRoutes } = await import('@webjsdev/mcp/routes-report');
+      const { extractRouteMethods } = await import('@webjsdev/mcp');
+      const { readFile } = await import('node:fs/promises');
+      const appDir = process.cwd();
+      const table = await buildRouteTable(appDir);
+      const report = await projectRoutes(table, { appDir, readFile, extractRouteMethods });
+
+      // --json: the machine contract, identical to the MCP `list_routes` shape.
+      if (rest.includes('--json')) {
+        console.log(JSON.stringify(report));
+        break;
+      }
+
+      const { pages, apis } = report;
+      // A page is reached via GET (its server render); a route.{js,ts} exposes
+      // exactly its exported verbs. Present both as one path -> methods -> file
+      // view. Dynamic pages append their param names.
+      const pageMethods = 'GET';
+
+      // --table: flat, aligned columns (KIND / PATH / METHODS / FILE), the
+      // easiest shape for an agent to scan or a human to grep. `--no-headers`
+      // drops the header row so the output pipes cleanly into awk/cut.
+      if (rest.includes('--table')) {
+        /** @type {Array<[string,string,string,string]>} */
+        const rows = [];
+        if (!rest.includes('--no-headers')) rows.push(['KIND', 'PATH', 'METHODS', 'FILE']);
+        for (const p of pages) {
+          rows.push(['page', p.path + (p.params ? ` [${p.params.join(', ')}]` : ''), pageMethods, p.file]);
+        }
+        for (const a of apis) {
+          rows.push(['api', a.path, a.methods.join(', ') || '(none)', a.file]);
+        }
+        // With --no-headers and no routes there are no rows; nothing to print.
+        if (rows.length === 0) break;
+        const widths = [0, 1, 2].map((c) => Math.max(...rows.map((r) => r[c].length)));
+        for (const r of rows) {
+          console.log(
+            `${r[0].padEnd(widths[0])}  ${r[1].padEnd(widths[1])}  ${r[2].padEnd(widths[2])}  ${r[3]}`,
+          );
+        }
+        break;
+      }
+
+      // Default: a grouped tree.
+      console.log(`webjs routes: ${pages.length} page(s), ${apis.length} API route(s)\n`);
+      if (pages.length) {
+        console.log('Pages');
+        const pathW = Math.max(...pages.map((p) => p.path.length));
+        for (const p of pages) {
+          const params = p.params ? `  ${p.params.map((n) => `[${n}]`).join(' ')}` : '';
+          console.log(`  ${p.path.padEnd(pathW)}  ${p.file}${params}`);
+        }
+        console.log();
+      }
+      if (apis.length) {
+        console.log('API routes');
+        const pathW = Math.max(...apis.map((a) => a.path.length));
+        const methW = Math.max(...apis.map((a) => (a.methods.join(', ') || '(none)').length));
+        for (const a of apis) {
+          const methods = a.methods.join(', ') || '(none)';
+          console.log(`  ${a.path.padEnd(pathW)}  ${methods.padEnd(methW)}  ${a.file}`);
+        }
+        console.log();
+      }
+      if (!pages.length && !apis.length) {
+        console.log('  No routes found. Add an app/page.ts or an app/**/route.ts.');
+      }
+      break;
+    }
     case 'types': {
       // Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
       // narrowing the @webjsdev/core `Route` href union + per-route `params`.
@@ -899,7 +1210,21 @@ Full docs: https://docs.webjs.dev`);
       });
       break;
     }
+    case 'version':
+      // Print the installed CLI version (#975). Also reachable as the top-level
+      // `webjs --version` / `-v` flag, handled at the top of main().
+      console.log(readCliVersion());
+      break;
     case 'help':
+      // `webjs help ` prints that command's usage + Options + Examples
+      // (#975); a bare `webjs help` prints the full banner. An unknown topic
+      // exits non-zero (printCommandHelp returns false), matching the Remix CLI.
+      if (rest[0]) {
+        if (!printCommandHelp(rest[0])) process.exit(1);
+      } else {
+        console.log(USAGE);
+      }
+      break;
     case undefined:
       console.log(USAGE);
       break;
diff --git a/packages/cli/lib/doctor.js b/packages/cli/lib/doctor.js
index 7f7aaa163..aa226e87e 100644
--- a/packages/cli/lib/doctor.js
+++ b/packages/cli/lib/doctor.js
@@ -44,9 +44,48 @@ import { checkNodeInline } from './node-preflight.js';
 
 /**
  * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus
- * @typedef {{ name: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult
+ * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult
  */
 
+/**
+ * Stable machine-readable code per check (#975), so an agent consuming
+ * `webjs doctor --json` branches on the failure KIND, not the human message
+ * text (which is free to change). The `name` stays the display identity (some
+ * are kebab-case, two are prose); the `code` is the durable contract, a
+ * SCREAMING_SNAKE_CASE constant that never changes for a given check. Attached
+ * centrally in `runDoctorChecks` so every check function stays focused on its
+ * own logic. Mirrors Remix's `DoctorFindingCode` enum (its `doctor/types.ts`).
+ *
+ * Keyed by each check's `name`. A missing entry falls back to a name-derived
+ * code (see `codeForName`), but every shipped check is listed here explicitly
+ * and a drift test asserts each result carries one of these codes.
+ * @type {Record}
+ */
+export const DOCTOR_CODES = {
+  'node-version': 'NODE_VERSION',
+  'tsconfig-erasable': 'TSCONFIG_ERASABLE',
+  'env-drift': 'ENV_DRIFT',
+  'vendor-pin': 'VENDOR_PIN',
+  'vendor-gitignore': 'VENDOR_GITIGNORE',
+  'webjs-versions': 'WEBJS_VERSIONS',
+  'framework-resolve': 'FRAMEWORK_RESOLVE',
+  'importmap-coherence': 'IMPORTMAP_COHERENCE',
+  'git-hook': 'GIT_HOOK',
+  'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
+  'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
+};
+
+/**
+ * The stable code for a check name: the explicit `DOCTOR_CODES` entry, else a
+ * best-effort derivation (uppercased, non-alphanumerics collapsed to `_`) so a
+ * newly-added check that forgets its map entry still gets a non-empty code.
+ * @param {string} name
+ * @returns {string}
+ */
+export function codeForName(name) {
+  return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
+}
+
 /**
  * Read the CLI package's own `engines.node` so the required Node major lives in
  * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
@@ -1020,5 +1059,8 @@ export async function runDoctorChecks(appDir, opts = {}) {
     checkElisionCarriers(appDir),
     checkStaticAssetFreshness(appDir),
   ]);
+  // Attach the stable machine code to every result (#975). Centralized here so
+  // each check function stays free of the code-contract concern.
+  for (const r of results) r.code = codeForName(r.name);
   return results;
 }
diff --git a/packages/mcp/AGENTS.md b/packages/mcp/AGENTS.md
index fb6d736e3..095794b67 100644
--- a/packages/mcp/AGENTS.md
+++ b/packages/mcp/AGENTS.md
@@ -35,7 +35,8 @@ src/
                          reply; tools/list, tools/call, resources/*, prompts/*.
                          INTROSPECTION tools (read-only, appDir-scoped), each
                          projecting a @webjsdev/server function: list_routes
-                         (buildRouteTable), list_actions (buildActionIndex +
+                         (buildRouteTable, via the shared projectRoutes from
+                         routes-report.js), list_actions (buildActionIndex +
                          hashFile, now reports verb/cache/tags/invalidates per
                          #488 and excludes reserved config exports from the
                          callable-action list), list_components (scanComponents),
@@ -60,6 +61,15 @@ src/
                          The shared shape returned by BOTH the MCP `check` tool
                          and `webjs check --json` (the CLI imports it from
                          `@webjsdev/mcp/check-report`), so the two are identical.
+  routes-report.js       routePathFromDir(routeDir) + projectRoutes(table,
+                         { appDir, readFile, extractRouteMethods }) ->
+                         { pages, apis } (#975). The shared route projector
+                         returned by BOTH the MCP `list_routes` tool and
+                         `webjs routes --json` (the CLI imports it from
+                         `@webjsdev/mcp/routes-report`), so the two are identical.
+                         A leaf module: the two effectful deps are injected so it
+                         needs no `mcp.js` import (no cycle). A drift test asserts
+                         `list_routes` output equals `projectRoutes`.
 scripts/
   copy-mcp-resources.js  prepack: bundle the repo-root skill (references + SKILL.md) + AGENTS.md
                          into resources/ (in `files`) so npx is self-contained.
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 56017a26d..abfe71042 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -13,6 +13,9 @@
     "./check-report": {
       "default": "./src/check-report.js"
     },
+    "./routes-report": {
+      "default": "./src/routes-report.js"
+    },
     "./package.json": "./package.json"
   },
   "files": [
diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js
index 42481f4ad..bcf9f6515 100644
--- a/packages/mcp/src/mcp.js
+++ b/packages/mcp/src/mcp.js
@@ -33,6 +33,7 @@ import {
   getPrompt,
 } from './mcp-docs.js';
 import { resolveFrameworkRoots, runSourceTool } from './mcp-source.js';
+import { projectRoutes } from './routes-report.js';
 
 const PROTOCOL_VERSION = '2024-11-05';
 
@@ -274,22 +275,6 @@ export function extractActionConfig(src) {
   };
 }
 
-/**
- * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
- * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
- * same normalization `buildRouteTable` uses for matching.
- *
- * @param {string} routeDir  POSIX-style, `.` for the app root.
- * @returns {string}
- */
-function routePathFromDir(routeDir) {
-  if (!routeDir || routeDir === '.') return '/';
-  const segs = routeDir
-    .split('/')
-    .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
-  return segs.length ? '/' + segs.join('/') : '/';
-}
-
 /**
  * The tool runners. Each is async, takes `(appDir)`, and returns a plain
  * JSON-serialisable projection of an existing server data function. All are
@@ -311,34 +296,10 @@ export function makeToolRunners(deps) {
   return {
     async list_routes(appDir) {
       const table = await buildRouteTable(appDir);
-      const pages = await Promise.all(
-        table.pages.map(async (r) => {
-          /** @type {{ path: string, file: string, dynamic?: boolean, params?: string[] }} */
-          const out = {
-            path: routePathFromDir(r.routeDir),
-            file: relative(appDir, r.file),
-          };
-          if (r.paramNames && r.paramNames.length) {
-            out.dynamic = true;
-            out.params = r.paramNames;
-          }
-          return out;
-        }),
-      );
-      const apis = await Promise.all(
-        table.apis.map(async (r) => {
-          let methods = [];
-          try {
-            methods = extractRouteMethods(await readFile(r.file, 'utf8'));
-          } catch {}
-          return {
-            path: routePathFromDir(r.routeDir),
-            file: relative(appDir, r.file),
-            methods,
-          };
-        }),
-      );
-      return { pages, apis };
+      // The projection lives in `routes-report.js` so `list_routes` and the CLI
+      // `webjs routes --json` stay byte-identical (#975), the same split as
+      // `check-report.js` for the `check` tool.
+      return projectRoutes(table, { appDir, readFile, extractRouteMethods });
     },
 
     async list_actions(appDir) {
diff --git a/packages/mcp/src/routes-report.js b/packages/mcp/src/routes-report.js
new file mode 100644
index 000000000..4bbbf2540
--- /dev/null
+++ b/packages/mcp/src/routes-report.js
@@ -0,0 +1,90 @@
+/**
+ * Shared JSON projector for the app route table (#975).
+ *
+ * `webjs routes --json` and the `webjs mcp` server's `list_routes` tool BOTH
+ * return the identical shape, so the projection lives here once (the same
+ * pattern as `check-report.js` for `check --json` / the MCP `check` tool). The
+ * input is the raw `RouteTable` from `buildRouteTable(appDir)` (the ONE route
+ * walker, reused, never re-implemented); the output is a stable, agent-friendly
+ * `{ pages, apis }` shape.
+ *
+ * This module is a LEAF: it imports only `node:path` and receives the two
+ * effectful dependencies it needs (`readFile` to read a `route.{js,ts}` file's
+ * source, `extractRouteMethods` to lexically pull its HTTP-verb exports) as
+ * injected arguments, the same dependency-injection style the MCP tool runners
+ * already use. That keeps it free of any `@webjsdev/mcp` main-entry import (so
+ * the CLI can pull just this projector without loading the MCP server) and free
+ * of a cycle with `mcp.js`.
+ *
+ * @module routes-report
+ */
+
+import { relative } from 'node:path';
+
+/**
+ * The literal URL path for a page/api directory: `blog/[slug]` -> `/blog/[slug]`,
+ * the root `.` -> `/`. Route groups `(group)` and `_private` segments drop, the
+ * same normalization `buildRouteTable` uses for matching.
+ *
+ * @param {string} routeDir  POSIX-style, `.` for the app root.
+ * @returns {string}
+ */
+export function routePathFromDir(routeDir) {
+  if (!routeDir || routeDir === '.') return '/';
+  const segs = routeDir
+    .split('/')
+    .filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
+  return segs.length ? '/' + segs.join('/') : '/';
+}
+
+/**
+ * @typedef {{ path: string, file: string, dynamic?: boolean, params?: string[] }} PageRouteReport
+ * @typedef {{ path: string, file: string, methods: string[] }} ApiRouteReport
+ * @typedef {{ pages: PageRouteReport[], apis: ApiRouteReport[] }} RoutesReport
+ */
+
+/**
+ * Project a `RouteTable` into the structured `{ pages, apis }` report shared by
+ * `webjs routes --json` and the MCP `list_routes` tool. Pure over its inputs
+ * (given the same table + deps it produces the same output); the only I/O is
+ * the injected `readFile` reading each `route.{js,ts}` source to extract its
+ * methods. A `route.{js,ts}` that cannot be read degrades to an empty method
+ * list rather than throwing, so one unreadable file never sinks the whole
+ * report.
+ *
+ * @param {{ pages: any[], apis: any[] }} table  the `buildRouteTable(appDir)` result
+ * @param {{
+ *   appDir: string,
+ *   readFile: (path: string, enc: string) => Promise,
+ *   extractRouteMethods: (src: string) => string[],
+ * }} deps
+ * @returns {Promise}
+ */
+export async function projectRoutes(table, { appDir, readFile, extractRouteMethods }) {
+  const pages = table.pages.map((r) => {
+    /** @type {PageRouteReport} */
+    const out = {
+      path: routePathFromDir(r.routeDir),
+      file: relative(appDir, r.file),
+    };
+    if (r.paramNames && r.paramNames.length) {
+      out.dynamic = true;
+      out.params = r.paramNames;
+    }
+    return out;
+  });
+  const apis = await Promise.all(
+    table.apis.map(async (r) => {
+      let methods = [];
+      try {
+        methods = extractRouteMethods(await readFile(r.file, 'utf8'));
+      } catch {}
+      return {
+        path: routePathFromDir(r.routeDir),
+        file: relative(appDir, r.file),
+        methods,
+      };
+    }),
+  );
+  return { pages, apis };
+}
diff --git a/packages/mcp/test/mcp.test.mjs b/packages/mcp/test/mcp.test.mjs
index 659d34412..570869c6f 100644
--- a/packages/mcp/test/mcp.test.mjs
+++ b/packages/mcp/test/mcp.test.mjs
@@ -171,6 +171,27 @@ test('mcp: tools/call list_routes returns the route projection', async () => {
   assert.deepEqual(api.methods.sort(), ['GET', 'POST']);
 });
 
+test('mcp: list_routes output equals the shared projectRoutes (no drift with the CLI)', async () => {
+  // The MCP tool and `webjs routes --json` both project through
+  // routes-report.js, so the tool output MUST equal projectRoutes over the same
+  // app. This locks the shared-projector guarantee (#975): if the MCP tool ever
+  // stops delegating to projectRoutes, this fails.
+  const { projectRoutes } = await import(resolve(REPO, 'packages', 'mcp', 'src', 'routes-report.js'));
+  const { buildRouteTable } = await import('@webjsdev/server');
+  const { readFile } = await import('node:fs/promises');
+  const dir = tmpDir();
+  write(dir, 'app/page.ts', `export default function P() {}\n`);
+  write(dir, 'app/blog/[slug]/page.ts', `export default function B() {}\n`);
+  write(dir, 'app/api/users/route.ts', `export async function GET() {}\nexport async function POST() {}\n`);
+
+  const { frames } = await driveMcp(dir, [
+    { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'list_routes', arguments: {} } },
+  ]);
+  const toolOut = JSON.parse(frames[0].result.content[0].text);
+  const expected = await projectRoutes(await buildRouteTable(dir), { appDir: dir, readFile, extractRouteMethods });
+  assert.deepEqual(toolOut, expected);
+});
+
 test('mcp: tools/call list_actions reports file + fn + RPC endpoint', async () => {
   const dir = tmpDir();
   write(
diff --git a/test/cli/doctor.test.mjs b/test/cli/doctor.test.mjs
index edf6f9dfb..ade0d3abe 100644
--- a/test/cli/doctor.test.mjs
+++ b/test/cli/doctor.test.mjs
@@ -544,6 +544,99 @@ test('CLI exits non-zero when a hard check fails (bad tsconfig)', () => {
   assert.match(r.stdout + r.stderr, /\[fail\] tsconfig-erasable/);
 });
 
+// ---------------------------------------------------------------------------
+// Stable codes (#975): every result carries a machine `code`.
+// ---------------------------------------------------------------------------
+const { DOCTOR_CODES, codeForName } = await import(resolve(CLI_LIB_DIR, 'doctor.js'));
+
+test('every DoctorResult carries a stable non-empty code', async () => {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'x' }));
+  const results = await runDoctorChecks(dir, baseOpts({ nodeVersion: '24.0.0' }));
+  const codes = new Set(Object.values(DOCTOR_CODES));
+  for (const r of results) {
+    assert.ok(r.code && typeof r.code === 'string', `${r.name} has a code`);
+    assert.ok(codes.has(r.code), `${r.name} code "${r.code}" is a declared DOCTOR_CODE`);
+  }
+  // The code is stable per check name, independent of the human message.
+  assert.equal(byName(results, 'node-version').code, 'NODE_VERSION');
+  assert.equal(byName(results, 'tsconfig-erasable').code, 'TSCONFIG_ERASABLE');
+});
+
+test('codeForName falls back to a derived code for an unmapped name', () => {
+  assert.equal(codeForName('node-version'), 'NODE_VERSION');
+  assert.equal(codeForName('Some New Check!'), 'SOME_NEW_CHECK');
+});
+
+// ---------------------------------------------------------------------------
+// CLI --json + --strict (#975).
+// ---------------------------------------------------------------------------
+function runCliArgs(cwd, args) {
+  return spawnSync(process.execPath, [CLI, 'doctor', ...args], { cwd, encoding: 'utf8' });
+}
+
+test('doctor --json emits the results (with codes) + a summary, valid JSON', () => {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'x' }));
+  write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } }));
+  const r = runCliArgs(dir, ['--json']);
+  assert.equal(r.status, 0, `no hard fail -> exit 0, got ${r.status}\n${r.stderr}`);
+  const out = JSON.parse(r.stdout);
+  assert.ok(Array.isArray(out.results), 'results is an array');
+  assert.ok(out.results.every((x) => x.code), 'every result carries a code');
+  assert.equal(typeof out.summary.pass, 'number');
+  assert.equal(out.summary.strict, false);
+  assert.equal(out.summary.ok, true);
+  // --json emits ONLY the JSON object (no human banner leaking onto stdout).
+  assert.doesNotMatch(r.stdout, /project-health checklist/);
+});
+
+test('doctor --json still exits non-zero on a hard fail', () => {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'x' }));
+  write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { strict: true } }));
+  const r = runCliArgs(dir, ['--json']);
+  assert.notEqual(r.status, 0, 'a hard fail exits non-zero even in --json mode');
+  const out = JSON.parse(r.stdout);
+  assert.equal(out.summary.ok, false);
+  assert.ok(out.results.some((x) => x.code === 'TSCONFIG_ERASABLE' && x.status === 'fail'));
+});
+
+// The --strict counterfactual: an app with a WARN but NO hard fail exits 0
+// normally, and 1 under --strict. Proving BOTH sides is what shows --strict is
+// what flips it (not some unrelated hard fail).
+test('--strict flips a warning-only app from exit 0 to exit 1', () => {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'x' }));
+  write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } }));
+  // Seed a guaranteed WARN with no hard fail: an .env.example whose key is
+  // absent from .env is the env-drift warn.
+  write(dir, '.env.example', 'DATABASE_URL=\nAUTH_SECRET=\n');
+  write(dir, '.env', 'DATABASE_URL=x\n');
+
+  const plain = runCliArgs(dir, []);
+  assert.equal(plain.status, 0, `without --strict a warn does NOT fail: got ${plain.status}\n${plain.stdout}`);
+
+  const strict = runCliArgs(dir, ['--strict']);
+  assert.equal(strict.status, 1, 'with --strict the same warn fails the exit');
+  assert.match(strict.stderr, /--strict was set/);
+});
+
+test('--strict with --json reports ok:false and exits 1 on a warning', () => {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'x' }));
+  write(dir, 'tsconfig.json', JSON.stringify({ compilerOptions: { erasableSyntaxOnly: true } }));
+  write(dir, '.env.example', 'AUTH_SECRET=\n');
+  write(dir, '.env', '\n');
+  const r = runCliArgs(dir, ['--json', '--strict']);
+  assert.equal(r.status, 1, 'strict + a warn -> exit 1');
+  const out = JSON.parse(r.stdout);
+  assert.equal(out.summary.strict, true);
+  assert.equal(out.summary.ok, false);
+  assert.ok(out.summary.warn > 0, 'a warning was present');
+  assert.equal(out.summary.fail, 0, 'and it was a warn, not a hard fail');
+});
+
 // Regression: the doctor pin check imports hasVendorPin from @webjsdev/server on
 // the REAL (un-stubbed) path. If it is not re-exported, the check silently
 // reports "no pin file" for a pinned app and the freshness check is inert. The
diff --git a/test/cli/help.test.mjs b/test/cli/help.test.mjs
new file mode 100644
index 000000000..3e5583fd2
--- /dev/null
+++ b/test/cli/help.test.mjs
@@ -0,0 +1,166 @@
+/**
+ * Tests for `webjs help ` (#975): per-command usage + Examples.
+ *
+ * A bare `webjs help` still prints the full USAGE banner; `webjs help `
+ * prints that command's usage line, summary, and an Examples block. An unknown
+ * command falls back to the banner with a short note. Every documented command
+ * must expose at least one example (that is the whole point: an agent reads the
+ * exact invocation instead of guessing flags).
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(__dirname, '..', '..');
+const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js');
+
+function help(...args) {
+  return spawnSync(process.execPath, [CLI, 'help', ...args], { encoding: 'utf8' });
+}
+
+/** Run the CLI with arbitrary argv (for the --help / -h flag forms). */
+function cli(...args) {
+  return spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8' });
+}
+
+// A stable banner fragment that does not read as brand prose.
+const BANNER = /per-command usage \+ examples/;
+
+test('`--help` and `-h` at the top level print the banner', () => {
+  for (const flag of ['--help', '-h']) {
+    const r = cli(flag);
+    assert.equal(r.status, 0, `${flag}: ${r.stderr}`);
+    assert.match(r.stdout, BANNER, `${flag} prints the banner`);
+  }
+});
+
+test('a `--help` / `-h` flag after a command prints that command\'s help', () => {
+  for (const flag of ['--help', '-h']) {
+    const routes = cli('routes', flag);
+    assert.equal(routes.status, 0, `routes ${flag}: ${routes.stderr}`);
+    assert.match(routes.stdout, /^Usage: webjs routes /m, `routes ${flag} shows routes help`);
+    assert.match(routes.stdout, /^Examples:/m);
+
+    const doctor = cli('doctor', flag);
+    assert.match(doctor.stdout, /^Usage: webjs doctor \[--json\] \[--strict\]/m, `doctor ${flag} shows doctor help`);
+  }
+});
+
+test('a `--help` flag short-circuits (does NOT run the command)', () => {
+  // A route printer would emit "N page(s)"; the help intercept must fire first,
+  // so `routes --help` in any directory prints help, never a route table.
+  const r = cli('routes', '--help');
+  assert.doesNotMatch(r.stdout, /page\(s\)/, 'help short-circuits before the routes body runs');
+  assert.match(r.stdout, /^Usage: /m);
+});
+
+test('a `--help` flag on typecheck is NOT intercepted (forwards to tsc)', () => {
+  // typecheck is a thin tsc wrapper, so --help idiomatically means tsc's own
+  // help. The intercept must skip it: our Usage line must NOT appear.
+  const r = cli('typecheck', '--help');
+  assert.doesNotMatch(r.stdout, /^Usage: webjs typecheck /m, 'typecheck --help is not the framework help');
+});
+
+test('a `--help` flag on a passthrough command (db/ui) is NOT intercepted', () => {
+  // db forwards to drizzle-kit and ui forwards to @webjsdev/ui; --help there
+  // must reach the wrapped tool, not print the framework command help.
+  for (const c of ['db', 'ui']) {
+    const r = cli(c, '--help');
+    assert.doesNotMatch(r.stdout, new RegExp(`^Usage: webjs ${c} `, 'm'), `${c} --help is not the framework help`);
+  }
+});
+
+test('an unknown command with `--help` still errors (not silently intercepted)', () => {
+  // `webjs bogus --help` must NOT become a silent success: only known,
+  // non-passthrough commands are intercepted, so this falls to Unknown-command.
+  const r = cli('bogus', '--help');
+  assert.equal(r.status, 1, 'an unknown command is an error even with --help');
+  assert.match(r.stderr, /Unknown command: bogus/);
+});
+
+// --- version -------------------------------------------------------------
+test('`webjs version`, `--version`, and `-v` print the CLI version', async () => {
+  const { readFile } = await import('node:fs/promises');
+  const pkg = JSON.parse(await readFile(resolve(REPO, 'packages', 'cli', 'package.json'), 'utf8'));
+  for (const form of [['version'], ['--version'], ['-v']]) {
+    const r = cli(...form);
+    assert.equal(r.status, 0, `${form.join(' ')}: ${r.stderr}`);
+    assert.equal(r.stdout.trim(), pkg.version, `${form.join(' ')} prints ${pkg.version}`);
+  }
+});
+
+test('bare `webjs help` prints the full USAGE banner', () => {
+  const r = help();
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /webjs commands:/);
+  assert.match(r.stdout, /webjs routes/);
+});
+
+test('`webjs help routes` prints usage + summary + an Options table + examples', () => {
+  const r = help('routes');
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /^Usage: webjs routes /m);
+  // The Options section documents each flag (Remix-CLI parity), incl. --no-headers
+  // and a universal -h, --help row.
+  assert.match(r.stdout, /^Options:/m);
+  assert.match(r.stdout, /^ {2}--json\s+Emit/m);
+  assert.match(r.stdout, /^ {2}--no-headers\s+/m);
+  assert.match(r.stdout, /^ {2}-h, --help\s+Show this help\./m);
+  assert.match(r.stdout, /^Examples:/m);
+  assert.match(r.stdout, /webjs routes --json/);
+});
+
+test('a passthrough command\'s help says -h/--help forwards to the wrapped tool', () => {
+  // `webjs help typecheck` must NOT claim "-h, --help  Show this help." because
+  // typecheck --help actually forwards to tsc. The row is worded accordingly.
+  const tc = help('typecheck');
+  assert.match(tc.stdout, /-h, --help\s+Forwarded to tsc/);
+  const db = help('db');
+  assert.match(db.stdout, /-h, --help\s+Forwarded to drizzle-kit/);
+  // A non-passthrough command keeps the generic wording.
+  const routes = help('routes');
+  assert.match(routes.stdout, /-h, --help\s+Show this help\./);
+});
+
+test('`webjs help doctor` documents --json and --strict', () => {
+  const r = help('doctor');
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /Usage: webjs doctor \[--json\] \[--strict\]/);
+  assert.match(r.stdout, /webjs doctor --strict/);
+  assert.match(r.stdout, /webjs doctor --json/);
+});
+
+test('`webjs help ` exits non-zero with an error (Remix CLI parity)', () => {
+  const r = help('bogus');
+  assert.equal(r.status, 1, 'an unknown help topic is an error, not a silent success');
+  assert.match(r.stderr, /Unknown help topic "bogus"/);
+  // Still prints the banner (to stderr) so the caller sees the real commands.
+  assert.match(r.stderr, /webjs commands:/);
+});
+
+// Drift guard: every command in the HELP map must carry a usage + at least one
+// example. Reads the HELP object literal out of the CLI source so a new command
+// added without an example reds this test.
+test('every HELP entry has a usage line and at least one example', async () => {
+  const { readFile } = await import('node:fs/promises');
+  const src = await readFile(CLI, 'utf8');
+  const start = src.indexOf('const HELP = {');
+  assert.ok(start >= 0, 'HELP map is present in the CLI');
+  // Slice out the object literal up to the printCommandHelp function.
+  const end = src.indexOf('function printCommandHelp', start);
+  const block = src.slice(start, end);
+  // Each top-level command entry looks like `  name: {` at two-space indent.
+  const commands = [...block.matchAll(/^ {2}([a-z]+): \{/gm)].map((m) => m[1]);
+  assert.ok(commands.length >= 10, `found the HELP commands (${commands.length})`);
+  for (const cmd of commands) {
+    const r = help(cmd);
+    assert.match(r.stdout, /^Usage: /m, `${cmd} shows a usage line`);
+    assert.match(r.stdout, /^Examples:/m, `${cmd} shows an Examples block`);
+    // At least one example line under Examples (indented, non-empty).
+    const after = r.stdout.split('Examples:')[1] || '';
+    assert.match(after, /\n {2}\S/, `${cmd} lists at least one example`);
+  }
+});
diff --git a/test/cli/routes.test.mjs b/test/cli/routes.test.mjs
new file mode 100644
index 000000000..9163a1a63
--- /dev/null
+++ b/test/cli/routes.test.mjs
@@ -0,0 +1,148 @@
+/**
+ * Tests for `webjs routes` (#975): the route-table printer.
+ *
+ * Two layers:
+ *   - The PURE projector `projectRoutes(table, deps)` (the shared
+ *     `@webjsdev/mcp/routes-report` module) against a tmp fixture app, so the
+ *     `{ pages, apis }` shape is asserted without spawning anything.
+ *   - The CLI integration: spawn `webjs routes` and assert the tree / --table /
+ *     --json variants. The --json branch MUST be byte-identical to what the
+ *     projector produces (the same projector backs the MCP `list_routes` tool),
+ *     which is the whole point of the shared module.
+ */
+import { test, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { resolve, dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { readFile } from 'node:fs/promises';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(__dirname, '..', '..');
+const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js');
+
+const { projectRoutes } = await import(
+  resolve(REPO, 'packages', 'mcp', 'src', 'routes-report.js')
+);
+const { buildRouteTable } = await import('@webjsdev/server');
+const { extractRouteMethods } = await import(
+  resolve(REPO, 'packages', 'mcp', 'src', 'mcp.js')
+);
+
+const cleanup = [];
+after(() => { for (const d of cleanup) rmSync(d, { recursive: true, force: true }); });
+
+/** A fresh tmp fixture dir. */
+function tmpDir() {
+  const dir = mkdtempSync(join(tmpdir(), 'routes-'));
+  cleanup.push(dir);
+  return dir;
+}
+
+/** Write a file, creating parent dirs. */
+function write(dir, rel, content) {
+  const full = join(dir, rel);
+  mkdirSync(dirname(full), { recursive: true });
+  writeFileSync(full, content);
+}
+
+/** A fixture app with a static page, a dynamic page, and an API route. */
+function fixtureApp() {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'fx' }));
+  write(dir, 'app/page.ts', 'export default function P(){}\n');
+  write(dir, 'app/blog/[slug]/page.ts', 'export default function B(){}\n');
+  write(dir, 'app/api/users/route.ts', 'export async function GET(){}\nexport async function POST(){}\n');
+  return dir;
+}
+
+function runCli(cwd, args) {
+  return spawnSync(process.execPath, [CLI, 'routes', ...args], { cwd, encoding: 'utf8' });
+}
+
+// ---------------------------------------------------------------------------
+// The pure projector.
+// ---------------------------------------------------------------------------
+test('projectRoutes returns pages (path/file/dynamic/params) and apis (path/file/methods)', async () => {
+  const dir = fixtureApp();
+  const table = await buildRouteTable(dir);
+  const report = await projectRoutes(table, { appDir: dir, readFile, extractRouteMethods });
+
+  const paths = report.pages.map((p) => p.path).sort();
+  assert.ok(paths.includes('/'), 'root page present');
+  assert.ok(paths.includes('/blog/[slug]'), 'dynamic page present');
+
+  const root = report.pages.find((p) => p.path === '/');
+  assert.equal(root.file, 'app/page.ts');
+  assert.equal(root.dynamic, undefined, 'a static page carries no dynamic flag');
+
+  const dyn = report.pages.find((p) => p.path === '/blog/[slug]');
+  assert.equal(dyn.dynamic, true);
+  assert.deepEqual(dyn.params, ['slug']);
+
+  const api = report.apis.find((a) => a.path === '/api/users');
+  assert.ok(api, 'api route present');
+  assert.equal(api.file, 'app/api/users/route.ts');
+  assert.deepEqual(api.methods.sort(), ['GET', 'POST']);
+});
+
+test('projectRoutes tolerates an unreadable route file (empty methods, no throw)', async () => {
+  const dir = fixtureApp();
+  const table = await buildRouteTable(dir);
+  // A readFile that always throws simulates a race-deleted / permission-denied
+  // route file; the api should still appear, with an empty method list.
+  const report = await projectRoutes(table, {
+    appDir: dir,
+    readFile: async () => { throw new Error('ENOENT'); },
+    extractRouteMethods,
+  });
+  const api = report.apis.find((a) => a.path === '/api/users');
+  assert.ok(api, 'api still listed');
+  assert.deepEqual(api.methods, [], 'unreadable route degrades to no methods');
+});
+
+// ---------------------------------------------------------------------------
+// CLI integration.
+// ---------------------------------------------------------------------------
+test('webjs routes --json is byte-identical to the shared projector', async () => {
+  const dir = fixtureApp();
+  const table = await buildRouteTable(dir);
+  const expected = await projectRoutes(table, { appDir: dir, readFile, extractRouteMethods });
+
+  const r = runCli(dir, ['--json']);
+  assert.equal(r.status, 0, `expected exit 0, got ${r.status}\n${r.stderr}`);
+  assert.deepEqual(JSON.parse(r.stdout), expected);
+  // And the serialized form matches too (the CLI stringifies the projector output verbatim).
+  assert.equal(r.stdout.trim(), JSON.stringify(expected));
+});
+
+test('webjs routes (tree) lists pages and API routes with methods', () => {
+  const dir = fixtureApp();
+  const r = runCli(dir, []);
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /2 page\(s\), 1 API route\(s\)/);
+  assert.match(r.stdout, /\/blog\/\[slug\]/);
+  assert.match(r.stdout, /app\/page\.ts/);
+  assert.match(r.stdout, /\/api\/users/);
+  assert.match(r.stdout, /GET, POST/);
+});
+
+test('webjs routes --table prints aligned KIND/PATH/METHODS/FILE columns', () => {
+  const dir = fixtureApp();
+  const r = runCli(dir, ['--table']);
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /^KIND\s+PATH\s+METHODS\s+FILE/m);
+  assert.match(r.stdout, /^page\s+\/\s+GET\s+app\/page\.ts/m);
+  assert.match(r.stdout, /^api\s+\/api\/users\s+GET, POST\s+app\/api\/users\/route\.ts/m);
+});
+
+test('webjs routes on an app with no routes says so', () => {
+  const dir = tmpDir();
+  write(dir, 'package.json', JSON.stringify({ name: 'empty' }));
+  const r = runCli(dir, []);
+  assert.equal(r.status, 0, r.stderr);
+  assert.match(r.stdout, /0 page\(s\), 0 API route\(s\)/);
+  assert.match(r.stdout, /No routes found/);
+});
diff --git a/test/hooks/block-prose-punctuation.test.mjs b/test/hooks/block-prose-punctuation.test.mjs
index 69776ba9b..a86d62e64 100644
--- a/test/hooks/block-prose-punctuation.test.mjs
+++ b/test/hooks/block-prose-punctuation.test.mjs
@@ -115,6 +115,18 @@ test('allows a package.json script value ending in `webjs ` before a quo
   assert.equal(runContent(`command = '${B} dev'`).status, 0);
 });
 
+test('allows `webjs ` inside an HTML tag boundary (docs headings / code)', () => {
+  // The docs pages reference commands as `

webjs routes

` and + // `webjs types`, where the subcommand is immediately followed by + // a `<` (or `>`). That is a command, not brand prose, so it must pass (#975). + assert.equal(runContent(`

${B} routes

`).status, 0); + assert.equal(runContent(`${B} types`).status, 0); + assert.equal(runContent(`

${B} doctor

`).status, 0); + // Counterfactual: a real verb after the brand before a tag still blocks (the + // widening admits only known subcommands, not arbitrary words). + assert.equal(runContent(`

${B} powers

`).status, 2); +}); + test('still blocks genuine lowercase-brand prose (counterfactual for #956)', () => { // The quote widening must NOT let real brand prose through: the brand followed // by a verb is not a `ship` subcommand, so it still blocks.