Skip to content

The bob CLI

bob is the one command BoB puts on every fleet machine. bob doctor grades this machine, bob ready converges a project, bob preflight gates a warp-drive run — and every verb the unattended-farm capability adds (#2024: update, warp, plan, groom, tick, notify, status, tour) lands the same way. This page explains what the CLI is, why it is one verb rather than one script per job, and how the nouns and verbs map onto the older bin/ names. The mechanics — the table, the router, the help engine — are the evidence for the argument, not the point of it.

Three files, one of which is data:

File Role
scripts/bob/commands.json The table. Every bob command is one row: a noun (doctor), an optional verb (warp start), the runner (node / bash / exec), the engine path under the BoB root, an argv prefix the router puts before the caller’s arguments, a one-line summary, the usage line, flags, exit codes, the tracking issue, and was — the legacy entrypoint the row replaces (null when the command is new). A row that no how-to or tutorial runs carries reference_only — the written reason its reference page is its home — and make check-howto-coverage fails a row that has neither (#2553). A row may carry a tagline of at most 70 characters: the one line bob help --all and the everyday list print. A row without a tagline falls back to the first sentence of its summary. The table’s top-level everyday list is the short set bare bob prints, grouped by situation; a name in it that is not a declared command fails the table, and make check with it (#2642). Schema bob-commands/1.
bin/bob The router. Under 100 lines of bash that resolves its own location through a PATH symlink (#1915), asks the help engine to look the noun/verb up in the table, and execs the row’s engine with the remaining argv. It hand-cases nothing: a command it does not know is a row that does not exist.
scripts/bob/help.js The help engine. Renders bob --help, bob <noun> --help, bob <noun> <verb> --help and bob help --json (schema bob-help/1) from the same table, and names the nearest match for an unknown noun or verb. It also performs the router’s table read (--route), so routing and help are one code path over one document and cannot disagree.

The engines are untouched. bob doctor runs scripts/checks/host-drift.js exactly as the old hand-cased bin/bob did; bob ready runs scripts/ready/ready.js exactly as bin/bob-ready does. The table records that history in was, so bob help --json can answer “what did this used to be called?” without anyone remembering.

BoB grew a bin/ directory the way tooling does: one script per job, each with its own usage text, its own idea of where the BoB root is, and its own place in the entrypoint gate. The 2026-09-10 architecture review counted the cost:

  • Discovery. A new machine, a new project, or an agent in a fresh session has no way to ask “what can BoB do?” except ls ~/.claude/bin. Twenty names with no summaries is not an answer.
  • Drift. Every script wrote its own --help, so the CLI table in CLAUDE.md and the docs was typed by hand and drifted from what the scripts actually accepted. The table could not be generated because there was no table to generate it from.
  • Growth. The unattended farm (#2024) adds eight more entrypoints. Eight more names in bin/, eight more probe lines in the gate, eight more rows typed into three documents — or eight rows in one JSON file.

One verb with a table behind it turns each of those into a property of the table rather than a discipline:

  • bob --help is the discovery surface, rendered from the rows, so it is complete by construction. bob help --json is the same document for an agent — a stable contract (bob-help/1) that the CLI reference tables will be generated from (#2061) instead of typed.
  • The entrypoint gate reads the table. scripts/checks/check-bin-entrypoints.sh probes bob <noun> [<verb>] --help for every row, so a new command is covered the moment its row lands. Nothing about a bob command is listed in the check.
  • Unknown commands fail loudly and helpfully. bob doctr exits 2 naming bob doctor; the same nearest-match logic serves a mistyped verb. An exit 0 on a typo is the silent-optimistic failure the shell-cli-design skill exists to prevent.
  • The router cannot rot. Because it hand-cases nothing, there is no code in bin/bob to update when a command is added, renamed, or retired. The 66-line hand-cased version that preceded it had already grown its own drift: usage text that named three commands while the table of what BoB could do lived in people’s heads.

The CLI contract itself — report by default, --apply to mutate, a versioned --json document with an error object on failure, exit codes with meaning (0 converged · 1 drift or gate-fail · 2 usage · 3+ domain states) — is the shell-cli-design skill’s. bob and its table follow it; this page does not restate it.

A noun is a thing BoB acts on or a check it runs; a verb narrows the action when the noun has more than one. A noun without a verb is a command in its own right (bob doctor); a noun with verbs requires one (bob warp status, bob warp stop), and bob warp alone renders that noun’s help with exit 2. The mapping is the was column of the table, and it is rendered — not typed — as the bob CLI reference: node scripts/bob/help.js --reference --write regenerates that page from commands.json, and tests/test-bob-dispatch.sh fails when the committed page and the table disagree (#2061 promotes that to a make check gate and feeds the same table into CLAUDE.md and the README). bob help --json is the authoritative machine-readable form at any point in the migration.

Two kinds of row sit in that table. A ported command runs an engine under scripts/ directly — bob doctor, bob ready (was bin/bob-ready), bob preflight, bob update, bob notify, bob plan …, bob groom, bob tour (see How BoB surfaces its own capabilities). A legacy command (#2059) reaches an existing script without porting it: the row’s runner is exec, its engine is scripts/cli/<was> — the old bin/ script, moved there unchanged by #2060 — and the caller’s argv passes through unchanged; a verb becomes an argv prefix, so bob warp status runs scripts/cli/warp status and bob dash serve --list runs scripts/cli/cds --list. That is how the rename lands before any engine is rewritten: cdi is bob init, cdb/cdg/cds are bob dash status|generate|serve, cdp is bob promote, cdl is bob link, cdprov is bob provision, cdfork is bob fork, cdfork-pair is bob pair, warp is bob warp <status|log|config|viz|stop|reap|session|finalize>, dev-up/dev-health are bob dev up|health, bob-reap/bob-release/bob-mirror are bob reap|release|mirror, forge-health/forge-queue are bob forge health|queue, setup-promotion is bob promotion setup, and swarm is bob swarm.

The old names keep working for one release cycle, as PATH shims, never shell aliases (bob-shell.zsh states the cd* names are PATH entrypoints — an alias would shadow them). Every bin/<was> is generated from the table by scripts/bob/gen-shims.sh (idempotent; --check is the drift gate check-bin-entrypoints.sh runs, --dry-run and --list preview): a few lines of bash that resolve their own location through a PATH symlink (#1915), print cdprov is now: bob provision … to stderr once per shell, and exec the sibling bob router with the spelling and the caller’s argv — stdin and stdout untouched, the engine’s exit status propagated. “Once per shell” is keyed on $BOB_SHELL_PID, which bob-shell.zsh exports as the interactive shell’s $$ so a subshell, a $(...) or a pipeline counts as the same shell; a plain script that never sourced the snippet falls back to the shim’s $PPID. BOB_SHIM_QUIET=1 silences the notice (the entrypoint gate and the tests set it). A name that maps to several verbs (warp) shims to the bare noun, the caller’s first token being the verb, so warp stop --help is answered by the router and never reaches the engine (#2049); cdprov-interview, which has no was row, is the one extra shim, for bob provision --interview. bob-ready needs no engine copy at all — its row was ported to scripts/ready/ready.js before the shim existed.

Retirement: 2026-09-21. The shims ship in the first stable release cut after 2026-09-12 and retire one release cycle later — the stable is weekly, so the stable cut on or after that date removes bin/<was>, leaving the was column as the record. The date is one constant (RETIRE_ON in gen-shims.sh), written into every shim header, quoted by the generated reference page, and pinned by tests/test-gen-shims.sh against this page. BoB’s own callers already use the bob spelling (#2091): scripts/bob/rewrite-callers.sh --apply moved every invocation in scripts/, hooks/, commands/, skills/, bin/, docs/, the Makefile and the root docs, and make check-legacy-names fails on a new one — so retiring the shims breaks no internal caller (see check-legacy-names).

So bin/ holds exactly four kinds of file — the router, the gh PATH shim, the shell snippet, and generated shims — and scripts/cli/ holds exactly the exec-row engines; check-bin-entrypoints.sh fails on anything else. Three bin/ files are deliberately not nouns, and the reference page lists them under Not mapped with the reason: bin/gh (the forge-health PATH shim — a transparent gh), bin/bob-shell.zsh (shell integration, below), and bin/cdprov-interview (the alias for bob provision --interview; provision is verb-less, so it cannot also carry a verb). Anything else that appears in bin/ without a was row is rendered there as not yet mapped — the test fails on it, so a new script cannot be added to bin/ without becoming a bob noun.

Out of scope, deliberately: the claude() shell wrapper in bin/bob-shell.zsh (the -a1/-a2/-a3 and -rdb flags) is shell integration, not a command, and stays where it is.

  1. Write the engine under scripts/…, following shell-cli-design (its own --help, --json, exit codes).
  2. Add one row to scripts/bob/commands.json: noun, verb (or null), runner, engine, args, summary, usage, flags, exit, issue, was. help.js validates the table on every read — a malformed row is a usage error naming the row, never a silent skip.
  3. Regenerate the docs: two mechanical commands, no prose to write. node scripts/bob/help.js --reference --write rewrites docs/reference/cli.md — the full reference, with flags, exit codes and the old→new mapping. make check-cli-table ARGS=--write re-splices the inventory table into the <!-- cli-table:start --> blocks in CLAUDE.md, README.md and the glossary, rendered from the same table by scripts/docs-site/lib/gen-reference.js (#2061).
  4. Run make check and make test-bob-dispatch. check-bin-entrypoints probes your row’s --help; check-cli-table fails if you skipped step 3’s second half, or if you added a bin/ entrypoint with no row; tests/test-bob-dispatch.sh asserts every engine path in the table exists, that every row renders its help through the real router, that bin/ holds only the router, the gh shim, the shell snippet and generated alias shims whose targets are rows, that scripts/cli/ holds exactly the exec-row engines, and that the committed reference page matches the generator.
  5. Do not touch bin/bob, and do not add a bin/<tool>. If the router needs a change to route your command, the row shape is wrong, not the router. bob <noun> is the entrypoint; a legacy name gets a shim from scripts/bob/gen-shims.sh only because a row carries it as was, and those shims are on their way out.

No copy of the CLI table is hand-typed any more. CLAUDE.md, the README and the glossary each carry a marked block that scripts/doc-keeper/sync.js fills from the one renderer, and make check fails on drift — so an un-regenerated table edit cannot land. What is genuinely not a bob command (the claude shell wrapper, the cdr alias) stays hand-written outside the block.

Surface Exit Meaning
bob <noun> [<verb>] … the engine’s own the router execs; it adds no exit code of its own on success
bob <noun-with-verbs> 2 no verb given — the noun’s verbs to stderr
bob <unknown>, bob <noun> <unknown> 2 unknown command, nearest match named
bob … with a missing table or engine 2 names the file and says run deploy.sh (BOB_SOURCE → BOB_HOME)
bob, bob --help, bob help 0 the everyday commands, grouped by situation (#2642)
bob help --all, bob help <noun>, bob <noun> --help 0 every command on one line each; one command’s detail

Documents: scripts/bob/commands.json is bob-commands/1; bob help --json is bob-help/1 — { schema, generated_at, source, commands[], error }, one document on stdout, an unknown noun under --json is the same document with error set and exit 2.