@lyupro/codex-bridge 0.5.3 → 0.5.5
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.
- package/CHANGELOG.md +98 -0
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +150 -399
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +81 -11
- package/cli/permissions.mjs +7 -1
- package/cli/probe-contract.mjs +180 -0
- package/package.json +49 -49
- package/scripts/run-tests.mjs +4 -1
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/hooks/shell-write-intent.mjs +44 -15
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +268 -398
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Reports the state of run storage on this machine: location, live count, retention. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
5
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
6
|
+
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
7
|
+
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
8
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
|
|
9
|
+
import { check } from './doctor-format.mjs';
|
|
10
|
+
|
|
11
|
+
export function retentionCheck(host) {
|
|
12
|
+
try {
|
|
13
|
+
const notice = retentionNotice(readRunConfig(host.brandConfigPath));
|
|
14
|
+
return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return check('retention', 'fail', `invalid configuration: ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The runner asks git for the repository root before it picks a runs folder, so doctor has to
|
|
22
|
+
* ask the same question: run from `src/home/lib/runner`, a plain cwd would name the folder `runner` and
|
|
23
|
+
* report a location no run will ever use.
|
|
24
|
+
*/
|
|
25
|
+
function repoRoot(cwd) {
|
|
26
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
27
|
+
return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
|
|
32
|
+
* Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
|
|
33
|
+
*/
|
|
34
|
+
export function projectRunsCheck() {
|
|
35
|
+
let resolved;
|
|
36
|
+
try {
|
|
37
|
+
resolved = resolveProjectRunsDir(runsRoot(), repoRoot(process.cwd()), { create: false });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return check('projectRuns', 'fail', err.message);
|
|
40
|
+
}
|
|
41
|
+
const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
|
|
42
|
+
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function liveRunsCheck() {
|
|
46
|
+
let count;
|
|
47
|
+
try {
|
|
48
|
+
count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
if (!count) return check('liveRuns', 'ok', '0 runs working right now');
|
|
53
|
+
const noun = count === 1 ? 'run' : 'runs';
|
|
54
|
+
return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
|
|
55
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,399 +1,150 @@
|
|
|
1
|
-
/** Diagnoses a Claude Code host without modifying it. */
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return check('projectRuns', 'fail', err.message);
|
|
152
|
-
}
|
|
153
|
-
const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
|
|
154
|
-
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function liveRunsCheck() {
|
|
158
|
-
let count;
|
|
159
|
-
try {
|
|
160
|
-
count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
|
|
161
|
-
} catch (err) {
|
|
162
|
-
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
163
|
-
}
|
|
164
|
-
if (!count) return check('liveRuns', 'ok', '0 runs working right now');
|
|
165
|
-
const noun = count === 1 ? 'run' : 'runs';
|
|
166
|
-
return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function probeCodex() {
|
|
170
|
-
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
|
|
171
|
-
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
|
|
172
|
-
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
173
|
-
if (result.error || result.status !== 0) {
|
|
174
|
-
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
175
|
-
}
|
|
176
|
-
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function probeCodexBridge() {
|
|
180
|
-
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex-bridge';
|
|
181
|
-
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex-bridge --version'] : ['--version'];
|
|
182
|
-
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
183
|
-
if (result.error || result.status !== 0) {
|
|
184
|
-
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
185
|
-
}
|
|
186
|
-
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function hookVersion(command, record, bridgeResult) {
|
|
190
|
-
if (command.startsWith('codex-bridge hook ')) {
|
|
191
|
-
return bridgeResult.available
|
|
192
|
-
? `global command ${bridgeResult.value}`
|
|
193
|
-
: `global command unavailable (${bridgeResult.value})`;
|
|
194
|
-
}
|
|
195
|
-
return `installed copy ${record.name}@${record.version}`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function probedVersion(result) {
|
|
199
|
-
return result.available ? result.value.match(/(\d+\.\d+\.\d+(?:[-+][^\s]+)?)\s*$/)?.[1] : null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function startHook(target) {
|
|
203
|
-
return new Promise((resolve) => {
|
|
204
|
-
const child = spawn(process.execPath, [target], {
|
|
205
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
206
|
-
windowsHide: true,
|
|
207
|
-
});
|
|
208
|
-
let stderr = '';
|
|
209
|
-
child.stderr.setEncoding('utf8');
|
|
210
|
-
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
211
|
-
child.on('error', (error) => resolve({ status: null, stderr: error.message }));
|
|
212
|
-
child.on('close', (status) => resolve({ status, stderr }));
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function startFailure(definition, result) {
|
|
217
|
-
if (result.status === 0) return null;
|
|
218
|
-
const lines = result.stderr.trim().split(/\r?\n/);
|
|
219
|
-
const resolver = lines.find((line) => /^Error \[ERR_MODULE_NOT_FOUND\]|Cannot find module/.test(line));
|
|
220
|
-
return `${definition.file} did not start: ${resolver || lines.find(Boolean) || `exit code ${result.status}`}`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function hookChecks(host, record, bridgeProbe, ownPackage) {
|
|
224
|
-
let bridgeResult;
|
|
225
|
-
const bridge = () => {
|
|
226
|
-
bridgeResult ??= bridgeProbe();
|
|
227
|
-
return bridgeResult;
|
|
228
|
-
};
|
|
229
|
-
const starts = record ? await Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
230
|
-
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
231
|
-
&& path.basename(hook.path) === definition.file);
|
|
232
|
-
return recorded ? startHook(recordTarget(host, recorded)) : null;
|
|
233
|
-
})) : [];
|
|
234
|
-
return Promise.all(HOOK_DEFINITIONS.map(async (definition, index) => {
|
|
235
|
-
const key = `hook:${definition.event}`;
|
|
236
|
-
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
237
|
-
// The file, not just the event: PreToolUse carries two hooks of this package, and matching
|
|
238
|
-
// by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
|
|
239
|
-
// hook line is the only place an operator can see WHICH file a matcher was registered for,
|
|
240
|
-
// so a wrong name here is worse than no line at all.
|
|
241
|
-
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
242
|
-
&& path.basename(hook.path) === definition.file);
|
|
243
|
-
if (!recorded) {
|
|
244
|
-
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
245
|
-
}
|
|
246
|
-
const expected = recordTarget(host, recorded);
|
|
247
|
-
const fullCommand = commandFor(expected);
|
|
248
|
-
const shortCommand = `codex-bridge hook ${definition.name}`;
|
|
249
|
-
try {
|
|
250
|
-
const state = await inspectHook(host.settingsPath, {
|
|
251
|
-
event: definition.event,
|
|
252
|
-
matcher: definition.matcher,
|
|
253
|
-
command: recorded.command || fullCommand,
|
|
254
|
-
alternateCommands: [fullCommand, shortCommand],
|
|
255
|
-
});
|
|
256
|
-
if (state.present) {
|
|
257
|
-
const recordedMatcher = state.matchedMatcher;
|
|
258
|
-
const command = state.matchedCommand || recorded.command || fullCommand;
|
|
259
|
-
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
260
|
-
const reason = form === 'short'
|
|
261
|
-
? 'PATH command uses the globally installed package'
|
|
262
|
-
: 'full path uses the copy placed by the last install';
|
|
263
|
-
const problems = [startFailure(definition, starts[index])].filter(Boolean);
|
|
264
|
-
// The matcher printed is the one the host recorded, never the one this package declares.
|
|
265
|
-
// Until 2026-08-23 the line stated the registry's own value, so a host stuck on the
|
|
266
|
-
// pre-shell matcher read as [ok] while the worktree lock never saw a Bash command — the
|
|
267
|
-
// same shape as the 2026-08-10 defect above, where doctor counted files instead of
|
|
268
|
-
// reading them. A check that compares the package to itself cannot fail.
|
|
269
|
-
if (recordedMatcher !== definition.matcher) problems.push(`recorded matcher ${recordedMatcher} differs from expected ${definition.matcher}; run codex-bridge update --force`);
|
|
270
|
-
const commandVersion = form === 'short' ? bridge() : null;
|
|
271
|
-
const globalVersion = probedVersion(commandVersion || { available: false });
|
|
272
|
-
if (globalVersion && globalVersion !== ownPackage.version) {
|
|
273
|
-
problems.push(`global PATH package version ${globalVersion} differs from ${packageSource().kind} version ${ownPackage.version}`);
|
|
274
|
-
}
|
|
275
|
-
const health = problems.length ? `; ${problems.join('; ')}` : '';
|
|
276
|
-
return check(key, problems.length ? 'warn' : 'ok', `${definition.event} matcher ${recordedMatcher} -> ${expected} (${form} command; ${reason}; ${hookVersion(command, record, commandVersion)}${health})`);
|
|
277
|
-
}
|
|
278
|
-
const command = recorded.command || fullCommand;
|
|
279
|
-
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
280
|
-
const failure = startFailure(definition, starts[index]);
|
|
281
|
-
const commandVersion = form === 'short' ? bridge() : null;
|
|
282
|
-
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command; ${hookVersion(command, record, commandVersion)}${failure ? `; ${failure}` : ''})`);
|
|
283
|
-
} catch (err) {
|
|
284
|
-
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
285
|
-
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
286
|
-
}
|
|
287
|
-
}));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function rulesCheck(host, record) {
|
|
291
|
-
if (!record) return check('rules', 'warn', 'cannot check before installation');
|
|
292
|
-
if (!record.rules) {
|
|
293
|
-
return check('rules', 'warn', 'rules were not installed by this installation; install or update will add them');
|
|
294
|
-
}
|
|
295
|
-
let registry;
|
|
296
|
-
try {
|
|
297
|
-
registry = await readRulesRegistry(host);
|
|
298
|
-
} catch (err) {
|
|
299
|
-
// Keep every diagnostic visible: package removal on a broken registry left the host without its watchdog.
|
|
300
|
-
return check('rules', 'fail', err.message);
|
|
301
|
-
}
|
|
302
|
-
const ownerNote = registry?.owners.length > 1 ? `; ${registry.owners.length} owners` : '';
|
|
303
|
-
const fingerprint = await fileFingerprint(record.rules.path);
|
|
304
|
-
if (!fingerprint) return check('rules', 'fail', `${record.rules.path}${ownerNote ? ` (${registry.owners.length} owners)` : ''}`);
|
|
305
|
-
return fingerprint === record.rules.fingerprint
|
|
306
|
-
? check('rules', 'ok', `${record.rules.path} (matches record${ownerNote})`)
|
|
307
|
-
: check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async function permissionsCheck(host) {
|
|
311
|
-
try {
|
|
312
|
-
const status = await inspectPermissions(host.settingsPath);
|
|
313
|
-
// Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
|
|
314
|
-
// doctor does not turn a healthy installation red merely because hardening was not requested.
|
|
315
|
-
// The ask count is part of the line because a full set shadowed by `ask` reads as working and
|
|
316
|
-
// is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
|
|
317
|
-
const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
|
|
318
|
-
return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
|
|
319
|
-
`${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
|
|
320
|
-
} catch (err) {
|
|
321
|
-
return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = probeCodexBridge, currentPackage } = {}) {
|
|
326
|
-
const checks = [sourceCheck()];
|
|
327
|
-
const hostExists = await exists(host.root);
|
|
328
|
-
checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
|
|
329
|
-
|
|
330
|
-
const ownPackage = currentPackage || await packageInfo();
|
|
331
|
-
let record = null;
|
|
332
|
-
let recordBroken = false;
|
|
333
|
-
try {
|
|
334
|
-
record = await readInstallRecord(host);
|
|
335
|
-
if (!record) checks.push(check('installation', 'fail', 'not installed'));
|
|
336
|
-
else {
|
|
337
|
-
const matches = record.name === ownPackage.name && record.version === ownPackage.version;
|
|
338
|
-
checks.push(check(
|
|
339
|
-
'installation',
|
|
340
|
-
matches ? 'ok' : 'warn',
|
|
341
|
-
`${record.name}@${record.version} (${matches ? 'matches package' : `package is ${ownPackage.name}@${ownPackage.version}`})`,
|
|
342
|
-
));
|
|
343
|
-
}
|
|
344
|
-
} catch (err) {
|
|
345
|
-
recordBroken = true;
|
|
346
|
-
checks.push(check('installation', 'fail', `broken record: ${err.message}`));
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const missingFiles = [];
|
|
350
|
-
if (record) {
|
|
351
|
-
for (const file of record.files) {
|
|
352
|
-
if (!(await isFile(recordTarget(host, file)))) missingFiles.push(`${file.root}/${file.path}`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
checks.push(check(
|
|
356
|
-
'files',
|
|
357
|
-
!record ? 'warn' : missingFiles.length ? 'fail' : 'ok',
|
|
358
|
-
!record ? 'not checked' : missingFiles.length ? `missing: ${missingFiles.join(', ')}` : `${record.files.length} installed file(s) present`,
|
|
359
|
-
));
|
|
360
|
-
const agents = await agentsCheck(host, record);
|
|
361
|
-
checks.push(agents);
|
|
362
|
-
const rules = await rulesCheck(host, record);
|
|
363
|
-
checks.push(rules);
|
|
364
|
-
checks.push(await permissionsCheck(host));
|
|
365
|
-
const bridge = bridgeProbe();
|
|
366
|
-
checks.push(bridgeCommandCheck(bridge));
|
|
367
|
-
checks.push(...await hookChecks(host, record, () => bridge, ownPackage));
|
|
368
|
-
const retention = retentionCheck(host);
|
|
369
|
-
checks.push(retention);
|
|
370
|
-
const conventions = await conventionsCheck(host);
|
|
371
|
-
checks.push(conventions);
|
|
372
|
-
|
|
373
|
-
const codex = codexProbe();
|
|
374
|
-
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
|
375
|
-
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
|
|
376
|
-
checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
|
|
377
|
-
checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
|
|
378
|
-
const projectRuns = projectRunsCheck();
|
|
379
|
-
checks.push(liveRunsCheck());
|
|
380
|
-
checks.push(projectRuns);
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
384
|
-
|| retention.status === 'fail' || conventions.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
|
|
385
|
-
checks,
|
|
386
|
-
record,
|
|
387
|
-
missingFiles,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
export function renderDoctor(result) {
|
|
392
|
-
return result.checks.map(({ key, status, value }) => {
|
|
393
|
-
if ((key === 'agents' || key === 'command') && status !== 'ok') return value;
|
|
394
|
-
const rendered = `[${status}] ${key}: ${value}`;
|
|
395
|
-
return ['retention', 'conventions', 'permissions', 'liveRuns'].includes(key) && status === 'warn'
|
|
396
|
-
? `${WARNING}${rendered}${RESET}`
|
|
397
|
-
: rendered;
|
|
398
|
-
}).join('\n');
|
|
399
|
-
}
|
|
1
|
+
/** Diagnoses a Claude Code host without modifying it. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { readInstallRecord, packageInfo } from './manifest.mjs';
|
|
6
|
+
import { recordTarget } from './install-record.mjs';
|
|
7
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
8
|
+
import { check, renderDoctor } from './doctor-format.mjs';
|
|
9
|
+
import {
|
|
10
|
+
agentsCheck,
|
|
11
|
+
conventionsCheck,
|
|
12
|
+
exists,
|
|
13
|
+
isFile,
|
|
14
|
+
permissionsCheck,
|
|
15
|
+
rulesCheck,
|
|
16
|
+
} from './doctor-installation.mjs';
|
|
17
|
+
import { hookChecks } from './doctor-hooks.mjs';
|
|
18
|
+
import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
|
|
19
|
+
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
20
|
+
|
|
21
|
+
export { renderDoctor };
|
|
22
|
+
|
|
23
|
+
function bridgeCommandCheck(result) {
|
|
24
|
+
return result.available
|
|
25
|
+
? check('command', 'ok', `codex-bridge resolves on PATH (${result.value})`)
|
|
26
|
+
: check('command', 'warn', `codex-bridge does not resolve on PATH (${result.value}); run npm i -g @lyupro/codex-bridge`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which copy of the package is answering. Plan_19 gives the CLI a second name, and a global install
|
|
31
|
+
* puts a second copy of the package on the machine beside any clone. `update` copies host files
|
|
32
|
+
* from whichever copy launched it, so `codexb update` from PATH silently reverts a host that
|
|
33
|
+
* `npm run dev:install` from the clone had just refreshed — and every line below this one describes
|
|
34
|
+
* the host as seen by THIS copy. An operator comparing two diagnoses has no other way to tell them
|
|
35
|
+
* apart.
|
|
36
|
+
*/
|
|
37
|
+
function packageSource() {
|
|
38
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
return {
|
|
40
|
+
root,
|
|
41
|
+
kind: root.split(/[\\/]/).includes('node_modules') ? 'installed copy' : 'clone',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sourceCheck() {
|
|
46
|
+
const source = packageSource();
|
|
47
|
+
return check('source', 'ok', `${source.root} (${source.kind === 'installed copy' ? 'installed package' : source.kind})`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function probeCodex() {
|
|
51
|
+
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
|
|
52
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
|
|
53
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
54
|
+
if (result.error || result.status !== 0) {
|
|
55
|
+
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
56
|
+
}
|
|
57
|
+
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function probeCodexBridge() {
|
|
61
|
+
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex-bridge';
|
|
62
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex-bridge --version'] : ['--version'];
|
|
63
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
64
|
+
if (result.error || result.status !== 0) {
|
|
65
|
+
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
66
|
+
}
|
|
67
|
+
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function diagnose({
|
|
71
|
+
host,
|
|
72
|
+
codexProbe = probeCodex,
|
|
73
|
+
bridgeProbe = probeCodexBridge,
|
|
74
|
+
currentPackage,
|
|
75
|
+
contractRecord,
|
|
76
|
+
hostVersion,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const checks = [sourceCheck()];
|
|
79
|
+
const hostExists = await exists(host.root);
|
|
80
|
+
checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
|
|
81
|
+
|
|
82
|
+
const ownPackage = currentPackage || await packageInfo();
|
|
83
|
+
let record = null;
|
|
84
|
+
let recordBroken = false;
|
|
85
|
+
try {
|
|
86
|
+
record = await readInstallRecord(host);
|
|
87
|
+
if (!record) checks.push(check('installation', 'fail', 'not installed'));
|
|
88
|
+
else {
|
|
89
|
+
const matches = record.name === ownPackage.name && record.version === ownPackage.version;
|
|
90
|
+
checks.push(check(
|
|
91
|
+
'installation',
|
|
92
|
+
matches ? 'ok' : 'warn',
|
|
93
|
+
`${record.name}@${record.version} (${matches ? 'matches package' : `package is ${ownPackage.name}@${ownPackage.version}`})`,
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
recordBroken = true;
|
|
98
|
+
checks.push(check('installation', 'fail', `broken record: ${err.message}`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const missingFiles = [];
|
|
102
|
+
if (record) {
|
|
103
|
+
for (const file of record.files) {
|
|
104
|
+
if (!(await isFile(recordTarget(host, file)))) missingFiles.push(`${file.root}/${file.path}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
checks.push(check(
|
|
108
|
+
'files',
|
|
109
|
+
!record ? 'warn' : missingFiles.length ? 'fail' : 'ok',
|
|
110
|
+
!record ? 'not checked' : missingFiles.length ? `missing: ${missingFiles.join(', ')}` : `${record.files.length} installed file(s) present`,
|
|
111
|
+
));
|
|
112
|
+
const agents = await agentsCheck(host, record);
|
|
113
|
+
checks.push(agents);
|
|
114
|
+
const rules = await rulesCheck(host, record);
|
|
115
|
+
checks.push(rules);
|
|
116
|
+
checks.push(await permissionsCheck(host));
|
|
117
|
+
const bridge = bridgeProbe();
|
|
118
|
+
checks.push(bridgeCommandCheck(bridge));
|
|
119
|
+
checks.push(...await hookChecks(host, record, () => bridge, ownPackage, packageSource().kind));
|
|
120
|
+
const hostContract = contractStatus({
|
|
121
|
+
record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
|
|
122
|
+
version: hostVersion === undefined ? detectHostVersion() : hostVersion,
|
|
123
|
+
});
|
|
124
|
+
const hostContractStatus = hostContract.state === 'verified'
|
|
125
|
+
? 'ok'
|
|
126
|
+
: hostContract.state === 'ignored' ? 'fail' : 'warn';
|
|
127
|
+
checks.push(check('hostContract', hostContractStatus, hostContract.message));
|
|
128
|
+
const retention = retentionCheck(host);
|
|
129
|
+
checks.push(retention);
|
|
130
|
+
const conventions = await conventionsCheck(host);
|
|
131
|
+
checks.push(conventions);
|
|
132
|
+
|
|
133
|
+
const codex = codexProbe();
|
|
134
|
+
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
|
135
|
+
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
|
|
136
|
+
checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
|
|
137
|
+
checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
|
|
138
|
+
const projectRuns = projectRunsCheck();
|
|
139
|
+
checks.push(liveRunsCheck());
|
|
140
|
+
checks.push(projectRuns);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
144
|
+
|| hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
|
|
145
|
+
|| projectRuns.status === 'fail' ? 1 : 0,
|
|
146
|
+
checks,
|
|
147
|
+
record,
|
|
148
|
+
missingFiles,
|
|
149
|
+
};
|
|
150
|
+
}
|