@lyupro/codex-bridge 0.6.8 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +91 -0
  25. package/cli/lifecycle-lock.mjs +335 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +2 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/terminal-question.mjs +41 -0
  46. package/cli/uninstall-purge.mjs +139 -0
  47. package/cli/uninstall-rules.mjs +72 -0
  48. package/cli/uninstall.mjs +208 -127
  49. package/cli/unlock-lifecycle.mjs +102 -0
  50. package/cli/unlock.mjs +7 -4
  51. package/cli/update.mjs +102 -27
  52. package/package.json +4 -3
  53. package/scripts/home-write-audit.mjs +286 -0
  54. package/scripts/home-write-inventory.mjs +49 -0
  55. package/scripts/home-write-lexer.mjs +182 -0
  56. package/scripts/isolated-roots.mjs +6 -1
  57. package/scripts/open-work/check.mjs +180 -0
  58. package/scripts/open-work/live-thresholds.mjs +155 -0
  59. package/scripts/open-work/register.mjs +56 -0
  60. package/scripts/root-guard/root-entries.mjs +15 -0
  61. package/scripts/run-tests.mjs +26 -1
  62. package/src/agents/codex-advisor.md +5 -2
  63. package/src/home/conventions.md +9 -0
  64. package/src/home/hooks/guard-tries.mjs +6 -4
  65. package/src/home/hooks/order-gate.mjs +2 -8
  66. package/src/home/hooks/reply-guard.mjs +2 -7
  67. package/src/home/hooks/worktree-witness.mjs +6 -6
  68. package/src/home/lib/agents.mjs +1 -1
  69. package/src/home/lib/atomic-json.mjs +24 -6
  70. package/src/home/lib/config-edit.mjs +25 -10
  71. package/src/home/lib/dispatcher-state.mjs +25 -10
  72. package/src/home/lib/file-lock.mjs +41 -10
  73. package/src/home/lib/handback-witness.mjs +7 -4
  74. package/src/home/lib/home-registry.mjs +211 -0
  75. package/src/home/lib/home-write.mjs +189 -0
  76. package/src/home/lib/hook-diagnostics.mjs +20 -0
  77. package/src/home/lib/host-observations.mjs +7 -4
  78. package/src/home/lib/meta/advice-status.mjs +2 -2
  79. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  80. package/src/home/lib/meta/events.mjs +2 -1
  81. package/src/home/lib/meta/paths.mjs +24 -24
  82. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  83. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  84. package/src/home/lib/meta/verdict.mjs +14 -16
  85. package/src/home/lib/run-config.mjs +10 -1
  86. package/src/home/lib/runner/git-paths.mjs +74 -0
  87. package/src/home/lib/runner/git-state.mjs +38 -34
  88. package/src/home/lib/runner/launcher.mjs +2 -2
  89. package/src/home/lib/runner/preflight.mjs +7 -0
  90. package/src/home/lib/runner/prompts.mjs +35 -2
  91. package/src/home/lib/runner/schemas.mjs +4 -1
  92. package/src/home/lib/runner/scope-check.mjs +29 -20
  93. package/src/home/lib/runner/worker.mjs +3 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,115 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.9] - 2026-10-02
8
+
9
+ ### Added
10
+
11
+ - `uninstall --purge` can remove operator data and the home folder after one preflight under the lifecycle lock:
12
+ no live runs, a readable installation record and no other recorded owners, before permissions, hooks or files change.
13
+ A live run is named with its stop command; an unreadable run folder refuses because it cannot be proven idle.
14
+ - Purge asks separately whether the host inventory is complete and whether it may remove the listed operator data,
15
+ because a record cannot prove that nobody else uses the home and inventory consent is not deletion consent.
16
+ Without a terminal both answers are no; a refusal or cancellation changes nothing.
17
+ - `uninstall --purge --dry-run` reports every refusal together and lists removals under
18
+ `If you confirm both questions:`, so an inspection never implies consent. It takes no lock and asks no questions;
19
+ refusals and unsafe entries exit 1, while the two pending confirmations alone do not.
20
+ - The "Uninstall and purge" section in `docs/overview.md` explains removal and its limits, because a run can start
21
+ during purge and Node cannot prevent an ancestor being swapped between its check and removal.
22
+ - The installation record keeps one owner per host sharing the home, because installing a second host used to erase
23
+ the first host's record. A new home starts with a complete inventory; an old record cannot prove all its owners.
24
+ - Interactive install and update ask once whether this host is the home's only owner when migrating an old record
25
+ or recording a pre-existing image, because those transitions leave other users unknown. Uninstall asks again when
26
+ the last known owner of an incomplete inventory leaves; no keeps the image, and Ctrl+C cancels before changes.
27
+ - `doctor` prints `host files` and `home owners` lines showing package files and hooks, files uninstall would keep,
28
+ recorded hosts and inventory completeness, because "not installed" had hidden files in an unrecorded host.
29
+ - The `doctor` line `owners in sync` names hosts verified against an older shared image and gives their update commands,
30
+ because a warning printed only during install was easily lost. It reports recorded verification, not a fresh
31
+ inspection of those hosts, and an incomplete inventory never reads as all in sync.
32
+ - Install, update and uninstall take the home's lifecycle lock before their first read, because concurrent hosts
33
+ could interleave image copies and record writes. Windows uses a named pipe and Linux an abstract socket released
34
+ by the kernel when the holder dies; macOS uses a lock file requiring explicit recovery. Dry runs take no lock.
35
+ - `codex-bridge unlock --lifecycle` shows whether the home is free, held or unverified, with the holder's command,
36
+ host and pid when available, because a busy-home refusal previously pointed to a command that did not exist.
37
+ - `codex-bridge unlock --lifecycle --clear` removes only a proven-dead holder's macOS lock file, because automatic
38
+ takeover could remove a live or replacement holder's lock. Windows and Linux have no lock file to clear.
39
+ - Every command and its actions answer `-h` and `--help` from one command registry, because separate usage text had
40
+ omitted model actions and subcommands refused help. A flag that needs a value still refuses `--host --help`.
41
+ - The model table ends with `codex-bridge model set <role> <model> [effort]` and `codex-bridge model list`, because
42
+ the profiles alone did not tell the operator how to change one.
43
+
44
+ ### Changed
45
+
46
+ - Install and update bring other recorded hosts' package files and hooks along with the shared image, even when the
47
+ initiating host is already current, because those hosts had kept old agents without the guards the new image needs.
48
+ A host left behind is named with its update command and makes the command exit 1.
49
+ - Other hosts' edited, missing or linked files remain conflicts that the initiating host's `--force` cannot override,
50
+ because replacing the shared image must not erase another host's edits. Synchronization leaves permissions, seeds
51
+ and Codex rules ownership alone; unreachable host roots are named rather than created.
52
+ - `uninstall` removes only the departing host's owner entry while other hosts still use the home, because removing
53
+ the whole record and image had broken their installations. The shared image goes only after the last owner's
54
+ hooks are gone and the inventory is complete or the operator confirms nobody else uses it.
55
+ - The installer and uninstall touch only declared files in the package home, because undeclared writes and copied
56
+ removal lists had left files that removal could not account for.
57
+ - Ordinary `uninstall` and its dry run use one home removal plan, and the real summary counts actual outcomes and
58
+ distinguishes an owner removed from a record removed, because planned removals were reported as completed even
59
+ when files stayed. Folders are removed only when empty, never recursively.
60
+ - Ordinary `uninstall` names each operator file it keeps for `--purge`, such as `config.json` and `conventions.md`,
61
+ because a hand-written preservation sentence missed files. Runs in `codex-runs` remain outside both uninstall
62
+ and purge.
63
+ - Advisor default budgets are now 10 minutes for `scope` and 25 minutes for `advise`, because measured advise runs
64
+ approached or exceeded the previous 15-minute deadline.
65
+ - Worktree snapshots use format v2 with exact file names, because quoted and trimmed legacy names could change the
66
+ verdict. A legacy clean snapshot still reads as clean; missing, damaged or incompatible snapshot pairs fail the
67
+ verdict rather than count as a clean tree, including a run started before and judged after this format update.
68
+
69
+ ### Fixed
70
+
71
+ - `uninstall` recognizes host files and hooks by the package's marks and deletes files only when content matches
72
+ that host's recorded fingerprint or the current package, because edited agents were lost and unrecorded hosts
73
+ could not be cleaned safely. Edited, unknown and seed files, links and unreadable entries are kept and named.
74
+ - Host hooks are removed before host files, and a host whose hooks cannot be detached keeps its files, rules
75
+ ownership, record entry and shared image, because a broken `settings.json` had left hooks pointing at removed files.
76
+ - Edited image files stay and are named by uninstall and purge unless their content proves they are package files,
77
+ because recorded paths alone had authorized deletion. Links are never crossed or removed; unreadable entries are
78
+ kept, and a link, inspection error or removal failure exits 1 instead of claiming complete removal.
79
+ - Purge re-checks the home after removal, names files that appeared with `Appeared during purge:` and exits 1,
80
+ because a run started during purge can recreate a file after it was removed.
81
+ - Repeating `uninstall` for a home with no recorded owners asks whether anyone still uses it, because declining
82
+ removal once had left a home that the next uninstall incorrectly called "not installed".
83
+ - A migrated old record is no longer treated as every unrecorded host's own installation, because update and
84
+ uninstall had operated on another host's files. Install or update can record an unrecorded host from its
85
+ package marks.
86
+ - Install and update refresh the host's stale image verification even when its files are current, because the
87
+ "up to date" exit had left the `doctor` warning in place after the prescribed update.
88
+ - Command usage refusals point to the command's `-h`, including inside the JSON error from `prune --json`,
89
+ because malformed arguments had failed without showing where to find the correct syntax.
90
+ - `model speed` refuses dash arguments before reading config or fetching the catalogue and points to
91
+ `model speed -h`, because `model speed build --fast` had treated the flag as a tier identifier.
92
+ - Advisor prose containing clock times, ratios or host:port no longer fails as a file citation, because `00:05`
93
+ had been read as a file and line number. Invented paths and citations outside the allowed files still fail.
94
+ - Advisor recommendations can contain full text and paragraphs, because hidden length and line-break limits had
95
+ cut answers mid-citation or caused broken text during decoding. Remaining array bounds are stated in the prompt.
96
+ - Broken advisor text now fails with a named contract error, and reply previews preserve whole characters,
97
+ because a NUL-containing answer had passed and UTF-16 shortening could split an emoji.
98
+ - An advisor `scope` with `--continue` is refused before starting and asks for a new order id, because continuing
99
+ scope spent the order's only continuation and made its advise phase unreachable.
100
+ - Scope checks, snapshots and verdicts read Git file names byte-exact, so Cyrillic and other non-ASCII names work,
101
+ because Git's quoted output had refused valid scopes and judged in-scope edits outside them. Invalid UTF-8 names
102
+ fail instead of being silently replaced, and scope patterns with line breaks are refused before starting.
103
+ - An untracked file edit that keeps the same size now appears in the snapshot comparison, because recording size
104
+ alone had made such edits invisible; files that disappear or cannot be read are recorded as missing or unreadable.
105
+ - An unreadable `config.json` fails with its path and reason in the runner and `doctor`, because permission errors
106
+ or a directory in its place had silently selected default models and budgets. Only a missing file uses defaults.
107
+ - `prune` checks links from the trusted root down and keeps the target on any inspection error, because checking
108
+ from the leaf up had crossed links first and treated unreadable ancestors as permission to delete.
109
+
110
+ After `npm i -g @lyupro/codex-bridge@0.6.9`, run `codex-bridge update` once to migrate the installation record.
111
+ An interactive update asks once whether this host is the home's only owner; answer it, because it decides whether a
112
+ later uninstall may remove the shared image. Every host sharing the home should run 0.6.9: older CLI copies do not take
113
+ the lifecycle lock. `codex-bridge doctor` from 0.6.8 cannot read a record written by 0.6.9 and reports
114
+ `[fail] installation: broken record`; installing 0.6.9 clears that report.
115
+
7
116
  ## [0.6.8] - 2026-09-25
8
117
 
9
118
  ### Added
package/README.md CHANGED
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **1667 automated tests: 1666 passing and 1 skipped**.
65
+ The current suite contains **2570 automated tests: 2569 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -129,17 +129,19 @@ Transport files from runs at least **30 days** old are pruned automatically when
129
129
  | `install [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Install into a Claude Code host. |
130
130
  | `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
131
131
  | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
132
- | `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
132
+ | `uninstall [--scope user\|project] [--host <path>] [--dry-run] [--purge]` | Remove recorded package files while preserving run artifacts; `--purge` also deletes your data and the home after one preflight and two consents (see [Uninstall and purge](docs/overview.md#uninstall-and-purge)). |
133
133
  | `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
134
134
  | `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
135
135
  | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
136
136
  | `projects [<name>] [--json]` | List projects or runs in the run store. |
137
137
  | `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]`<br>`prune --all-projects [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup, for one project or across the whole run store. |
138
- | `unlock [<project>\|--all]` | Close records whose runner is gone. |
138
+ | `unlock [<project>\|--all]`<br>`unlock --lifecycle [--clear]` | Close records whose runner is gone, or show who holds the lock that serializes install, update and uninstall of this home; `--clear` removes that lock only when it is a file left by a holder proven dead (macOS; Windows and Linux locks are released by the kernel). |
139
139
  | `read <run>` | Render a run's structured event stream. |
140
140
  | `stop <run>` | Stop a run and record a `FAIL` verdict. |
141
141
 
142
- Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms.
142
+ Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms, or `-h` after
143
+ any command or action for its own usage (`codex-bridge model -h`, `codex-bridge model set -h`); a refusal of an unknown
144
+ option or an extra argument ends with that pointer.
143
145
 
144
146
  ### Development
145
147
 
@@ -1,89 +1,33 @@
1
1
  #!/usr/bin/env node
2
- /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
- import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
- import { probeContract } from '../cli/probe-contract.mjs';
5
- import { resolveProbeTarget } from '../cli/probe-target.mjs';
6
- import { brandStateDir } from '../src/home/lib/brand-home.mjs';
2
+ /** Dispatches codex-bridge CLI arguments through the command registry (Plan_71 D1). */
7
3
  import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
8
- import { hook } from '../cli/hook.mjs';
9
- import { resolveHost } from '../cli/hosts.mjs';
10
- import { install } from '../cli/install.mjs';
11
- import { model } from '../cli/model.mjs';
12
- import { projects } from '../cli/projects.mjs';
13
- import { read } from '../cli/read.mjs';
14
- import { runCodex } from '../cli/run-launcher.mjs';
15
4
  import { packageInfo } from '../cli/manifest.mjs';
16
- import { permissions } from '../cli/permissions.mjs';
17
- import { prune } from '../cli/prune.mjs';
18
- import { stop } from '../cli/stop.mjs';
19
- import { unlock } from '../cli/unlock.mjs';
20
- import { uninstall } from '../cli/uninstall.mjs';
21
- import { update } from '../cli/update.mjs';
22
-
23
- export const HELP = `codex-bridge — Claude Code dispatchers for Codex
24
-
25
- Usage:
26
- codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
27
- codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
28
- codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
29
- codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
30
- codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]
31
- codex-bridge run <runner options> --task-file <path>
32
- codex-bridge model [list]
33
- codex-bridge projects [<name>] [--json]
34
- codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
35
- codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
36
- codex-bridge unlock [<project>|--all]
37
- codex-bridge read <run>
38
- codex-bridge stop <run>
39
- codex-bridge hook <name>
40
- codexb <same command forms as codex-bridge>
41
- codex-bridge --help
42
- codex-bridge --version
43
-
44
- Commands:
45
- install Install codex-bridge into the selected Claude Code host
46
- update Update a recorded codex-bridge installation
47
- permissions Show, add, or remove optional shell permission rules
48
- uninstall Remove installed files while preserving run artifacts
49
- doctor Diagnose the selected Claude Code host (--probe-contract measures on a live host)
50
- run Start or attach to a delegated Codex run
51
- model Show machine-wide model profiles or list the live catalogue
52
- projects List projects or runs from the run store
53
- prune Remove archived transport, or purge selected run folders
54
- unlock Close running records whose runner is gone
55
- read Render a run's structured event stream
56
- stop Stop a running Codex run and record FAIL
57
-
58
- Hook dispatch:
59
- codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
60
-
61
- const HOOK_COMMAND = 'hook';
62
-
63
- export function commandOptions(command, argv) {
64
- const options = {};
65
- const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
66
- : command === 'uninstall' ? new Set(['--dry-run'])
67
- : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
68
- const flagNames = new Map([
69
- ['--dry-run', 'dryRun'],
70
- ['--force', 'force'],
71
- ['--probe-contract', 'probeContract'],
72
- ]);
73
- for (let index = 0; index < argv.length; index += 1) {
74
- const arg = argv[index];
75
- if (booleanFlags.has(arg)) {
76
- options[flagNames.get(arg)] = true;
77
- continue;
78
- }
79
- if (arg !== '--scope' && arg !== '--host' && !(command === 'doctor' && arg === '--probe-executable')) throw new Error(`unknown ${command} option "${arg}"`);
80
- const value = argv[index + 1];
81
- if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
82
- options[arg === '--scope' ? 'scope' : arg === '--host' ? 'host' : 'probeExecutable'] = value;
83
- index += 1;
84
- }
85
- return options;
86
- }
5
+ import { COMMANDS } from '../cli/command-registry.mjs';
6
+ import { helpRequest, renderCommandHelp } from '../cli/command-help.mjs';
7
+
8
+ export { commandOptions } from '../cli/command-registry.mjs';
9
+
10
+ const publicCommands = COMMANDS.filter((entry) => entry.section === 'public');
11
+ const commandWidth = Math.max(...publicCommands.map((entry) => entry.name.length));
12
+ export const HELP = [
13
+ 'codex-bridge — Claude Code dispatchers for Codex',
14
+ '',
15
+ 'Usage:',
16
+ ...publicCommands.flatMap((entry) => [entry, ...Object.values(entry.actions ?? {})]
17
+ .flatMap((section) => section.usage.map((line) => ` ${line}`))),
18
+ ' codexb <same command forms as codex-bridge>',
19
+ ' codex-bridge --help',
20
+ ' codex-bridge --version',
21
+ '',
22
+ 'Commands:',
23
+ ...publicCommands.map((entry) => ` ${entry.name.padEnd(commandWidth)} ${entry.summary}`),
24
+ '',
25
+ 'Hook dispatch:',
26
+ ...COMMANDS.filter((entry) => entry.section === 'machine')
27
+ .flatMap((entry) => entry.usage.map((line) => ` ${line} ${entry.summary}`)),
28
+ '',
29
+ 'Run codex-bridge <command> -h for one command.',
30
+ ].join('\n');
87
31
 
88
32
  export async function main(argv, io = console) {
89
33
  const [command, ...rest] = argv;
@@ -95,103 +39,17 @@ export async function main(argv, io = console) {
95
39
  io.log((await packageInfo()).version);
96
40
  return 0;
97
41
  }
98
- if (command === 'run') return runCodex(rest);
99
- if (command === 'doctor') {
100
- const options = commandOptions(command, rest);
101
- if (options.probeExecutable && !options.probeContract) throw new Error('--probe-executable requires --probe-contract');
102
- const host = resolveHost(options);
103
- // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
104
- // measured. Printing the diagnosis first and the measurement after would answer one question
105
- // twice in one output, with the older answer on top (Plan_52 D25).
106
- let probe = null;
107
- if (options.probeContract) {
108
- const target = resolveProbeTarget({ stateDir: brandStateDir(host.brandRoot), executable: options.probeExecutable });
109
- if (!target.error) io.log(`probe: target ${target.version} at ${target.executable} (${target.source})`);
110
- probe = await probeContract({ host, target });
111
- io.log(`probe: ${probe.message}`);
112
- for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
113
- io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
114
- }
115
- }
116
- const result = await diagnose({ host });
117
- io.log(renderDoctor(result));
118
- // An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
119
- // measurement pass silently in a script (Plan_52 D26).
120
- return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
121
- }
122
- if (command === 'stop') {
123
- if (rest.length !== 1) {
124
- io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
125
- return 2;
126
- }
127
- const result = await stop({ run: rest[0] });
128
- io.log(result.output);
129
- return result.exitCode;
130
- }
131
- if (command === HOOK_COMMAND) return hook(rest, io);
132
- if (command === 'read') {
133
- if (rest.length !== 1) {
134
- io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
135
- return 2;
136
- }
137
- const result = read({ run: rest[0] });
138
- io.log(result.output);
139
- return result.exitCode;
140
- }
141
- if (command === 'projects') {
142
- const result = projects(rest);
143
- io.log(result.output);
144
- return result.exitCode;
145
- }
146
- if (command === 'model') {
147
- const result = await model(rest);
148
- io.log(result.output);
149
- return result.exitCode;
150
- }
151
- if (command === 'prune') {
152
- const result = await prune(rest);
153
- io.log(result.output);
154
- return result.exitCode;
155
- }
156
- if (command === 'sweep') {
157
- io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
42
+ const entry = COMMANDS.find((candidate) => candidate.name === command);
43
+ if (!entry) {
44
+ io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
158
45
  return 2;
159
46
  }
160
- if (command === 'unlock') {
161
- const result = unlock(rest);
162
- io.log(result.output);
163
- return result.exitCode;
164
- }
165
- if (command === 'permissions') {
166
- let action;
167
- let optionArgs = rest;
168
- if (rest[0] && !rest[0].startsWith('-')) {
169
- action = rest[0];
170
- optionArgs = rest.slice(1);
171
- }
172
- if (action && !['add', 'remove'].includes(action)) {
173
- throw new Error(`unknown permissions action "${action}"`);
174
- }
175
- const options = commandOptions(command, optionArgs);
176
- const host = resolveHost(options);
177
- const result = await permissions({ host, action });
178
- io.log(result.output);
179
- return result.exitCode;
180
- }
181
- if (command === 'install' || command === 'update' || command === 'uninstall') {
182
- const options = commandOptions(command, rest);
183
- const host = resolveHost(options);
184
- const handlers = {
185
- install: () => install({ host, dryRun: options.dryRun, force: options.force }),
186
- update: () => update({ host, dryRun: options.dryRun, force: options.force }),
187
- uninstall: () => uninstall({ host, dryRun: options.dryRun }),
188
- };
189
- const result = await handlers[command]();
190
- io.log(result.output);
191
- return result.exitCode;
47
+ const asked = helpRequest(rest, Object.keys(entry.actions ?? {}));
48
+ if (asked && entry.section !== 'renamed') {
49
+ io.log(renderCommandHelp(entry, asked.action));
50
+ return 0;
192
51
  }
193
- io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
194
- return 2;
52
+ return entry.handler(rest, io);
195
53
  }
196
54
 
197
55
  if (isInvokedDirectly(process.argv[1], import.meta.url)) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Decides what a help request is and how one command's help reads (Plan_71 D1).
3
+ * `codex-bridge model -h` answered "unexpected argument" and no subcommand answered -h at all
4
+ * (2026-09-29), so the operator could not learn how to set a model. Only exact argv shapes count
5
+ * (advice A1): a scan for --help would turn `--host --help` — today a "requires a value" refusal —
6
+ * into a help answer and hide the parser's error.
7
+ */
8
+
9
+ export function helpRequest(argv, actions = []) {
10
+ const isHelp = (value) => value === '-h' || value === '--help';
11
+ if (argv.length === 1 && isHelp(argv[0])) return { action: null };
12
+ if (argv.length === 2 && actions.includes(argv[0]) && isHelp(argv[1])) {
13
+ return { action: argv[0] };
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function withCommandHelp(command, message) {
19
+ const hint = `Run codex-bridge ${command} -h for usage.`;
20
+ if (message === hint || message.endsWith(`\n${hint}`)) return message;
21
+ return `${message}\n${hint}`;
22
+ }
23
+
24
+ export function renderCommandHelp(entry, action = null) {
25
+ const prefix = `codex-bridge ${entry.name}`;
26
+ const actions = Object.entries(entry.actions ?? {});
27
+ // Registry typos must fail even when the malformed action is not the requested one.
28
+ for (const section of [entry, ...actions.map(([, details]) => details)]) {
29
+ for (const line of section.usage) {
30
+ if (line !== prefix && !line.startsWith(`${prefix} `)) {
31
+ throw new Error(`Help usage must start with "${prefix}": ${line}`);
32
+ }
33
+ }
34
+ }
35
+
36
+ let section = entry;
37
+ if (action !== null) {
38
+ const match = actions.find(([name]) => name === action);
39
+ if (!match) throw new Error(`Unknown help action for ${entry.name}: ${action}`);
40
+ section = match[1];
41
+ }
42
+ const lines = ['Usage:', ...section.usage.map((line) => ` ${line}`), '', section.summary];
43
+ if (action === null && entry.actions) {
44
+ const width = Math.max(...actions.map(([name]) => name.length));
45
+ lines.push('', 'Actions:', ...actions.map(([name, details]) => ` ${name.padEnd(width)} ${details.summary}`));
46
+ lines.push(`Run codex-bridge ${entry.name} <action> -h for one action.`);
47
+ }
48
+ return lines.join('\n');
49
+ }