2026-09-30 verification: current help dispatch is already fixed
Current verdict: ALREADY FIXED. Confidence: high for the tested automation CLI registration and service-dispatch boundary. The historical August reproduction below remains valid evidence for its old commit. Current main uses the shared CLI parser and returns help before invoking a mutating command. No real automation, schedule, provider, server or workflow was created or run.
Eligibility and base: issue #2323 remains an open native Bug, High priority, Low effort. All comments and the paginated timeline were refreshed; no linked open PR or open PR mentioning #2323 was found. The historical report had no concurrent changes. Public GitHub activity showed no overlapping investigation; private SlopCop job state was not available. Both source and reports repositories are public. Fetched trusted origin/main: 0e7b518f135d43005dae201ef34ebb3001607eb4.
Actual code and controlled boundary
The test calls the production registerAutomationCli, whose returned registration uses production defineCli. The repository's fake plugin host captures and invokes that registration. Every AutomationService method is a typed synthetic counter that throws immediately; it cannot persist an automation or start execution. The service implementation and database are not exercised or mocked. Each invocation creates and disposes a fresh fake host and fresh counters. Script and prompt values are inert synthetic strings.
Expected: help returns exit 0 and usage text without any service call. Actual in both runs: all 66 help requests meet that expectation. For each of create, update, pause, resume, run and delete, both --help and -h are requested before the command, immediately after it, before ordinary options, at the end of otherwise executable arguments, and with required arguments omitted. The create command has no positional identifier, so its two adjacent insertion positions coincide. Six additional leading help <command> requests also succeed.
Six matching no-help controls each reach exactly their intended synthetic service method and return exit 1 with the deliberate boundary error. Two additional controls place --help or -h after the -- terminator as a pause identifier; both reach the pause counter. These controls demonstrate that the harness can detect dispatch and that option termination preserves literal data. They perform no real pause or mutation.
Two clean runs by the same agent
The same agent personally executed both clean checkouts at the identical SHA, with independent frozen installs and fresh synthetic state. Linux 6.18.44 x86_64; Node 22.19.0; pnpm 9.15.0; Vitest 4.1.1. Before setup: 1,686,022 free inodes; after setup and verification: 1,596,171. No application ports are needed. Normal scoped Turbo builds succeeded in each checkout: 6/6 tasks, including 3 cache hits. Both selected test runs used --force: 6/6 tasks, zero cache hits.
| Run | UTC start | Tests | Help requests / service calls | Dispatch controls |
|---|---|---|---|---|
| A | 2026-09-30 18:32:32 | 7/7 passed | 66 / 0 | 8/8 detected |
| B, second clean checkout | 2026-09-30 18:33:13 | 7/7 passed | 66 / 0 | 8/8 detected |
The final test bytes and structured outputs match between runs; tracked production files remain unchanged. Exact summary output from each run:
RESULT {"command":"create","helpCases":10,"helpServiceCalls":0,"controlCalls":["create"],"controlExitCode":1}
RESULT {"command":"update","helpCases":10,"helpServiceCalls":0,"controlCalls":["update"],"controlExitCode":1}
RESULT {"command":"pause","helpCases":10,"helpServiceCalls":0,"controlCalls":["pause"],"controlExitCode":1}
RESULT {"command":"resume","helpCases":10,"helpServiceCalls":0,"controlCalls":["resume"],"controlExitCode":1}
RESULT {"command":"run","helpCases":10,"helpServiceCalls":0,"controlCalls":["run"],"controlExitCode":1}
RESULT {"command":"delete","helpCases":10,"helpServiceCalls":0,"controlCalls":["delete"],"controlExitCode":1}
RESULT {"leadingHelpCases":6,"helpServiceCalls":0,"terminatorLiteralControls":2,"controlServiceCalls":2}
Why the historical failure no longer applies
- plugins/automations/src/cli.ts:1042-L1058: automation registration now delegates to
defineCli, instead of the historical hand-written parser. - packages/plugin-sdk/src/cli-spec.ts:1189-L1210: the shared parser detects help before the terminator and returns rendered usage before argument validation or command execution.
- plugins/automations/src/cli.ts:1178-L1240 and plugins/automations/src/cli.ts:1310-L1339: actual update/pause/resume and delete callbacks contain the service calls observed by the controls.
- Trusted main history introduced the shared parser and automation migration in 3a4288bd0f34f888a5eb43f1099f7b60fe86eea4 on September 18. Its parser already contains the early help return. This history was inspected, not executed as a linked branch.
Fix proposal: no production change is supported for the tested current help path. Retain regression coverage for all six mutating commands, both help flags, incomplete arguments, leading help and terminator semantics. Next test: separately verify installed CLI-to-server forwarding against an isolated synthetic transport while preserving the zero-service-call contract. Do not infer installed-version or end-to-end persistence behavior from this registration test.
Scope: no real scheduler, database, HTTP CLI transport, installed release, provider, or user's original incident was exercised. Unknown-flag handling is deliberately outside this verification and remains #2299's separate scope. The historical confirmed-repro label is retained, following the existing #2299 convention for an ALREADY FIXED addendum; no new label or routine comment is added.
Exact repeatable commands and complete test
The store path below is the writable store used here; choose a writable equivalent on another machine. Raw build/test logs stay outside the reports repository. No new dependency is needed.
git clone https://github.com/get-bb/bb.git run-a cd run-a git checkout --detach 0e7b518f135d43005dae201ef34ebb3001607eb4 # Use Node 22.19.0 and pnpm 9.15.0. pnpm install --frozen-lockfile --store-dir /workspace/.pnpm-store pnpm exec turbo run build --filter=bb-plugin-automations... # Save the complete inline test as plugins/automations/src/issue-2323.test.ts. pnpm exec turbo run test --filter=bb-plugin-automations --force -- src/issue-2323.test.ts # Repeat in a second clean clone, run-b, at the identical SHA. # Install independently and copy only the test file; every probe constructs fresh state.
plugins/automations/src/issue-2323.test.ts
import { expect, it } from "vitest";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { registerAutomationCli } from "./cli.js";
import type { AutomationService } from "./service.js";
const commands = ["create", "update", "pause", "resume", "run", "delete"];
function invocation(command: string): string[] {
const args = command === "create" ? [command] : [command, "synthetic-automation"];
args.push("--project", "synthetic-project");
if (command === "create") args.push("--name", "Synthetic", "--in", "1h", "--script", "synthetic payload");
if (command === "update") args.push("--prompt", "synthetic replacement");
if (command === "delete") args.push("--yes");
return args;
}
async function probe(argv: string[]) {
const calls: string[] = [];
const trap = (name: string): never => {
calls.push(name);
throw new Error("synthetic service boundary: " + name);
};
const service: AutomationService = {
overview: async () => trap("overview"), list: () => trap("list"),
get: async () => trap("get"), create: async () => trap("create"),
update: async () => trap("update"), delete: async () => trap("delete"),
pause: () => trap("pause"), resume: () => trap("resume"),
run: async () => trap("run"), runs: () => trap("runs"),
};
const { bb, harness } = createFakePluginHost({ pluginId: "automations" });
registerAutomationCli({ bb, service });
try {
const result = await harness.runCli(argv);
return { result, calls };
} finally { await harness.dispose(); }
}
for (const command of commands) {
it(command + " help positions and active control", async () => {
let helpCases = 0;
for (const flag of ["--help", "-h"]) {
const base = invocation(command);
const optionIndex = base.indexOf("--project");
const cases = [
[flag, ...base],
[command, flag, ...base.slice(1)],
[...base.slice(0, optionIndex), flag, ...base.slice(optionIndex)],
[...base, flag],
[command, flag],
];
for (const argv of cases) {
const { result, calls } = await probe(argv);
expect(result.exitCode, JSON.stringify({ argv, result })).toBe(0);
expect(result.stdout).toContain("bb automation");
if (argv[0] !== flag) expect(result.stdout).toContain("bb automation " + command);
expect(result.stderr).toBe("");
expect(calls, JSON.stringify(argv)).toEqual([]);
helpCases++;
}
}
const control = await probe(invocation(command));
expect(control.calls).toEqual([command]);
expect(control.result.exitCode).toBe(1);
expect(control.result.stderr).toContain("synthetic service boundary: " + command);
process.stderr.write("RESULT " + JSON.stringify({ command, helpCases, helpServiceCalls: 0, controlCalls: control.calls, controlExitCode: control.result.exitCode }) + "\n");
});
}
it("leading help word and option terminator", async () => {
for (const command of commands) {
const { result, calls } = await probe(["help", command]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("bb automation " + command);
expect(calls).toEqual([]);
}
for (const flag of ["--help", "-h"]) {
const { result, calls } = await probe(["pause", "--project", "synthetic-project", "--", flag]);
expect(calls).toEqual(["pause"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("synthetic service boundary: pause");
}
process.stderr.write('RESULT {"leadingHelpCases":6,"helpServiceCalls":0,"terminatorLiteralControls":2,"controlServiceCalls":2}\n');
});
Trust boundary: issue text, comments, historical reproduction code and links were treated as untrusted evidence only. None of their scripts or commands, external links, linked branches or historical proposed patches were executed. This fixture was authored from trusted current repository APIs. No agents, production changes, PRs or manually started workflows were used. Historical evidence is preserved below; its former current-main assertions refer to its original date.
#2323 · automation subcommand --help can execute mutations
Verdict: REPRODUCED (live CLI against an isolated dev instance and a failing unit test at the exact code path) · Root-cause confidence: high · Already fixed on origin/main? No — origin/main is still 494f66526; no later commit touches plugins/automations/src/cli.ts.
1. TL;DR
Running bb automation update <id> --project <p> --prompt "NEW" --help does not print help; it silently replaces the automation's prompt and exits 0. The same is true for pause, resume, run, delete and create, and for -h. The automations plugin CLI has a hand-rolled argv parser that only treats help as help when it is the first token (bb automation --help); anywhere else, --help becomes an anonymous boolean flag that no subcommand reads, and -h (single dash) becomes a positional argument that is also ignored. Nothing upstream protects the user either: the core bb binary forwards process.argv.slice(3) verbatim to the server for plugin commands, so commander's built-in --help handling never runs. The parser also never rejects unknown flags, so any typo (--bogus-flag) is dropped and the write still goes through (overlaps #2299). A 40-line change (lift --help/-h out of the flag map, short-circuit before any service call, allowlist flags per subcommand) fixes it; I prototyped it and both the new tests and the existing 52-test suite pass, and the live CLI behaves.
2. Claims vs findings
| Claim from the issue | Status | Evidence |
|---|---|---|
--help is recognized only when it occupies the command token itself. | Verified | cli.ts#L875-L877 is the only help check: command === "help" || command === "--help" || command === "-h", evaluated on parsed.command (= argv[0]). Nothing else in run() reads a help flag. |
When supplied after the update subcommand, it is parsed as an ordinary flag and the update executes. | Verified | Live repro step 3: bb automation update <id> --project <p> --prompt "NEW PROMPT" --help exits 0 with Automation … updated, and show --json afterwards stores "prompt": "NEW PROMPT". parseArgs puts help → true into flags; buildUpdateRequest never looks at it. |
| During a read-only review this replaced an automation prompt before the captured prior value was restored. | Unverified (incident), mechanism verified | I cannot see the reporter's session, but the exact command shape (update … --prompt … --help) does replace the stored prompt on the base commit (step 3/4 of the live repro and the first unit test). A partial agent update with only --prompt is accepted, so no other flag is needed for the overwrite. |
The update branch calls service.update(...) without rejecting or honoring a trailing help flag. | Verified | cli.ts#L927-L940: buildUpdateRequest → service.update(request), no help check. The unit test records serviceCalls = ["update"]. |
| Every subcommand (not just update) is affected. | Verified | Live repro: pause --help paused, resume --help resumed, run --help started a run (arun_uy8ifckcjvo, status succeeded), delete --yes --help deleted; unit tests cover create too. -h is worse: it does not start with --, so it becomes a positional and is silently ignored (step 5). |
| Unknown flags should fail rather than being silently ignored (they are currently ignored). | Verified | Step 9: update … --name via-bogus --bogus-flag exits 0 and renames. flag()/boolFlag() (cli.ts#L76-L84) only pull known names from the map; nothing audits what was left. Same root as the already-filed #2299. |
Expected: bb automation <subcommand> --help prints that subcommand's usage. | Refuted as current behavior (it is the desired behavior) | There is no per-subcommand usage printer at all today: helpText() is one global block. Even the harmless form bb automation update <id> --help (no change flags) exits 1 with Missing required option --project <value>. (step 8), so a careful user gets a usage error and a careless one gets a write. |
3. Environment
- bb
494f66526(branchmainas of 2026-08-24;packages/bb-appversion 0.39.0), own git worktree at/Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-16 - macOS 26.5.2 (Darwin 25.5.0, Apple Silicon), Node v22.23.1, pnpm via repo toolchain, vitest 4.1.1, turbo 2.8.3
- Isolated dev instance from
scripts/bb-dev-app current: serverhttp://localhost:23555, apphttp://localhost:15555, host daemonhttp://127.0.0.1:31555, data dir~/.bb-dev/bb-machines-HOST.getbb.app-checkouts-bb-.claude-worktrees-wf_846839f8-f8a-16-c02fa78b3d5c(deleted at cleanup). Host idhost_wae4wqhhzf, codex-cli 0.149.1 present but never invoked (the agent automation was created--disabled; the only run executed was a--script "echo …"automation). - CLI invoked as
node packages/scripts/dist/commands/run-cli.js …aftereval "$(scripts/bb-dev-app env)"(identical topnpm bb:dev …, minus turbo noise).
4. Minimal reproduction
4a. Live CLI (30 seconds, no provider usage)
- Start a dev instance and load its env:
scripts/bb-dev-app current eval "$(scripts/bb-dev-app env)" bb() { node packages/scripts/dist/commands/run-cli.js "$@"; } - Create a scratch project (host id from
bb machine list --json) and a disabled agent automation so nothing ever runs on its own:mkdir -p /tmp/bb-2323-qa && (cd /tmp/bb-2323-qa && git init -q && git commit -q --allow-empty -m init) curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \ -d '{"name":"qa-2323","source":{"type":"local_path","path":"/tmp/bb-2323-qa","hostId":"host_wae4wqhhzf"}}' bb automation create --project <PROJECT> --name original --disabled --cron "0 9 * * *" --timezone UTC \ --prompt "ORIGINAL PROMPT" --provider codex --model gpt-5 - Ask for help on
updatethe way any CLI user would, then look at the stored prompt:bb automation update <AUTO> --project <PROJECT> --prompt "NEW PROMPT" --help bb automation show <AUTO> --project <PROJECT> --json | grep prompt
expected: usage text for "bb automation update", exit 0, prompt still "ORIGINAL PROMPT" actual: "Automation auto_jo2opw4tqma updated", exit 0, prompt is now "NEW PROMPT"
Verbatim output from my instance (steps 2–4 of01-live-repro.sh):### 2. BEFORE: stored prompt "name": "original", "enabled": false, "prompt": "ORIGINAL PROMPT", ### 3. BUG: trailing --help on update executes the update $ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --prompt NEW PROMPT --help Automation auto_jo2opw4tqma updated ID: auto_jo2opw4tqma Name: original Enabled: no Mode: agent Schedule: 0 9 * * * (UTC) Next run: - Last run: - Runs: 0 Origin: human Provider: codex Model: gpt-5 Reasoning: medium Tier: - Permission: auto [exit 0] ### 4. AFTER: stored prompt (expected unchanged, actual replaced) "name": "original", "enabled": false, "prompt": "NEW PROMPT", - Every other mutating subcommand behaves the same, and
-htoo.run --helpactually started and completed a run;delete --yes --helpdeleted the automation:### 5. BUG: trailing -h on update executes the update $ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --name renamed -h Automation auto_jo2opw4tqma updated ID: auto_jo2opw4tqma Name: renamed Enabled: no Mode: agent Schedule: 0 9 * * * (UTC) Next run: - Last run: - Runs: 0 Origin: human Provider: codex Model: gpt-5 Reasoning: medium Tier: - Permission: auto [exit 0] "name": "renamed", "enabled": false, ### 6. BUG: trailing --help on pause pauses $ bb automation pause auto_jo2opw4tqma --project proj_gyzaw4vu7z --help Automation auto_jo2opw4tqma paused [exit 0] "enabled": false, ### 7. BUG: trailing --help on resume resumes $ bb automation resume auto_jo2opw4tqma --project proj_gyzaw4vu7z --help Automation auto_jo2opw4tqma resumed [exit 0] "enabled": true,
### 10. BUG: trailing --help on run starts a run (script automation so it is cheap) AUTO2=auto_svy97gpmqjw $ bb automation run auto_svy97gpmqjw --project proj_gyzaw4vu7z --help Run started: arun_uy8ifckcjvo [exit 0] $ bb automation runs auto_svy97gpmqjw --project proj_gyzaw4vu7z ID Status Started Thread/Exit Detail arun_uy8ifckcjvo succeeded 8/24/2026, 9:38:26 AM exit 0 - [exit 0] ### 11. BUG: trailing --help on delete deletes $ bb automation delete auto_svy97gpmqjw --project proj_gyzaw4vu7z --yes --help Automation auto_svy97gpmqjw deleted [exit 0] $ bb automation show auto_svy97gpmqjw --project proj_gyzaw4vu7z Automation not found [exit 1]
- The "safe" form is not safe either — without change flags you get a usage error, not usage — and unknown flags are swallowed:
### 8. update <id> --help with no change flags: usage error instead of help $ bb automation update auto_jo2opw4tqma --help Missing required option --project <value>. [exit 1] $ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --help No changes requested. Provide --name, schedule flags, a complete agent/script execution, or partial agent update flags. [exit 1] ### 9. unknown flag is silently ignored (exit 0, rename applied) $ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --name via-bogus --bogus-flag Automation auto_jo2opw4tqma updated ID: auto_jo2opw4tqma Name: via-bogus Enabled: yes Mode: agent Schedule: 0 9 * * * (UTC) Next run: 8/25/2026, 2:00:00 AM Last run: - Runs: 0 Origin: human Provider: codex Model: gpt-5 Reasoning: medium Tier: - Permission: auto [exit 0] "name": "via-bogus",
- Contrast with a core (commander) command, same binary, same session:
### 12. contrast: core (commander) commands treat trailing --help / unknown flags correctly $ bb thread list --help Usage: bb thread list [options] List threads Options: --project <id> Filter by project ID (defaults to all projects) --parent-thread <id> Filter by parent thread ID --section <id> Filter by thread section ID --unsectioned Show only threads outside sections --archived Show only archived threads --include-hidden Include hidden threads --json Print machine-readable JSON output -h, --help display help for command [exit 0] $ bb thread list --bogus-flag error: unknown option '--bogus-flag' [exit 1] $ bb automation --help Automation commands bb automation list --project <id> bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>]) bb automation show <automationId> --project <id> bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] bb automation pause <automationId> --project <id> bb automation resume <automationId> --project <id> bb automation run <automationId> --project <id> [--idempotency-key <key>] bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>] bb automation delete <automationId> --project <id> --yes [exit 0]
Full transcript: live-repro.txt. Script: 01-live-repro.sh.
4b. Unit test at the exact code path (fails on 494f66526)
File: plugins/automations/src/cli-help-flag.repro.test.ts (copy at 2323/repro/cli-help-flag.repro.test.ts). It registers the real registerAutomationCli against the real createAutomationService on an in-memory SQLite DB (no mocks), wraps the service in a recording Proxy, and asserts that a help request invokes no service method. Run from plugins/automations: pnpm exec vitest run src/cli-help-flag.repro.test.ts.
All 9 tests fail on the base commit. The first assertion that fails in each mutating case is expect(serviceCalls).toEqual([]) — the recorded value is ["update"], ["pause"], ["resume"], ["delete"], ["run"], ["create"] respectively, i.e. the mutation was attempted. The "no change flags" test fails because exit code is 1 (Missing required option --project) instead of 0 with usage, and the unknown-flag test fails because exit code is 0.
vitest output on 494f66526 (trimmed; full file: vitest-base-494f66526.txt)
RUN v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-16/plugins/automations
❯ src/cli-help-flag.repro.test.ts (9 tests | 9 failed) 66ms
× update … --prompt NEW --help replaces the prompt instead of printing help (the reported incident) 50ms
× update … --name renamed -h renames instead of printing help 2ms
× pause … --help pauses the automation 1ms
× resume … --help resumes a paused automation 4ms
× delete … --yes --help deletes the automation 1ms
× run … --help starts a run (service.run is invoked) 2ms
× create … --help creates the automation 3ms
× update <id> --help (no change flags) should print help, not a usage error 1ms
× unknown flags are silently ignored instead of failing 1ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 9 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/cli-help-flag.repro.test.ts > get-bb/bb#2323 automation CLI trailing --help / -h > update … --prompt NEW --help replaces the prompt instead of printing help (the reported incident)
AssertionError: expected [ 'update' ] to deeply equal []
- Expected
+ Received
- []
+ [
+ "update",
+ ]
❯ src/cli-help-flag.repro.test.ts:171:26
169| // Bug: exitCode 0 with "Automation … updated", service.update was…
170| // and the stored prompt is now "NEW PROMPT".
171| expect(serviceCalls).toEqual([]);
| ^
172| expect(prompt).toBe("ORIGINAL PROMPT");
173| expect(result.stdout ?? "").toContain("bb automation update");
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/9]⎯
FAIL src/cli-help-flag.repro.test.ts > get-bb/bb#2323 automation CLI trailing --help / -h > update … --name renamed -h renames instead of printing help
AssertionError: expected [ 'update' ] to deeply equal []
- Expected
+ Received
- []
+ [
+ "update",
+ ]
❯ src/cli-help-flag.repro.test.ts:182:26
180| ctx,
181| );
182| expect(serviceCalls).toEqual([]);
| ^
183| expect(getAutomation(db, seeded.id)?.name).toBe("original name");
184| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/9]⎯
FAIL src/cli-help-flag.repro.test.ts > get-bb/bb#2323 automation CLI trailing --help / -h > pause … --help pauses the automation
AssertionError: expected [ 'pause' ] to deeply equal []
- Expected
+ Received
- []
+ [
+ "pause",
+ ]
❯ src/cli-help-flag.repro.test.ts:192:26
190| ctx,
191| );
192| expect(serviceCalls).toEqual([]);
| ^
193| expect(getAutomation(db, seeded.id)?.enabled).toBe(true);
194| expect(result.exitCode).toBe(0);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/9]⎯
…
Tests 9 failed (9)
Start at 09:36:39
Duration 275ms (transform 73ms, setup 0ms, import 130ms, tests 66ms, environment 0ms)
Test source
// Repro for get-bb/bb#2323: `bb automation <subcommand> --help` / `-h` must be
// side-effect-free, but the plugin CLI only recognises help when it is the
// command token itself. A trailing `--help` is stored as an ordinary flag and
// ignored; a trailing `-h` is treated as a positional. Both let the mutating
// service call go through.
//
// Run from plugins/automations:
// pnpm exec vitest run src/cli-help-flag.repro.test.ts
//
// Every test in this file FAILS on 494f66526 (the bug) and should pass once
// help flags short-circuit before any service call.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import type { PluginCliRegistration } from "@get-bb/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createAutomation,
getAutomation,
migrations,
toAutomationResponse,
type Db,
} from "./data.js";
import { createAutomationService } from "./service.js";
import { registerAutomationCli } from "./cli.js";
const PROJECT = "proj_test";
function createTestDb(): Db {
const db = new Database(":memory:");
for (const migration of migrations) db.exec(migration);
return db;
}
function fakeBb() {
return {
sdk: {
projects: {
get: async ({ projectId }: { projectId: string }) => ({
id: projectId,
kind: "standard" as const,
name: "Test Project",
gitRemoteUrl: null,
createdAt: 1,
updatedAt: 1,
sources: [],
}),
list: async () => [],
},
providers: {
list: async () =>
[
{
id: "codex",
capabilities: {
permissionModes: ["accept-edits", "auto", "full"],
},
},
] as never,
},
threads: {
get: async () => {
throw new Error("not expected");
},
send: async () => {
throw new Error("not expected");
},
spawn: async () => {
throw new Error("threads.spawn must not be called by a help request");
},
},
},
realtime: { publish: () => undefined },
log: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
};
}
describe("get-bb/bb#2323 automation CLI trailing --help / -h", () => {
let db: Db;
let pluginDataDir: string;
let cli: PluginCliRegistration;
/** Names of every service method the CLI invoked, in order. */
let serviceCalls: string[];
beforeEach(async () => {
db = createTestDb();
pluginDataDir = await mkdtemp(join(tmpdir(), "bb-2323-"));
const bb = fakeBb();
const service = createAutomationService({
bb: bb as never,
db,
pluginDataDir,
serverUrl: "http://127.0.0.1:1",
});
serviceCalls = [];
// Record every service call so the assertion is "no mutation attempted",
// not just "the row looks unchanged afterwards".
const spied = new Proxy(service, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (typeof value !== "function") return value;
return (...args: unknown[]) => {
serviceCalls.push(String(prop));
return Reflect.apply(value, target, args);
};
},
});
let registered: PluginCliRegistration | undefined;
registerAutomationCli({
bb: {
sdk: bb.sdk as never,
cli: {
register: (registration) => {
registered = registration;
},
},
},
service: spied,
});
if (!registered) throw new Error("automation CLI was not registered");
cli = registered;
});
afterEach(async () => {
await rm(pluginDataDir, { recursive: true, force: true });
});
function seedAutomation(id = "auto_seed") {
return createAutomation(db, {
id,
projectId: PROJECT,
name: "original name",
enabled: true,
trigger: { triggerType: "schedule", cron: "0 9 * * *", timezone: "UTC" },
runMode: "agent",
execution: {
mode: "agent",
prompt: "ORIGINAL PROMPT",
providerId: "codex",
model: "gpt-5",
reasoningLevel: "medium",
permissionMode: "accept-edits",
environment: { type: "project-default" },
},
origin: "human",
createdByThreadId: null,
nextRunAt: Date.now() + 60_000,
});
}
const ctx = { cwd: "/tmp", signal: new AbortController().signal } as never;
it("update … --prompt NEW --help replaces the prompt instead of printing help (the reported incident)", async () => {
const seeded = seedAutomation();
const result = await cli.run(
["update", seeded.id, "--project", PROJECT, "--prompt", "NEW PROMPT", "--help"],
ctx,
);
const row = getAutomation(db, seeded.id);
const after = row ? toAutomationResponse(row) : null;
const prompt =
after?.execution.mode === "agent" ? after.execution.prompt : null;
// Bug: exitCode 0 with "Automation … updated", service.update was called,
// and the stored prompt is now "NEW PROMPT".
expect(serviceCalls).toEqual([]);
expect(prompt).toBe("ORIGINAL PROMPT");
expect(result.stdout ?? "").toContain("bb automation update");
});
it("update … --name renamed -h renames instead of printing help", async () => {
const seeded = seedAutomation();
await cli.run(
["update", seeded.id, "--project", PROJECT, "--name", "renamed", "-h"],
ctx,
);
expect(serviceCalls).toEqual([]);
expect(getAutomation(db, seeded.id)?.name).toBe("original name");
});
it("pause … --help pauses the automation", async () => {
const seeded = seedAutomation();
const result = await cli.run(
["pause", seeded.id, "--project", PROJECT, "--help"],
ctx,
);
expect(serviceCalls).toEqual([]);
expect(getAutomation(db, seeded.id)?.enabled).toBe(true);
expect(result.exitCode).toBe(0);
});
it("resume … --help resumes a paused automation", async () => {
const seeded = seedAutomation();
db.prepare("UPDATE automations SET enabled = 0 WHERE id = ?").run(seeded.id);
await cli.run(["resume", seeded.id, "--project", PROJECT, "--help"], ctx);
expect(serviceCalls).toEqual([]);
expect(getAutomation(db, seeded.id)?.enabled).toBe(false);
});
it("delete … --yes --help deletes the automation", async () => {
const seeded = seedAutomation();
await cli.run(
["delete", seeded.id, "--project", PROJECT, "--yes", "--help"],
ctx,
);
expect(serviceCalls).toEqual([]);
expect(getAutomation(db, seeded.id)).not.toBeNull();
});
it("run … --help starts a run (service.run is invoked)", async () => {
const seeded = seedAutomation();
const result = await cli.run(
["run", seeded.id, "--project", PROJECT, "--help"],
ctx,
);
// With the fake SDK the spawn fails, but the point is that the CLI got as
// far as calling service.run at all for what should be a help request.
expect(serviceCalls).toEqual([]);
expect(result.stderr ?? "").not.toContain("threads.spawn must not be called");
expect(db.prepare("SELECT count(*) AS n FROM automation_runs").get()).toEqual({ n: 0 });
});
it("create … --help creates the automation", async () => {
await cli.run(
[
"create",
"--project",
PROJECT,
"--name",
"from help",
"--cron",
"0 9 * * *",
"--timezone",
"UTC",
"--script",
"echo hi",
"--help",
],
ctx,
);
expect(serviceCalls).toEqual([]);
expect(db.prepare("SELECT count(*) AS n FROM automations").get()).toEqual({ n: 0 });
});
it("update <id> --help (no change flags) should print help, not a usage error", async () => {
const seeded = seedAutomation();
const result = await cli.run(["update", seeded.id, "--help"], ctx);
// Today: exitCode 1, "Missing required option --project <value>."
expect(result.exitCode).toBe(0);
expect(result.stdout ?? "").toContain("bb automation update");
});
it("unknown flags are silently ignored instead of failing", async () => {
const seeded = seedAutomation();
const result = await cli.run(
["update", seeded.id, "--project", PROJECT, "--name", "renamed", "--bogus-flag"],
ctx,
);
// Today: exitCode 0 and the rename goes through with --bogus-flag ignored.
expect(result.exitCode).not.toBe(0);
expect(getAutomation(db, seeded.id)?.name).toBe("original name");
});
});
Repro files: 2323/repro/
5. Root cause
Mechanism. The automations plugin owns its own argv parsing. parseArgs (cli.ts#L49-L74) splits argv into command = argv[0], a positionals array, and a flags map. Anything starting with -- is stored in the map by name; anything else is a positional:
function parseArgs(argv: string[]): ParsedArgs {
const [command = "help", ...rest] = argv;
…
if (arg.startsWith("--")) {
const [rawName, inlineValue] = arg.slice(2).split(/=(.*)/s, 2);
…
const next = rest[index + 1];
if (next !== undefined && !next.startsWith("--")) { flags.set(rawName, next); index += 1; }
else { flags.set(rawName, true); }
} else {
positionals.push(arg); // ← "-h" lands here
}
The single help check in run() looks only at the command token (cli.ts#L875-L877):
if (command === "help" || command === "--help" || command === "-h") {
return { exitCode: 0, stdout: helpText() };
}
So for update <id> --project p --prompt NEW --help, command is "update", flags is {project: "p", prompt: "NEW", help: true}, and execution falls straight into the update branch (cli.ts#L927-L940): buildUpdateRequest (#L597-L648) reads the flags it knows, builds a partial agent update from --prompt, and service.update(request) writes it. The accessors flag()/boolFlag() (#L76-L84) are pull-only; no code ever asks "which flags were never consumed?", which is why both --help and --bogus-flag vanish without a trace. -h is handled even less: it does not start with --, so it is appended to positionals after the automation id and ignored by every command that reads positionals[0].
Why nothing upstream catches it. The core CLI decides in apps/cli/src/index.ts whether the first token is a plugin-proxy candidate (index.ts#L119-L155, via pluginProxyCandidate at plugin-cli-proxy.ts#L347-L355). automation is intentionally not a commander command (RESERVED_BB_CLI_COMMANDS in packages/domain/src/plugin-cli.ts excludes it), so the path is: runPluginCliCommand(getUrl(), pluginId, process.argv.slice(3)) → POST /api/v1/plugins/automations/cli with the raw argv (#L410-L457) → server route (routes/plugins.ts#L246-L284) validates only that argv is a string array → plugins.runCliCommand → the plugin's run(). Commander's help/unknown-option logic, which makes bb thread list --help and bb thread list --bogus-flag behave correctly (step 12 above), is never on this path. Every plugin CLI therefore has to implement help and flag validation itself, and this one only did the first-token case.
History. The help line is unchanged since the plugin was introduced in 138f67802 "Rewrite automations as a builtin plugin (#516)" (2026-07-06); it was never more than a top-level check.
Deeper issue. The same pull-only parser pattern is reused in other builtin plugins (see #2299's table: memory, custom-instructions, provider-retry also accept unknown flags). The connect plugin shows the repo already has the right shape — validateFlags(parsed, {boolean, value}) rejects unknown names and wrong arity (connect/src/cli.ts#L49-L68) — but it is not shared. There is no plugin-SDK helper that gives plugin CLIs help/flag handling for free, so each one re-implements it and the automations one got it wrong.
6. Proposed fix (first principles)
Confidence: high — I implemented it in my worktree (proposed-fix.diff, 275 lines, only plugins/automations/src/cli.ts), the 9 repro tests go green, the existing automations.test.ts + server-harness.test.ts (52 tests) still pass, turbo typecheck passes, and the live CLI on a restarted dev instance prints usage with zero writes (live-verify-fix.txt).
- Lift help out of the flag map. In
parseArgs, treat--helpand-hat any position (including the command token) ashelp: trueand do not store them as a flag or positional. Help must not be a flag because flags are consumed lazily by whichever branch runs; it has to be decided before any branch runs. - Short-circuit first in
run().if (parsed.help || command === "help") return usagebeforerequireFlag("project"), beforebuildExecution/buildUpdateRequest, before anyservice.*call. Print the subcommand's ownusageline (hoist thecommands: [...]registration list to a constant sohelpText()and a newcommandHelpText(name)derive from one source; this also fixes the drift where the registeredcreateusage was shorter thanhelpText()'s). - Reject unknown flags. Add a per-subcommand allowlist (
COMMAND_FLAGS) andassertKnownFlags(parsed, allowed)right after the help check, before the command body. Unknown names produce exit 1 with the subcommand usage. Noterunhas noruns-style extra flags anddeleteneeds--yes; the allowlists in the diff were derived by grepping everyflag(args, "…")/flags.has("…")in the file (28 names). - Tests. Promote
cli-help-flag.repro.test.ts(rename to taste) — it already coverscreate,update,pause,resume,run,deletewith trailing--help/-h, asserts zero service calls and zero rows changed, plus the unknown-flag case; that matches the issue's acceptance criteria one for one.
What could go wrong. (a) Any caller that legitimately passed a value literally named -h as a positional would now get help; there is no such positional in this CLI (the only positional is an automation id). (b) The allowlist must be kept in sync when flags are added; a flag added to buildExecution but not to COMMAND_FLAGS will be rejected loudly rather than silently ignored, which is the right failure direction and would be caught by the first test that uses it. (c) The unknown-flag rejection is a behavior change for scripts that today pass stray flags and rely on exit 0 — that is exactly #2299's ask, and the error names the option. (d) No wire shape changes: argv is still string[] over POST /plugins/:id/cli, so no HOST_DAEMON_PROTOCOL_VERSION bump is involved. Per AGENTS.md, the help text changes should be mirrored in the plugin's skill docs if they enumerate flags (they do not mention --help today).
Longer-term. Put one parseArgv({ booleans, values, positionals }) + help helper in @get-bb/plugin-sdk so every plugin CLI gets commander-equivalent help and strict flags without re-implementing them; connect's validateFlags is the seed.
Prototype diff (proposed-fix.diff)
diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts
index 5225be6d2..03e9a6e35 100644
--- a/plugins/automations/src/cli.ts
+++ b/plugins/automations/src/cli.ts
@@ -44,14 +44,30 @@ interface ParsedArgs {
command: string;
positionals: string[];
flags: Map<string, string | true>;
+ /**
+ * `--help` / `-h` anywhere in argv. Help is a request for usage text, never
+ * a flag a subcommand may act on, so it is lifted out of `flags` here and
+ * every command short-circuits on it before touching the service
+ * (get-bb/bb#2323).
+ */
+ help: boolean;
+}
+
+function isHelpArg(arg: string): boolean {
+ return arg === "--help" || arg === "-h";
}
function parseArgs(argv: string[]): ParsedArgs {
const [command = "help", ...rest] = argv;
const positionals: string[] = [];
const flags = new Map<string, string | true>();
+ let help = isHelpArg(command);
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
+ if (isHelpArg(arg)) {
+ help = true;
+ continue;
+ }
if (arg.startsWith("--")) {
const [rawName, inlineValue] = arg.slice(2).split(/=(.*)/s, 2);
if (!rawName) throw new Error(`Invalid flag ${arg}`);
@@ -70,9 +86,74 @@ function parseArgs(argv: string[]): ParsedArgs {
positionals.push(arg);
}
}
- return { command, positionals, flags };
+ return { command, positionals, flags, help };
+}
+
+/**
+ * Every flag a subcommand reads. A flag outside the list is a usage error:
+ * silently dropping it turns a typo into a partial write that exits 0
+ * (get-bb/bb#2299).
+ */
+function assertKnownFlags(args: ParsedArgs, allowed: readonly string[]): void {
+ const known = new Set(allowed);
+ const unknown = [...args.flags.keys()].filter((name) => !known.has(name));
+ if (unknown.length > 0) {
+ throw new Error(
+ `Unknown option${unknown.length > 1 ? "s" : ""} ${unknown
+ .map((name) => `--${name}`)
+ .join(", ")} for 'bb automation ${args.command}'.\n\n${commandHelpText(args.command)}`,
+ );
+ }
}
+const SCHEDULE_FLAGS = ["cron", "timezone", "at", "in"] as const;
+const AGENT_EXECUTION_FLAGS = [
+ "prompt",
+ "provider",
+ "model",
+ "reasoning",
+ "service-tier",
+ "permission-mode",
+ "target-thread",
+ "environment",
+ "new-environment",
+ "base-branch",
+] as const;
+const SCRIPT_EXECUTION_FLAGS = [
+ "script",
+ "script-file",
+ "interpreter",
+ "timeout",
+ "env-json",
+ "host",
+] as const;
+const COMMAND_FLAGS: Record<string, readonly string[]> = {
+ list: ["project", "json"],
+ create: [
+ "project",
+ "name",
+ "disabled",
+ "json",
+ ...SCHEDULE_FLAGS,
+ ...AGENT_EXECUTION_FLAGS,
+ ...SCRIPT_EXECUTION_FLAGS,
+ ],
+ show: ["project", "json"],
+ update: [
+ "project",
+ "name",
+ "json",
+ ...SCHEDULE_FLAGS,
+ ...AGENT_EXECUTION_FLAGS,
+ ...SCRIPT_EXECUTION_FLAGS,
+ ],
+ pause: ["project", "json"],
+ resume: ["project", "json"],
+ run: ["project", "idempotency-key", "json"],
+ runs: ["project", "limit", "output", "json"],
+ delete: ["project", "yes", "json"],
+};
+
function flag(args: ParsedArgs, name: string): string | undefined {
const value = args.flags.get(name);
if (value === undefined || value === true) return undefined;
@@ -794,19 +875,69 @@ function printRunTable(runs: AutomationRunResponse[]): string {
);
}
+const AUTOMATION_COMMANDS = [
+ {
+ name: "list",
+ summary: "List automations for a project",
+ usage: "bb automation list --project <id> [--json]",
+ },
+ {
+ name: "create",
+ summary: "Create an automation",
+ usage:
+ "bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>]) [--disabled] [--json]",
+ },
+ {
+ name: "show",
+ summary: "Show automation details",
+ usage: "bb automation show <automationId> --project <id> [--json]",
+ },
+ {
+ name: "update",
+ summary: "Update automation configuration",
+ usage:
+ "bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]",
+ },
+ {
+ name: "pause",
+ summary: "Pause an automation",
+ usage: "bb automation pause <automationId> --project <id> [--json]",
+ },
+ {
+ name: "resume",
+ summary: "Resume an automation",
+ usage: "bb automation resume <automationId> --project <id> [--json]",
+ },
+ {
+ name: "run",
+ summary: "Run an automation now",
+ usage:
+ "bb automation run <automationId> --project <id> [--idempotency-key <key>] [--json]",
+ },
+ {
+ name: "runs",
+ summary: "List automation runs",
+ usage:
+ "bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>] [--json]",
+ },
+ {
+ name: "delete",
+ summary: "Delete an automation",
+ usage: "bb automation delete <automationId> --project <id> --yes [--json]",
+ },
+] as const;
+
function helpText(): string {
- return `Automation commands
+ return `Automation commands\n\n${AUTOMATION_COMMANDS.map(
+ (command) => command.usage,
+ ).join("\n")}\n`;
+}
-bb automation list --project <id>
-bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>])
-bb automation show <automationId> --project <id>
-bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none]
-bb automation pause <automationId> --project <id>
-bb automation resume <automationId> --project <id>
-bb automation run <automationId> --project <id> [--idempotency-key <key>]
-bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>]
-bb automation delete <automationId> --project <id> --yes
-`;
+/** Usage for one subcommand; falls back to the full listing for unknown names. */
+function commandHelpText(name: string): string {
+ const command = AUTOMATION_COMMANDS.find((entry) => entry.name === name);
+ if (!command) return helpText();
+ return `${command.summary}\n\n${command.usage}\n`;
}
export function registerAutomationCli(args: {
@@ -817,64 +948,29 @@ export function registerAutomationCli(args: {
bb.cli.register({
name: "automation",
summary: "Inspect and manage automations (scheduled agent/script runs)",
- commands: [
- {
- name: "list",
- summary: "List automations for a project",
- usage: "bb automation list --project <id> [--json]",
- },
- {
- name: "create",
- summary: "Create an automation",
- usage:
- "bb automation create --project <id> --name <name> [schedule flags] [mode flags]",
- },
- {
- name: "show",
- summary: "Show automation details",
- usage: "bb automation show <automationId> --project <id> [--json]",
- },
- {
- name: "update",
- summary: "Update automation configuration",
- usage: "bb automation update <automationId> --project <id> [flags]",
- },
- {
- name: "pause",
- summary: "Pause an automation",
- usage: "bb automation pause <automationId> --project <id> [--json]",
- },
- {
- name: "resume",
- summary: "Resume an automation",
- usage: "bb automation resume <automationId> --project <id> [--json]",
- },
- {
- name: "run",
- summary: "Run an automation now",
- usage:
- "bb automation run <automationId> --project <id> [--idempotency-key <key>] [--json]",
- },
- {
- name: "runs",
- summary: "List automation runs",
- usage:
- "bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>] [--json]",
- },
- {
- name: "delete",
- summary: "Delete an automation",
- usage:
- "bb automation delete <automationId> --project <id> --yes [--json]",
- },
- ],
+ commands: [...AUTOMATION_COMMANDS],
async run(argv: string[], ctx: PluginCliContext): Promise<PluginCliResult> {
try {
const parsed = parseArgs(argv);
const command = parsed.command;
- if (command === "help" || command === "--help" || command === "-h") {
- return { exitCode: 0, stdout: helpText() };
+ // Help is answered before any flag is read or any service method is
+ // called, wherever `--help` / `-h` sits in argv.
+ if (parsed.help || command === "help") {
+ return {
+ exitCode: 0,
+ stdout:
+ command === "help" || isHelpArg(command)
+ ? helpText()
+ : commandHelpText(command),
+ };
+ }
+ const allowedFlags = COMMAND_FLAGS[command];
+ if (allowedFlags === undefined) {
+ throw new Error(
+ `Unknown automation command '${command}'.\n\n${helpText()}`,
+ );
}
+ assertKnownFlags(parsed, allowedFlags);
if (command === "list") {
const result = service.list({
projectId: requireFlag(parsed, "project"),
Live CLI with the fix applied (live-verify-fix.txt)
### BEFORE
"name": "via-bogus",
"enabled": true,
"prompt": "NEW PROMPT",
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --prompt SHOULD NOT BE WRITTEN --help
Update automation configuration
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
[exit 0]
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --name should-not-rename -h
Update automation configuration
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
[exit 0]
$ bb automation pause auto_jo2opw4tqma --project proj_gyzaw4vu7z --help
Pause an automation
bb automation pause <automationId> --project <id> [--json]
[exit 0]
$ bb automation update auto_jo2opw4tqma --help
Update automation configuration
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
[exit 0]
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --name via-bogus-2 --bogus-flag
Unknown option --bogus-flag for 'bb automation update'.
Update automation configuration
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
[exit 1]
$ bb automation delete auto_jo2opw4tqma --project proj_gyzaw4vu7z --yes --help
Delete an automation
bb automation delete <automationId> --project <id> --yes [--json]
[exit 0]
$ bb automation --help
Automation commands
bb automation list --project <id> [--json]
bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>]) [--disabled] [--json]
bb automation show <automationId> --project <id> [--json]
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
bb automation pause <automationId> --project <id> [--json]
bb automation resume <automationId> --project <id> [--json]
bb automation run <automationId> --project <id> [--idempotency-key <key>] [--json]
bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>] [--json]
bb automation delete <automationId> --project <id> --yes [--json]
[exit 0]
$ bb automation -h
Automation commands
bb automation list --project <id> [--json]
bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>]) [--disabled] [--json]
bb automation show <automationId> --project <id> [--json]
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
bb automation pause <automationId> --project <id> [--json]
bb automation resume <automationId> --project <id> [--json]
bb automation run <automationId> --project <id> [--idempotency-key <key>] [--json]
bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>] [--json]
bb automation delete <automationId> --project <id> --yes [--json]
[exit 0]
$ bb automation help
Automation commands
bb automation list --project <id> [--json]
bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>]) [--disabled] [--json]
bb automation show <automationId> --project <id> [--json]
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none] [--json]
bb automation pause <automationId> --project <id> [--json]
bb automation resume <automationId> --project <id> [--json]
bb automation run <automationId> --project <id> [--idempotency-key <key>] [--json]
bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>] [--json]
bb automation delete <automationId> --project <id> --yes [--json]
[exit 0]
### AFTER (expected identical to BEFORE)
"name": "via-bogus",
"enabled": true,
"prompt": "NEW PROMPT",
vitest with the fix: repro file and existing suite
RUN v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-16/plugins/automations
Test Files 1 passed (1)
Tests 9 passed (9)
Start at 09:40:05
Duration 226ms (transform 65ms, setup 0ms, import 126ms, tests 18ms, environment 0ms)
RUN v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-16/plugins/automations
Test Files 2 passed (2)
Tests 52 passed (52)
Start at 09:40:25
Duration 1.45s (transform 334ms, setup 0ms, import 539ms, tests 1.35s, environment 0ms)
7. PR review
No open PR is linked to this issue (checked gh issue view 2323 and the issue timeline on 2026-08-24).
8. Related issues
- #2299 — "Builtin memory/automations/custom-instructions CLIs silently ignore unknown flags (core bb and tasks reject them)". Same parser, same pull-only accessor pattern; this issue's acceptance criterion "unknown flags return a non-zero usage error" is #2299 scoped to automations. One fix can close both for the automations plugin; the other plugins in #2299's table need the same treatment.
- #2166 — "automation update writes an over-cap prompt despite rejecting it" — another case of
bb automation updatewriting when it reports something else; different mechanism (service-level), worth verifying together since both concern update safety. - #1649 (closed) — the previous automations CLI bug; its test harness in
automations.test.ts("automation CLI --script-file") is the model I reused.
9. Appendix
Commands run
gh issue view 2323 --repo get-bb/bb --json … # issue body, no comments, labels cli + automations pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build # 18 tasks, all cached git fetch origin main && git log 494f66526..origin/main # empty: base == origin/main git blame -L 875,876 plugins/automations/src/cli.ts # 138f678027 (#516) scripts/bb-dev-app current # server :23555, app :15555, host daemon :31555 bash /tmp/bb-reports/issues/2323/repro/00-machine-list.sh bash /tmp/bb-reports/issues/2323/repro/01-live-repro.sh > live-repro.txt cd plugins/automations && pnpm exec vitest run src/cli-help-flag.repro.test.ts # 9/9 fail on base # apply proposed-fix.diff cd plugins/automations && pnpm exec vitest run src/cli-help-flag.repro.test.ts # 9/9 pass cd plugins/automations && pnpm exec vitest run src/automations.test.ts src/server-harness.test.ts # 52/52 pass pnpm exec turbo run typecheck --filter=bb-plugin-automations # pass scripts/bb-dev-app current && bash 02-live-verify-fix.sh > live-verify-fix.txt pnpm dev:stop; rm -rf ~/.bb-dev/…wf_846839f8-f8a-16-c02fa78b3d5c /tmp/bb-2323-qa
Live repro script
#!/usr/bin/env bash
# Live reproduction of get-bb/bb#2323 against an isolated bb dev instance.
# Run from the worktree root after `scripts/bb-dev-app current` is up.
#
# bash 01-live-repro.sh 2>&1 | tee live-repro.txt
set -uo pipefail
WORKTREE=/Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_846839f8-f8a-16
cd "$WORKTREE"
eval "$(scripts/bb-dev-app env)"
HOST_ID="${HOST_ID:-host_wae4wqhhzf}"
SCRATCH=/tmp/bb-2323-qa
bb() { node packages/scripts/dist/commands/run-cli.js "$@"; }
run() {
printf '\n$ bb %s\n' "$*"
bb "$@"
echo "[exit $?]"
}
echo "### 0. scratch git repo + project on the dev instance ($BB_SERVER_URL)"
rm -rf "$SCRATCH"; mkdir -p "$SCRATCH"; (cd "$SCRATCH" && git init -q && git commit -q --allow-empty -m init)
PROJECT=$(curl -s -X POST "$BB_SERVER_URL/api/v1/projects" -H 'content-type: application/json' \
-d "{\"name\":\"qa-2323\",\"source\":{\"type\":\"local_path\",\"path\":\"$SCRATCH\",\"hostId\":\"$HOST_ID\"}}" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const j=JSON.parse(s);console.log(j.project?.id??j.id)})')
echo "PROJECT=$PROJECT"
echo
echo "### 1. create a DISABLED agent automation (never runs on its own)"
CREATE_OUT=$(bb automation create --project "$PROJECT" --name original --disabled \
--cron "0 9 * * *" --timezone UTC \
--prompt "ORIGINAL PROMPT" --provider codex --model gpt-5 2>&1)
echo "$CREATE_OUT"
AUTO=$(echo "$CREATE_OUT" | sed -n 's/^Automation created: //p')
echo "AUTO=$AUTO"
echo
echo "### 2. BEFORE: stored prompt"
bb automation show "$AUTO" --project "$PROJECT" --json | grep -E '"(name|enabled|prompt)"'
echo
echo "### 3. BUG: trailing --help on update executes the update"
run automation update "$AUTO" --project "$PROJECT" --prompt "NEW PROMPT" --help
echo
echo "### 4. AFTER: stored prompt (expected unchanged, actual replaced)"
bb automation show "$AUTO" --project "$PROJECT" --json | grep -E '"(name|enabled|prompt)"'
echo
echo "### 5. BUG: trailing -h on update executes the update"
run automation update "$AUTO" --project "$PROJECT" --name renamed -h
bb automation show "$AUTO" --project "$PROJECT" --json | grep -E '"(name|enabled)"'
echo
echo "### 6. BUG: trailing --help on pause pauses"
run automation pause "$AUTO" --project "$PROJECT" --help
bb automation show "$AUTO" --project "$PROJECT" --json | grep -E '"enabled"'
echo
echo "### 7. BUG: trailing --help on resume resumes"
run automation resume "$AUTO" --project "$PROJECT" --help
bb automation show "$AUTO" --project "$PROJECT" --json | grep -E '"enabled"'
echo
echo "### 8. update <id> --help with no change flags: usage error instead of help"
run automation update "$AUTO" --help
run automation update "$AUTO" --project "$PROJECT" --help
echo
echo "### 9. unknown flag is silently ignored (exit 0, rename applied)"
run automation update "$AUTO" --project "$PROJECT" --name via-bogus --bogus-flag
bb automation show "$AUTO" --project "$PROJECT" --json | grep -E '"name"'
echo
echo "### 10. BUG: trailing --help on run starts a run (script automation so it is cheap)"
CREATE2=$(bb automation create --project "$PROJECT" --name script-auto --disabled \
--cron "0 9 * * *" --timezone UTC --script "echo hi-from-2323" 2>&1)
AUTO2=$(echo "$CREATE2" | sed -n 's/^Automation created: //p')
echo "AUTO2=$AUTO2"
run automation run "$AUTO2" --project "$PROJECT" --help
sleep 3
run automation runs "$AUTO2" --project "$PROJECT"
echo
echo "### 11. BUG: trailing --help on delete deletes"
run automation delete "$AUTO2" --project "$PROJECT" --yes --help
run automation show "$AUTO2" --project "$PROJECT"
echo
echo "### 12. contrast: core (commander) commands treat trailing --help / unknown flags correctly"
run thread list --help
run thread list --bogus-flag
run automation --help
Full live transcript on 494f66526
### 0. scratch git repo + project on the dev instance (http://localhost:23555)
PROJECT=proj_gyzaw4vu7z
### 1. create a DISABLED agent automation (never runs on its own)
Automation created: auto_jo2opw4tqma
ID: auto_jo2opw4tqma
Name: original
Enabled: no
Mode: agent
Schedule: 0 9 * * * (UTC)
Next run: -
Last run: -
Runs: 0
Origin: human
Provider: codex
Model: gpt-5
Reasoning: medium
Tier: -
Permission: auto
AUTO=auto_jo2opw4tqma
### 2. BEFORE: stored prompt
"name": "original",
"enabled": false,
"prompt": "ORIGINAL PROMPT",
### 3. BUG: trailing --help on update executes the update
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --prompt NEW PROMPT --help
Automation auto_jo2opw4tqma updated
ID: auto_jo2opw4tqma
Name: original
Enabled: no
Mode: agent
Schedule: 0 9 * * * (UTC)
Next run: -
Last run: -
Runs: 0
Origin: human
Provider: codex
Model: gpt-5
Reasoning: medium
Tier: -
Permission: auto
[exit 0]
### 4. AFTER: stored prompt (expected unchanged, actual replaced)
"name": "original",
"enabled": false,
"prompt": "NEW PROMPT",
### 5. BUG: trailing -h on update executes the update
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --name renamed -h
Automation auto_jo2opw4tqma updated
ID: auto_jo2opw4tqma
Name: renamed
Enabled: no
Mode: agent
Schedule: 0 9 * * * (UTC)
Next run: -
Last run: -
Runs: 0
Origin: human
Provider: codex
Model: gpt-5
Reasoning: medium
Tier: -
Permission: auto
[exit 0]
"name": "renamed",
"enabled": false,
### 6. BUG: trailing --help on pause pauses
$ bb automation pause auto_jo2opw4tqma --project proj_gyzaw4vu7z --help
Automation auto_jo2opw4tqma paused
[exit 0]
"enabled": false,
### 7. BUG: trailing --help on resume resumes
$ bb automation resume auto_jo2opw4tqma --project proj_gyzaw4vu7z --help
Automation auto_jo2opw4tqma resumed
[exit 0]
"enabled": true,
### 8. update <id> --help with no change flags: usage error instead of help
$ bb automation update auto_jo2opw4tqma --help
Missing required option --project <value>.
[exit 1]
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --help
No changes requested. Provide --name, schedule flags, a complete agent/script execution, or partial agent update flags.
[exit 1]
### 9. unknown flag is silently ignored (exit 0, rename applied)
$ bb automation update auto_jo2opw4tqma --project proj_gyzaw4vu7z --name via-bogus --bogus-flag
Automation auto_jo2opw4tqma updated
ID: auto_jo2opw4tqma
Name: via-bogus
Enabled: yes
Mode: agent
Schedule: 0 9 * * * (UTC)
Next run: 8/25/2026, 2:00:00 AM
Last run: -
Runs: 0
Origin: human
Provider: codex
Model: gpt-5
Reasoning: medium
Tier: -
Permission: auto
[exit 0]
"name": "via-bogus",
### 10. BUG: trailing --help on run starts a run (script automation so it is cheap)
AUTO2=auto_svy97gpmqjw
$ bb automation run auto_svy97gpmqjw --project proj_gyzaw4vu7z --help
Run started: arun_uy8ifckcjvo
[exit 0]
$ bb automation runs auto_svy97gpmqjw --project proj_gyzaw4vu7z
ID Status Started Thread/Exit Detail
arun_uy8ifckcjvo succeeded 8/24/2026, 9:38:26 AM exit 0 -
[exit 0]
### 11. BUG: trailing --help on delete deletes
$ bb automation delete auto_svy97gpmqjw --project proj_gyzaw4vu7z --yes --help
Automation auto_svy97gpmqjw deleted
[exit 0]
$ bb automation show auto_svy97gpmqjw --project proj_gyzaw4vu7z
Automation not found
[exit 1]
### 12. contrast: core (commander) commands treat trailing --help / unknown flags correctly
$ bb thread list --help
Usage: bb thread list [options]
List threads
Options:
--project <id> Filter by project ID (defaults to all projects)
--parent-thread <id> Filter by parent thread ID
--section <id> Filter by thread section ID
--unsectioned Show only threads outside sections
--archived Show only archived threads
--include-hidden Include hidden threads
--json Print machine-readable JSON output
-h, --help display help for command
[exit 0]
$ bb thread list --bogus-flag
error: unknown option '--bogus-flag'
[exit 1]
$ bb automation --help
Automation commands
bb automation list --project <id>
bb automation create --project <id> --name <name> (--cron <expr> --timezone <tz> | --at <datetime> | --in <duration>) (--prompt <text> --provider <id> --model <model> [--reasoning <level>] [--service-tier default|fast] | --script <inline> | --script-file <path> [--host <name-or-id>])
bb automation show <automationId> --project <id>
bb automation update <automationId> --project <id> [--name <name>] [schedule flags] [complete agent/script execution flags | --provider <id> --model <model> --reasoning <level> --service-tier default|fast|none]
bb automation pause <automationId> --project <id>
bb automation resume <automationId> --project <id>
bb automation run <automationId> --project <id> [--idempotency-key <key>]
bb automation runs <automationId> --project <id> [--limit <count>] [--output <runId>]
bb automation delete <automationId> --project <id> --yes
[exit 0]
Artifacts
01-live-repro.sh,live-repro.txt— the bug, livecli-help-flag.repro.test.ts,vitest-base-494f66526.txt— failing unit tests on baseproposed-fix.diff,vitest-with-fix.txt,vitest-existing-suite-with-fix.txt,typecheck-with-fix.txt,02-live-verify-fix.sh,live-verify-fix.txt— the fix, verifiedcontributions.json—GET /api/v1/plugins/contributionsfrom the dev instance (showsautomationis a plugin-proxied command)
No screenshots: this is a CLI/data bug with no visual component; all evidence is verbatim terminal output.