@lyupro/codex-bridge 0.1.0

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 (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/bin/codex-bridge.mjs +105 -0
  5. package/cli/copy.mjs +38 -0
  6. package/cli/doctor.mjs +170 -0
  7. package/cli/hosts.mjs +50 -0
  8. package/cli/install.mjs +109 -0
  9. package/cli/manifest.mjs +210 -0
  10. package/cli/settings-merge.mjs +101 -0
  11. package/cli/stop.mjs +125 -0
  12. package/cli/uninstall.mjs +62 -0
  13. package/cli/update.mjs +156 -0
  14. package/package.json +48 -0
  15. package/scripts/check-file-size.mjs +193 -0
  16. package/scripts/run-tests.mjs +26 -0
  17. package/src/agents/codex-build.md +224 -0
  18. package/src/agents/codex-review.md +166 -0
  19. package/src/agents/codex-scout.md +188 -0
  20. package/src/commands/env.md +38 -0
  21. package/src/commands/usage.md +88 -0
  22. package/src/hooks/reply-guard.mjs +311 -0
  23. package/src/meta/chain.mjs +87 -0
  24. package/src/meta/environment.mjs +36 -0
  25. package/src/meta/paths.mjs +193 -0
  26. package/src/meta/reply.mjs +133 -0
  27. package/src/meta/run-state.mjs +197 -0
  28. package/src/meta/verdict.mjs +362 -0
  29. package/src/rules/codex-bridge.rules +173 -0
  30. package/src/run-codex.mjs +113 -0
  31. package/src/run-config.json +4 -0
  32. package/src/run-config.mjs +302 -0
  33. package/src/runner/args.mjs +111 -0
  34. package/src/runner/attach.mjs +111 -0
  35. package/src/runner/codex-cmd.mjs +265 -0
  36. package/src/runner/git-state.mjs +144 -0
  37. package/src/runner/launcher.mjs +374 -0
  38. package/src/runner/project-dir.mjs +92 -0
  39. package/src/runner/prompts.mjs +97 -0
  40. package/src/runner/run-context.mjs +52 -0
  41. package/src/runner/run-env.mjs +38 -0
  42. package/src/runner/runs-root.mjs +16 -0
  43. package/src/runner/schemas.mjs +135 -0
  44. package/src/runner/worker.mjs +62 -0
  45. package/src/write-meta.mjs +135 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
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
+
7
+ ## [0.1.0] - 2026-08-04
8
+
9
+ ### Added
10
+
11
+ - Three dispatcher agents for reconnaissance (`codex-scout`), scoped implementation (`codex-build`), and independent review (`codex-review`), each with a structured response contract.
12
+ - A delegating runner that separates the launcher from a detached worker, preserves run artifacts after a dropped call, and supports configurable artifact roots.
13
+ - A zero-dependency `codex-bridge` CLI with `install`, `uninstall`, `update`, and `doctor`, including user/project scopes, dry runs, force handling, idempotent installs, hook merging with backups, and manifest-based ownership tracking.
14
+ - A reply guard hook that blocks dispatcher replies that do not match the recorded run state or required response shape.
15
+ - Run-chain and busy-tree lock protections: repeated work is linked across runs, an unfinished chain requires explicit `--continue`, and concurrent writing runs for one repository are refused before Codex starts.
16
+ - Scope and coverage validation: implementation changes must stay within the declared scope, report/worktree mismatches fail the run, and reconnaissance reports must account for all requested subquestions.
17
+ - Package-managed Codex execution rules that forbid history-, branch-, index-, and worktree-destructive operations, writes inside `.git`, and other unsafe repository mutations while leaving the operator's existing rules untouched.
18
+ - A 400-line source-file size gate with explicit rationale required for exclusions.
19
+ - Asynchronous run start: the launcher reports the run folder and returns instead of holding the caller for the length of the run, and repeating the same order attaches to the run already in flight rather than paying for a second one.
20
+ - Per-mode time budgets that end a run and its whole process tree at a deadline, plus a `stop` subcommand for closing a run that wedged anyway.
21
+ - Explicit reconnaissance subquestions through a mandatory, repeatable `--question` flag, so coverage is counted against what was ordered rather than against prose the dispatcher rewrote.
22
+ - An `answerLanguage` configuration key that fixes the language of a run's answer instead of leaving it to the model.
23
+
24
+ ### Changed
25
+
26
+ - Run identity now includes an orchestrator-issued mandatory `--order-id`, stored with run state and used alongside the task identity to detect repeats even when a run is renamed.
27
+ - A repeat of an order is now continued at most once, and only behind a run that already carries a verdict, so a dispatcher can no longer restart itself into a chain of paid runs.
28
+ - A run left behind by a dropped call is now closed with a `FAIL` verdict that names what it left in the worktree, instead of staying open with no account of its work.
29
+ - Runs now snapshot both branch and `HEAD` before and after execution; branch changes, detached `HEAD`, and commit drift are treated as failures, with preflight refusal for a subsequent run when an abandoned run left repository state out of sync.
30
+ - English-language prompts and runner/command output are shipped for the three dispatchers and their supporting commands.
31
+ - Installation updates track installed file contents so outdated package files can be refreshed without overwriting hand edits unless `--force` is requested.
32
+
33
+ ### Fixed
34
+
35
+ - Prevented environment and service writes from being misreported as delegated work while keeping them visible in run results.
36
+ - Prevented different repositories with the same directory name from sharing run history.
37
+ - Prevented malformed state files, including files with a byte-order mark, from silently bypassing abandoned-run detection, busy-tree locking, and chain lookup.
38
+ - Fixed diagnostics to report the run folder actually used by the runner and to continue reporting remaining checks when a project marker is unreadable.
39
+ - Fixed reported file paths to keep the spelling on disk instead of the case-folded form used for matching, so a reply names a path that exists on case-sensitive filesystems.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lyupro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # codex-bridge
2
+
3
+ Delegate implementation, reconnaissance and code review from Claude Code to the
4
+ [Codex CLI](https://developers.openai.com/codex/cli), so the work runs on a Codex
5
+ subscription instead of the Claude one.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx @lyupro/codex-bridge install # into ~/.claude
11
+ node bin/codex-bridge.mjs install # from a clone, same thing
12
+ node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
13
+ node bin/codex-bridge.mjs update # bring an installation up to the current version
14
+ node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
15
+ node bin/codex-bridge.mjs uninstall # removes exactly what was installed
16
+ node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
17
+ ```
18
+
19
+ `install` copies the package into the host's `agents/codex/`, its slash commands into
20
+ `commands/codex/`, and registers the stop hook in `settings.json` — **merged**, so hooks that
21
+ are already there survive, and the file is backed up before every write. Run it twice and the
22
+ second run does nothing. `--dry-run` prints the plan and touches nothing.
23
+
24
+ `update` moves an existing installation to the current version. It knows the sha256 of every file
25
+ as it was installed, so it can tell a file you edited on the host from one left over by an older
26
+ version: outdated files are refreshed silently, files you changed by hand stop the run and are
27
+ named, and `--force` is what overwrites them. A file the package no longer ships is removed only
28
+ if you never touched it.
29
+
30
+ `uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
31
+ stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
32
+
33
+ `stop` takes a run folder — a full path or a bare name from the current project's runs directory —
34
+ kills that run's whole process tree and closes the folder the way an abandoned run is closed: a
35
+ `FAIL` verdict naming what the run left in the worktree. A run that already has a verdict is left
36
+ alone. Runs end on their own time budget now, so this is for the case where something wedged
37
+ anyway.
38
+
39
+ Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked with a
40
+ `.project.json` holding that repository's path. Two checkouts that share a directory name — two
41
+ different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
42
+ which folder the current repository writes to.
43
+
44
+ ## What it gives you
45
+
46
+ - **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
47
+ (write access limited to a declared scope), and an independent review by a second model.
48
+ - **A runner that survives a dropped call.** The launcher hands the run to a detached
49
+ worker, so a killed dispatcher no longer leaves the work unexplainable: the run folder is
50
+ closed with artifacts either way.
51
+ - **A verdict that checks the report against the worktree.** The agent's own account of what
52
+ it changed is compared with what git says actually changed, and a mismatch is a failed run
53
+ rather than a nicer-sounding summary.
54
+ - **A stop hook** that refuses a dispatcher reply which does not match the recorded state of
55
+ the run.
56
+
57
+ ## Requirements
58
+
59
+ - Node.js >= 24 (the file-size gate uses `fs.glob`, the test runner uses directory globs)
60
+ - The `codex` CLI, authenticated (`codex --version`, `codex login`)
61
+ - Claude Code, for the agents and the hook to be registered in
62
+
63
+ No runtime dependencies: everything here is plain `.mjs` on the Node standard library, and
64
+ that is deliberate — installing from a clone stays one command, with no build step.
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ git clone https://github.com/lyupro/codex-bridge.git
70
+ cd codex-bridge
71
+ git config core.hooksPath .githooks # pre-commit file-size gate, 400 lines per source file
72
+ npm test # node --test, no dependencies to install
73
+ ```
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { diagnose, renderDoctor } from '../cli/doctor.mjs';
6
+ import { resolveHost } from '../cli/hosts.mjs';
7
+ import { install } from '../cli/install.mjs';
8
+ import { packageInfo } from '../cli/manifest.mjs';
9
+ import { stop } from '../cli/stop.mjs';
10
+ import { uninstall } from '../cli/uninstall.mjs';
11
+ import { update } from '../cli/update.mjs';
12
+
13
+ export const HELP = `codex-bridge — Claude Code dispatchers for Codex
14
+
15
+ Usage:
16
+ codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
17
+ codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
18
+ codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
19
+ codex-bridge doctor [--scope user|project] [--host <path>]
20
+ codex-bridge stop <run>
21
+ codex-bridge --help
22
+ codex-bridge --version
23
+
24
+ Commands:
25
+ install Install codex-bridge into the selected Claude Code host
26
+ update Update a recorded codex-bridge installation
27
+ uninstall Remove installed files while preserving run artifacts
28
+ doctor Diagnose the selected Claude Code host
29
+ stop Stop a running Codex run and record FAIL`;
30
+
31
+ function commandOptions(command, argv) {
32
+ const options = {};
33
+ const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
34
+ : command === 'uninstall' ? new Set(['--dry-run']) : new Set();
35
+ const flagNames = new Map([
36
+ ['--dry-run', 'dryRun'],
37
+ ['--force', 'force'],
38
+ ]);
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const arg = argv[index];
41
+ if (booleanFlags.has(arg)) {
42
+ options[flagNames.get(arg)] = true;
43
+ continue;
44
+ }
45
+ if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
46
+ const value = argv[index + 1];
47
+ if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
48
+ options[arg === '--scope' ? 'scope' : 'host'] = value;
49
+ index += 1;
50
+ }
51
+ return options;
52
+ }
53
+
54
+ export async function main(argv, io = console) {
55
+ const [command, ...rest] = argv;
56
+ if (!command || command === '--help' || command === '-h') {
57
+ io.log(HELP);
58
+ return 0;
59
+ }
60
+ if (command === '--version' || command === '-v') {
61
+ io.log((await packageInfo()).version);
62
+ return 0;
63
+ }
64
+ if (command === 'doctor') {
65
+ const host = resolveHost(commandOptions(command, rest));
66
+ const result = await diagnose({ host });
67
+ io.log(renderDoctor(result));
68
+ return result.exitCode;
69
+ }
70
+ if (command === 'stop') {
71
+ if (rest.length !== 1) {
72
+ io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
73
+ return 2;
74
+ }
75
+ const result = await stop({ run: rest[0] });
76
+ io.log(result.output);
77
+ return result.exitCode;
78
+ }
79
+ if (command === 'install' || command === 'update' || command === 'uninstall') {
80
+ const options = commandOptions(command, rest);
81
+ const host = resolveHost(options);
82
+ const handlers = {
83
+ install: () => install({ host, dryRun: options.dryRun, force: options.force }),
84
+ update: () => update({ host, dryRun: options.dryRun, force: options.force }),
85
+ uninstall: () => uninstall({ host, dryRun: options.dryRun }),
86
+ };
87
+ const result = await handlers[command]();
88
+ io.log(result.output);
89
+ return result.exitCode;
90
+ }
91
+ io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
92
+ return 2;
93
+ }
94
+
95
+ const invokedDirectly =
96
+ process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
97
+
98
+ if (invokedDirectly) {
99
+ main(process.argv.slice(2))
100
+ .then((exitCode) => { process.exitCode = exitCode; })
101
+ .catch((err) => {
102
+ console.error(`codex-bridge: ${err.message}`);
103
+ process.exitCode = 2;
104
+ });
105
+ }
package/cli/copy.mjs ADDED
@@ -0,0 +1,38 @@
1
+ /** Copies planned install files atomically and compares their rendered contents. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { replacePlaceholders } from './manifest.mjs';
6
+
7
+ export async function plannedContent(item, agentsDir) {
8
+ const source = await fs.readFile(item.source);
9
+ if (item.processing === 'copy') return source;
10
+ if (item.processing === 'placeholders') {
11
+ return Buffer.from(replacePlaceholders(source.toString('utf8'), agentsDir));
12
+ }
13
+ throw new Error(`unknown install processing "${item.processing}"`);
14
+ }
15
+
16
+ export async function targetMatches(item, agentsDir) {
17
+ const expected = await plannedContent(item, agentsDir);
18
+ try {
19
+ const actual = await fs.readFile(item.target);
20
+ return actual.equals(expected);
21
+ } catch (err) {
22
+ if (err.code === 'ENOENT' || err.code === 'EISDIR') return false;
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ export async function copyPlannedFile(item, agentsDir) {
28
+ const content = await plannedContent(item, agentsDir);
29
+ await fs.mkdir(path.dirname(item.target), { recursive: true });
30
+ const temporary = path.join(path.dirname(item.target), `.${path.basename(item.target)}.${randomUUID()}.tmp`);
31
+ try {
32
+ await fs.writeFile(temporary, content, { flag: 'wx' });
33
+ await fs.rename(temporary, item.target);
34
+ } catch (err) {
35
+ await fs.rm(temporary, { force: true }).catch(() => {});
36
+ throw err;
37
+ }
38
+ }
package/cli/doctor.mjs ADDED
@@ -0,0 +1,170 @@
1
+ /** Diagnoses a Claude Code host without modifying it. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileFingerprint, readInstallRecord, packageInfo } from './manifest.mjs';
6
+ import { runsRoot } from '../src/runner/runs-root.mjs';
7
+ import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
8
+
9
+ async function exists(target) {
10
+ try {
11
+ await fs.access(target);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ async function isFile(target) {
19
+ try {
20
+ return (await fs.stat(target)).isFile();
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function check(key, status, value) {
27
+ return { key, status, value };
28
+ }
29
+
30
+ /**
31
+ * The runner asks git for the repository root before it picks a runs folder, so doctor has to
32
+ * ask the same question: run from `src/runner`, a plain cwd would name the folder `runner` and
33
+ * report a location no run will ever use.
34
+ */
35
+ function repoRoot(cwd) {
36
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
37
+ return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
38
+ }
39
+
40
+ /**
41
+ * A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
42
+ * Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
43
+ */
44
+ function projectRunsCheck() {
45
+ let resolved;
46
+ try {
47
+ resolved = resolveProjectRunsDir(runsRoot(), repoRoot(process.cwd()), { create: false });
48
+ } catch (err) {
49
+ return check('projectRuns', 'fail', err.message);
50
+ }
51
+ const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
52
+ return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
53
+ }
54
+
55
+ function hookCommands(value) {
56
+ if (Array.isArray(value)) return value.flatMap(hookCommands);
57
+ if (value && typeof value === 'object') {
58
+ const own = value.type === 'command' && typeof value.command === 'string' ? [value.command] : [];
59
+ return [...own, ...Object.values(value).filter((child) => child !== value.command).flatMap(hookCommands)];
60
+ }
61
+ return [];
62
+ }
63
+
64
+ function commandReferences(command, expected) {
65
+ const normalizedExpected = path.resolve(expected).split(path.sep).join('/');
66
+ const tokens = [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)]
67
+ .map((match) => match[1] ?? match[2] ?? match[3]);
68
+ return tokens.some((token) => path.resolve(token).split(path.sep).join('/') === normalizedExpected);
69
+ }
70
+
71
+ export function probeCodex() {
72
+ const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
73
+ const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
74
+ const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
75
+ if (result.error || result.status !== 0) {
76
+ return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
77
+ }
78
+ return { available: true, value: (result.stdout || result.stderr).trim() };
79
+ }
80
+
81
+ async function hookCheck(host, record) {
82
+ if (!record) return check('hook', 'warn', 'cannot check before installation');
83
+ let settings;
84
+ try {
85
+ settings = JSON.parse(await fs.readFile(host.settingsPath, 'utf8'));
86
+ } catch (err) {
87
+ const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
88
+ return check('hook', 'warn', value);
89
+ }
90
+ const registration = settings?.hooks?.SubagentStop;
91
+ if (!registration) return check('hook', 'warn', 'SubagentStop is not registered');
92
+ const expected = path.resolve(host.root, record.hook.path);
93
+ const commands = hookCommands(registration);
94
+ const pointsToGuard = commands.some((command) => commandReferences(command, expected));
95
+ return pointsToGuard
96
+ ? check('hook', 'ok', `SubagentStop -> ${expected}`)
97
+ : check('hook', 'warn', 'SubagentStop does not point to the installed reply-guard.mjs');
98
+ }
99
+
100
+ async function rulesCheck(record) {
101
+ if (!record) return check('rules', 'warn', 'cannot check before installation');
102
+ if (!record.rules) {
103
+ return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
104
+ }
105
+ const fingerprint = await fileFingerprint(record.rules.path);
106
+ if (!fingerprint) return check('rules', 'fail', record.rules.path);
107
+ return fingerprint === record.rules.fingerprint
108
+ ? check('rules', 'ok', `${record.rules.path} (matches record)`)
109
+ : check('rules', 'warn', `${record.rules.path} (modified after installation)`);
110
+ }
111
+
112
+ export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
113
+ const checks = [];
114
+ const hostExists = await exists(host.root);
115
+ checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
116
+
117
+ const ownPackage = currentPackage || await packageInfo();
118
+ let record = null;
119
+ let recordBroken = false;
120
+ try {
121
+ record = await readInstallRecord(host);
122
+ if (!record) checks.push(check('installation', 'fail', 'not installed'));
123
+ else {
124
+ const matches = record.name === ownPackage.name && record.version === ownPackage.version;
125
+ checks.push(check(
126
+ 'installation',
127
+ matches ? 'ok' : 'warn',
128
+ `${record.name}@${record.version} (${matches ? 'matches package' : `package is ${ownPackage.name}@${ownPackage.version}`})`,
129
+ ));
130
+ }
131
+ } catch (err) {
132
+ recordBroken = true;
133
+ checks.push(check('installation', 'fail', `broken record: ${err.message}`));
134
+ }
135
+
136
+ const missingFiles = [];
137
+ if (record) {
138
+ for (const relative of record.files) {
139
+ if (!(await isFile(path.join(host.root, relative)))) missingFiles.push(relative);
140
+ }
141
+ }
142
+ checks.push(check(
143
+ 'files',
144
+ !record ? 'warn' : missingFiles.length ? 'fail' : 'ok',
145
+ !record ? 'not checked' : missingFiles.length ? `missing: ${missingFiles.join(', ')}` : `${record.files.length} installed file(s) present`,
146
+ ));
147
+ const rules = await rulesCheck(record);
148
+ checks.push(rules);
149
+ checks.push(await hookCheck(host, record));
150
+
151
+ const codex = codexProbe();
152
+ checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
153
+ const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
154
+ checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
155
+ checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
156
+ const projectRuns = projectRunsCheck();
157
+ checks.push(projectRuns);
158
+
159
+ return {
160
+ exitCode: !record || recordBroken || missingFiles.length || rules.status === 'fail'
161
+ || projectRuns.status === 'fail' ? 1 : 0,
162
+ checks,
163
+ record,
164
+ missingFiles,
165
+ };
166
+ }
167
+
168
+ export function renderDoctor(result) {
169
+ return result.checks.map(({ key, status, value }) => `[${status}] ${key}: ${value}`).join('\n');
170
+ }
package/cli/hosts.mjs ADDED
@@ -0,0 +1,50 @@
1
+ /** Resolves Claude Code host paths without touching the filesystem. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ function repositoryRoot(start) {
7
+ let current = path.resolve(start);
8
+ while (true) {
9
+ if (fs.existsSync(path.join(current, '.git'))) return current;
10
+ const parent = path.dirname(current);
11
+ if (parent === current) return path.resolve(start);
12
+ current = parent;
13
+ }
14
+ }
15
+
16
+ export function resolveHost({
17
+ scope = 'user',
18
+ host,
19
+ cwd = process.cwd(),
20
+ homedir = os.homedir(),
21
+ codexHome,
22
+ } = {}) {
23
+ const resolvedCodexHome = codexHome || process.env.CODEX_HOME || path.join(homedir, '.codex');
24
+ const codexRulesDir = path.join(resolvedCodexHome, 'rules');
25
+ if (host) {
26
+ const root = path.resolve(host);
27
+ return {
28
+ root,
29
+ agentsDir: path.join(root, 'agents', 'codex'),
30
+ commandsDir: path.join(root, 'commands', 'codex'),
31
+ codexRulesDir,
32
+ settingsPath: path.join(root, 'settings.json'),
33
+ scope: 'host',
34
+ };
35
+ }
36
+ if (scope !== 'user' && scope !== 'project') {
37
+ throw new Error(`unknown scope "${scope}": expected user or project`);
38
+ }
39
+ const root = path.resolve(
40
+ scope === 'project' ? path.join(repositoryRoot(cwd), '.claude') : path.join(homedir, '.claude'),
41
+ );
42
+ return {
43
+ root,
44
+ agentsDir: path.join(root, 'agents', 'codex'),
45
+ commandsDir: path.join(root, 'commands', 'codex'),
46
+ codexRulesDir,
47
+ settingsPath: path.join(root, 'settings.json'),
48
+ scope,
49
+ };
50
+ }
@@ -0,0 +1,109 @@
1
+ /** Installs codex-bridge files and its host hook with conflict-safe idempotency. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ buildInstallPlan,
6
+ fileFingerprint,
7
+ packageInfo,
8
+ readInstallRecord,
9
+ recordMatchesPackage,
10
+ rulesPlan,
11
+ seedPlan,
12
+ writeInstallRecord,
13
+ } from './manifest.mjs';
14
+ import { copyPlannedFile, targetMatches } from './copy.mjs';
15
+ import { inspectHook, mergeHook } from './settings-merge.mjs';
16
+
17
+ async function targetExists(target) {
18
+ try {
19
+ await fs.access(target);
20
+ return true;
21
+ } catch (err) {
22
+ if (err.code === 'ENOENT') return false;
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
28
+ const plan = await buildInstallPlan(host, packageRoot);
29
+ const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
30
+ const currentPackage = await packageInfo(packageRoot);
31
+ const record = await readInstallRecord(host);
32
+ const inspectedHook = await inspectHook(
33
+ host.settingsPath,
34
+ path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
35
+ );
36
+ const states = await Promise.all(plan.map(async (item) => ({
37
+ item,
38
+ exists: await targetExists(item.target),
39
+ matches: await targetMatches(item, host.agentsDir),
40
+ fingerprint: await fileFingerprint(item.target),
41
+ })));
42
+ const ruleState = {
43
+ item: rule,
44
+ exists: await targetExists(rule.target),
45
+ matches: await targetMatches(rule, host.agentsDir),
46
+ fingerprint: await fileFingerprint(rule.target),
47
+ };
48
+ const conflicts = [
49
+ ...states.filter((state) => state.exists && !state.matches
50
+ && (!record || (record.fingerprints
51
+ && record.fingerprints[state.item.relativeToHost] !== state.fingerprint))),
52
+ ...(ruleState.exists && !ruleState.matches
53
+ && (!record?.rules || record.rules.fingerprint !== ruleState.fingerprint) ? [ruleState] : []),
54
+ ];
55
+ if (conflicts.length && !force) {
56
+ const files = conflicts.map(({ item }) => ` ${item.relativeToHost || item.name}`).join('\n');
57
+ return {
58
+ exitCode: 1,
59
+ output: `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`,
60
+ };
61
+ }
62
+
63
+ const changedFiles = states.filter((state) => !state.matches);
64
+ const changedRule = !ruleState.matches;
65
+ const sameRecord = recordMatchesPackage(record, plan, currentPackage,
66
+ new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
67
+ { path: rule.target, fingerprint: ruleState.fingerprint });
68
+ if (!changedFiles.length && !changedRule && inspectedHook.present && sameRecord) {
69
+ return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
70
+ }
71
+
72
+ if (dryRun) {
73
+ const lines = changedFiles.map(({ item, exists }) =>
74
+ `${exists ? 'Would overwrite' : 'Would create'} ${item.relativeToHost}`);
75
+ if (changedRule) {
76
+ lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
77
+ }
78
+ lines.push(inspectedHook.present ? 'Hook is already registered.' : 'Would register SubagentStop hook.');
79
+ lines.push('Would write installation record.');
80
+ return { exitCode: 0, output: lines.join('\n') };
81
+ }
82
+
83
+ for (const { item } of changedFiles) await copyPlannedFile(item, host.agentsDir);
84
+ if (changedRule) await copyPlannedFile(rule, host.agentsDir);
85
+ // Seeded files are written once and then belong to the operator: an existing one is left
86
+ // exactly as it is, including under --force, because --force is about our files, not theirs.
87
+ for (const seed of seedPlan(host, packageRoot)) {
88
+ if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
89
+ }
90
+ const hookResult = await mergeHook(
91
+ host.settingsPath,
92
+ path.join(host.agentsDir, 'hooks', 'reply-guard.mjs'),
93
+ inspectedHook,
94
+ );
95
+ const hookRelative = plan.find((item) => path.basename(item.target) === 'reply-guard.mjs')?.relativeToHost;
96
+ if (!hookRelative) throw new Error('install plan does not contain hooks/reply-guard.mjs');
97
+ const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
98
+ [item.relativeToHost, await fileFingerprint(item.target)])));
99
+ await writeInstallRecord(host, {
100
+ ...currentPackage,
101
+ installedAt: new Date().toISOString(),
102
+ mode: 'copy',
103
+ files: plan.map((item) => item.relativeToHost),
104
+ fingerprints,
105
+ rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
106
+ hook: { event: 'SubagentStop', path: hookRelative, createdGroup: hookResult.createdGroup || record?.hook?.createdGroup || false },
107
+ });
108
+ return { exitCode: 0, output: `Installed ${plan.length + 1} files and registered the SubagentStop hook.` };
109
+ }