The bob CLI
The bob CLI
Section titled “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.
What it is
Section titled “What it is”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.
Why one verb
Section titled “Why one verb”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 inCLAUDE.mdand 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 --helpis the discovery surface, rendered from the rows, so it is complete by construction.bob help --jsonis 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.shprobesbob <noun> [<verb>] --helpfor every row, so a new command is covered the moment its row lands. Nothing about abobcommand is listed in the check. - Unknown commands fail loudly and helpfully.
bob doctrexits 2 namingbob doctor; the same nearest-match logic serves a mistyped verb. An exit 0 on a typo is the silent-optimistic failure theshell-cli-designskill exists to prevent. - The router cannot rot. Because it hand-cases nothing, there is no code in
bin/bobto 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.
How nouns and verbs map to the old names
Section titled “How nouns and verbs map to the old names”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 alias shims (#2060)
Section titled “The alias shims (#2060)”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.
Adding a command
Section titled “Adding a command”- Write the engine under
scripts/…, followingshell-cli-design(its own--help,--json, exit codes). - Add one row to
scripts/bob/commands.json:noun,verb(ornull),runner,engine,args,summary,usage,flags,exit,issue,was.help.jsvalidates the table on every read — a malformed row is a usage error naming the row, never a silent skip. - Regenerate the docs: two mechanical commands, no prose to write.
node scripts/bob/help.js --reference --writerewrites docs/reference/cli.md — the full reference, with flags, exit codes and the old→new mapping.make check-cli-table ARGS=--writere-splices the inventory table into the<!-- cli-table:start -->blocks inCLAUDE.md,README.mdand the glossary, rendered from the same table byscripts/docs-site/lib/gen-reference.js(#2061). - Run
make checkandmake test-bob-dispatch.check-bin-entrypointsprobes your row’s--help;check-cli-tablefails if you skipped step 3’s second half, or if you added abin/entrypoint with no row;tests/test-bob-dispatch.shasserts every engine path in the table exists, that every row renders its help through the real router, thatbin/holds only the router, theghshim, the shell snippet and generated alias shims whose targets are rows, thatscripts/cli/holds exactly the exec-row engines, and that the committed reference page matches the generator. - Do not touch
bin/bob, and do not add abin/<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 fromscripts/bob/gen-shims.shonly because a row carries it aswas, 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.
Exit codes and contracts
Section titled “Exit codes and contracts”| 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.
Related
Section titled “Related”shell-cli-design— the CLI contract everybobengine follows.- bob CLI reference — the generated old→new table and every command, from
commands.json. - Verification system —
make check, where the entrypoint gate runs. - bob ready · Preflight gate — the two commands the router shipped with.
- Updating a machine:
bob update— the first verb the unattended-farm capability added. - #2044 — the requirement this page was authored under; #2024 — the capability that grows the table.