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.

RunUTC startTestsHelp requests / service callsDispatch controls
A2026-09-30 18:32:327/7 passed66 / 08/8 detected
B, second clean checkout2026-09-30 18:33:137/7 passed66 / 08/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

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.

← reports

#2323 · automation subcommand --help can execute mutations

Bug Priority: High Effort: small cli automations open on GitHub 2026-08-24 · base 494f66526

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 issueStatusEvidence
--help is recognized only when it occupies the command token itself.Verifiedcli.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.VerifiedLive 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 verifiedI 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.Verifiedcli.ts#L927-L940: buildUpdateRequest → service.update(request), no help check. The unit test records serviceCalls = ["update"].
Every subcommand (not just update) is affected.VerifiedLive 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).VerifiedStep 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

4. Minimal reproduction

4a. Live CLI (30 seconds, no provider usage)

  1. 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 "$@"; }
  2. 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
  3. Ask for help on update the 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 of 01-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",
    
  4. Every other mutating subcommand behaves the same, and -h too. run --help actually started and completed a run; delete --yes --help deleted 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]
    
  5. 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",
    
  6. 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).

  1. Lift help out of the flag map. In parseArgs, treat --help and -h at any position (including the command token) as help: true and 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.
  2. Short-circuit first in run(). if (parsed.help || command === "help") return usage before requireFlag("project"), before buildExecution/buildUpdateRequest, before any service.* call. Print the subcommand's own usage line (hoist the commands: [...] registration list to a constant so helpText() and a new commandHelpText(name) derive from one source; this also fixes the drift where the registered create usage was shorter than helpText()'s).
  3. Reject unknown flags. Add a per-subcommand allowlist (COMMAND_FLAGS) and assertKnownFlags(parsed, allowed) right after the help check, before the command body. Unknown names produce exit 1 with the subcommand usage. Note run has no runs-style extra flags and delete needs --yes; the allowlists in the diff were derived by grepping every flag(args, "…")/flags.has("…") in the file (28 names).
  4. Tests. Promote cli-help-flag.repro.test.ts (rename to taste) — it already covers create, update, pause, resume, run, delete with 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

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

No screenshots: this is a CLI/data bug with no visual component; all evidence is verbatim terminal output.