@lyupro/codex-bridge 0.4.0 → 0.5.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.
- package/CHANGELOG.md +44 -0
- package/README.md +88 -97
- package/bin/codex-bridge.mjs +11 -7
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +56 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +67 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +88 -173
- package/cli/projects.mjs +20 -5
- package/cli/remove-layout.mjs +59 -0
- package/cli/settings-merge.mjs +91 -7
- package/cli/uninstall.mjs +47 -32
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +15 -13
- package/src/agents/codex-review.md +13 -9
- package/src/agents/codex-scout.md +14 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +20 -10
- package/src/hooks/reply-guard.mjs +1 -58
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/runner/args.mjs +10 -1
- package/src/runner/attach.mjs +9 -2
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +15 -13
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +4 -0
package/cli/manifest.mjs
CHANGED
|
@@ -1,38 +1,52 @@
|
|
|
1
|
-
/** Defines install mappings and
|
|
1
|
+
/** Defines the package install mappings and renders files before they are copied. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
// The hook table lives under src/ because the installed gate reads it too: cli/ is not copied
|
|
7
|
-
// into a host, so a definition kept here would be invisible to the very hook it describes.
|
|
8
6
|
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
9
7
|
import {
|
|
10
8
|
renderRequiredInputSummary,
|
|
11
9
|
renderRequiredInputs,
|
|
12
10
|
} from '../src/required-inputs.mjs';
|
|
13
11
|
import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
|
|
12
|
+
import { renderStopSummary } from '../src/stop-contract.mjs';
|
|
14
13
|
import { readJsonFile } from '../src/json-file.mjs';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
14
|
+
import {
|
|
15
|
+
INSTALL_RECORD_NAME,
|
|
16
|
+
RULES_NAME,
|
|
17
|
+
definitionForRecordedHook,
|
|
18
|
+
fileEntry,
|
|
19
|
+
installRecordPath,
|
|
20
|
+
legacyInstallRecordPath,
|
|
21
|
+
normalizeInstallRecord,
|
|
22
|
+
readInstallRecord,
|
|
23
|
+
recordFileKey,
|
|
24
|
+
recordMatchesPackage,
|
|
25
|
+
recordTarget,
|
|
26
|
+
validateInstallRecord,
|
|
27
|
+
writeInstallRecord,
|
|
28
|
+
} from './install-record.mjs';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
HOOK_DEFINITIONS,
|
|
32
|
+
INSTALL_RECORD_NAME,
|
|
33
|
+
RULES_NAME,
|
|
34
|
+
definitionForRecordedHook,
|
|
35
|
+
installRecordPath,
|
|
36
|
+
legacyInstallRecordPath,
|
|
37
|
+
normalizeInstallRecord,
|
|
38
|
+
readInstallRecord,
|
|
39
|
+
recordFileKey,
|
|
40
|
+
recordMatchesPackage,
|
|
41
|
+
recordTarget,
|
|
42
|
+
validateInstallRecord,
|
|
43
|
+
writeInstallRecord,
|
|
44
|
+
};
|
|
30
45
|
|
|
31
46
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
47
|
export const PACKAGE_ROOT = path.resolve(HERE, '..');
|
|
33
|
-
export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
|
|
34
|
-
export const RULES_NAME = 'codex-bridge.rules';
|
|
35
48
|
const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* Files the operator owns once they exist. Seeded on first install so the defaults are
|
|
38
52
|
* visible and editable, then never written, never compared, never removed: run-config.json
|
|
@@ -41,25 +55,29 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
|
|
|
41
55
|
* update — the same way overwriting a .env would.
|
|
42
56
|
*/
|
|
43
57
|
export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
|
|
44
|
-
|
|
45
|
-
new Set(SEEDED_SOURCES.map((source) => posix(
|
|
46
|
-
path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
|
|
47
|
-
)));
|
|
58
|
+
|
|
48
59
|
export const INSTALL_TABLE = Object.freeze([
|
|
49
|
-
{ source: 'src/agents/*.md', target: 'agentsDir', processing: 'placeholders' },
|
|
50
|
-
{ source: 'src/commands/*.md', target: 'commandsDir', processing: 'placeholders' },
|
|
51
|
-
{ source: 'src/**', target: '
|
|
60
|
+
{ source: 'src/agents/*.md', root: 'claude', target: 'agentsDir', processing: 'placeholders' },
|
|
61
|
+
{ source: 'src/commands/*.md', root: 'claude', target: 'commandsDir', processing: 'placeholders' },
|
|
62
|
+
{ source: 'src/hooks/**', root: 'brand', target: 'brandHooksDir', processing: 'copy' },
|
|
63
|
+
{ source: 'src/**', root: 'brand', target: 'brandRunnerDir', processing: 'copy' },
|
|
64
|
+
// The runner reads its own version out of the package.json beside it, so meta.json can say
|
|
65
|
+
// which code wrote a run (Plan_34). In the clone that file sits one level above src/; without
|
|
66
|
+
// this line the installed layout has nothing one level above lib/, and every run on a real host
|
|
67
|
+
// would die on import while the whole suite stayed green against the clone.
|
|
68
|
+
{ source: 'package.json', root: 'brand', target: 'brandRoot', processing: 'copy' },
|
|
52
69
|
]);
|
|
53
70
|
|
|
54
71
|
const posix = (value) => value.split(path.sep).join('/');
|
|
55
72
|
|
|
56
|
-
export function replacePlaceholders(content,
|
|
73
|
+
export function replacePlaceholders(content, installationRoot) {
|
|
57
74
|
const agentType = content.match(/^name:\s*([^\r\n]+)$/m)?.[1].trim();
|
|
58
75
|
return content
|
|
59
|
-
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(
|
|
76
|
+
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(installationRoot)))
|
|
60
77
|
.replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
|
|
61
78
|
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
62
|
-
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
|
|
79
|
+
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution())
|
|
80
|
+
.replaceAll('{{CODEX_STOP_SUMMARY}}', renderStopSummary());
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
@@ -67,21 +85,24 @@ export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
|
|
67
85
|
return { name: parsed.name, version: parsed.version };
|
|
68
86
|
}
|
|
69
87
|
|
|
70
|
-
export function installRecordPath(host) {
|
|
71
|
-
return path.join(host.agentsDir, INSTALL_RECORD_NAME);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
88
|
export function installedHookPath(host, definition) {
|
|
75
|
-
return path.join(host.
|
|
89
|
+
return path.join(host.brandHooksDir, definition.file);
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
/** Where each seeded file comes from and where it goes; contents are copied verbatim. */
|
|
79
93
|
export function seedPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
80
|
-
return SEEDED_SOURCES.map((source) =>
|
|
81
|
-
source
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
return SEEDED_SOURCES.map((source) => {
|
|
95
|
+
const target = source.endsWith('run-config.json')
|
|
96
|
+
? host.brandConfigPath
|
|
97
|
+
: host.brandConventionsPath;
|
|
98
|
+
return {
|
|
99
|
+
source: path.join(packageRoot, source),
|
|
100
|
+
target,
|
|
101
|
+
root: 'brand',
|
|
102
|
+
relativeToRoot: posix(path.relative(host.brandRoot, target)),
|
|
103
|
+
processing: 'copy',
|
|
104
|
+
};
|
|
105
|
+
});
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
export function rulesPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
@@ -101,137 +122,24 @@ export async function fileFingerprint(absolutePath) {
|
|
|
101
122
|
}
|
|
102
123
|
}
|
|
103
124
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
*/
|
|
109
|
-
export function recordMatchesPackage(record, plan, currentPackage, fingerprints, ruleState) {
|
|
110
|
-
return Boolean(record)
|
|
111
|
-
&& record.name === currentPackage.name
|
|
112
|
-
&& record.version === currentPackage.version
|
|
113
|
-
&& record.files.length === plan.length
|
|
114
|
-
&& record.files.every((file, index) => file === plan[index].relativeToHost)
|
|
115
|
-
&& Boolean(record.fingerprints)
|
|
116
|
-
&& plan.every((item) => record.fingerprints[item.relativeToHost] === fingerprints.get(item.relativeToHost))
|
|
117
|
-
&& (!ruleState || (record.rules?.path === ruleState.path
|
|
118
|
-
&& record.rules.fingerprint === ruleState.fingerprint));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function validateInstallRecord(record) {
|
|
122
|
-
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
123
|
-
throw new Error('installation record must be an object');
|
|
124
|
-
}
|
|
125
|
-
const strings = ['name', 'version', 'installedAt', 'mode'];
|
|
126
|
-
for (const key of strings) {
|
|
127
|
-
if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
|
|
128
|
-
}
|
|
129
|
-
if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
|
|
130
|
-
if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
|
|
131
|
-
if (!Array.isArray(record.files) || !record.files.length || record.files.some((file) => typeof file !== 'string' || !file)) {
|
|
132
|
-
throw new Error('installation record files must be a non-empty list of strings');
|
|
133
|
-
}
|
|
134
|
-
if (record.files.some((file) => path.isAbsolute(file) || file.split(/[\\/]/).some((part) => part === '..' || part === '.'))) {
|
|
135
|
-
throw new Error('installation record files must stay relative to the host root');
|
|
136
|
-
}
|
|
137
|
-
if (new Set(record.files).size !== record.files.length) {
|
|
138
|
-
throw new Error('installation record files must not contain duplicates');
|
|
139
|
-
}
|
|
140
|
-
// Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
|
|
141
|
-
// update needs its own guard against deleting a run folder someone listed as an installed file.
|
|
142
|
-
if (record.files.some((file) => file === 'codex-runs' || file.split(/[\\/]/)[0] === 'codex-runs')) {
|
|
143
|
-
throw new Error('installation record files must not name run artifacts under codex-runs');
|
|
144
|
-
}
|
|
145
|
-
if (record.fingerprints !== undefined) {
|
|
146
|
-
if (!record.fingerprints || typeof record.fingerprints !== 'object' || Array.isArray(record.fingerprints)) {
|
|
147
|
-
throw new Error('installation record fingerprints must be an object');
|
|
148
|
-
}
|
|
149
|
-
const fingerprintKeys = Object.keys(record.fingerprints);
|
|
150
|
-
if (fingerprintKeys.length !== record.files.length || fingerprintKeys.some((file) => !record.files.includes(file))) {
|
|
151
|
-
throw new Error('installation record fingerprints keys must exactly match files');
|
|
152
|
-
}
|
|
153
|
-
if (Object.values(record.fingerprints).some((fingerprint) =>
|
|
154
|
-
typeof fingerprint !== 'string' || !/^[a-f0-9]{64}$/i.test(fingerprint))) {
|
|
155
|
-
throw new Error('installation record fingerprints must be 64-character hexadecimal SHA256 strings');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (record.rules !== undefined) {
|
|
159
|
-
if (!record.rules || typeof record.rules !== 'object' || Array.isArray(record.rules)) {
|
|
160
|
-
throw new Error('installation record rules must be an object');
|
|
161
|
-
}
|
|
162
|
-
if (typeof record.rules.path !== 'string' || !record.rules.path) {
|
|
163
|
-
throw new Error('installation record rules path must be a non-empty string');
|
|
164
|
-
}
|
|
165
|
-
if (path.basename(record.rules.path) !== RULES_NAME) {
|
|
166
|
-
throw new Error(`installation record rules path must name ${RULES_NAME}`);
|
|
167
|
-
}
|
|
168
|
-
if (typeof record.rules.fingerprint !== 'string'
|
|
169
|
-
|| !/^[a-f0-9]{64}$/i.test(record.rules.fingerprint)) {
|
|
170
|
-
throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (record.hooks !== undefined && record.hook !== undefined) {
|
|
174
|
-
throw new Error('installation record must use hooks instead of hook');
|
|
175
|
-
}
|
|
176
|
-
const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
|
|
177
|
-
if (!Array.isArray(hooks) || !hooks.length) {
|
|
178
|
-
throw new Error('installation record hooks must be a non-empty list');
|
|
179
|
-
}
|
|
180
|
-
for (const hook of hooks) {
|
|
181
|
-
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
|
|
182
|
-
throw new Error('installation record hook must be an object');
|
|
183
|
-
}
|
|
184
|
-
const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
|
|
185
|
-
if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
|
|
186
|
-
throw new Error('installation record hook must identify a supported event and its path');
|
|
187
|
-
}
|
|
188
|
-
if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
|
|
189
|
-
throw new Error('installation record hook path must stay relative to the host root');
|
|
190
|
-
}
|
|
191
|
-
const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
|
|
192
|
-
if (!definition || !record.files.includes(hook.path)) {
|
|
193
|
-
const files = definitions.map((entry) => entry.file).join(' or ');
|
|
194
|
-
throw new Error(`installation record hook path must name the installed ${files}`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return record;
|
|
125
|
+
function targetBase(host, mapping) {
|
|
126
|
+
const base = host[mapping.target];
|
|
127
|
+
if (!base) throw new Error(`host has no ${mapping.target} install target`);
|
|
128
|
+
return base;
|
|
198
129
|
}
|
|
199
130
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (record.hooks !== undefined) return record;
|
|
203
|
-
const { hook, ...withoutLegacyHook } = record;
|
|
204
|
-
return { ...withoutLegacyHook, hooks: [hook] };
|
|
131
|
+
function rootFor(host, mapping) {
|
|
132
|
+
return mapping.root === 'brand' ? host.brandRoot : host.root;
|
|
205
133
|
}
|
|
206
134
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
parsed = await readJsonFile(installRecordPath(host));
|
|
211
|
-
} catch (err) {
|
|
212
|
-
if (err.code === 'ENOENT') return null;
|
|
213
|
-
if (err.cause) {
|
|
214
|
-
throw new Error(`invalid installation record JSON: ${err.cause.message}`, { cause: err.cause });
|
|
215
|
-
}
|
|
216
|
-
throw err;
|
|
135
|
+
function targetRelative(packageRoot, source, mapping) {
|
|
136
|
+
if (mapping.processing === 'placeholders' || mapping.source === 'package.json') {
|
|
137
|
+
return path.basename(source);
|
|
217
138
|
}
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const seeded = seededTargets(host);
|
|
223
|
-
record.files = record.files.filter((file) => !seeded.has(file));
|
|
224
|
-
if (record.fingerprints) {
|
|
225
|
-
record.fingerprints = Object.fromEntries(
|
|
226
|
-
Object.entries(record.fingerprints).filter(([file]) => !seeded.has(file)),
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
return record;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export async function writeInstallRecord(host, record) {
|
|
233
|
-
const normalized = normalizeInstallRecord(record);
|
|
234
|
-
await fs.writeFile(installRecordPath(host), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
139
|
+
const sourceRoot = mapping.source === 'src/hooks/**'
|
|
140
|
+
? path.join(packageRoot, 'src', 'hooks')
|
|
141
|
+
: path.join(packageRoot, 'src');
|
|
142
|
+
return path.relative(sourceRoot, source);
|
|
235
143
|
}
|
|
236
144
|
|
|
237
145
|
export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
@@ -239,22 +147,29 @@ export async function buildInstallPlan(host, packageRoot = PACKAGE_ROOT) {
|
|
|
239
147
|
const claimedSources = [];
|
|
240
148
|
for (const mapping of INSTALL_TABLE) {
|
|
241
149
|
for await (const relative of fs.glob(mapping.source, { cwd: packageRoot, exclude: claimedSources })) {
|
|
242
|
-
if (posix(relative) === RULES_SOURCE) continue;
|
|
243
|
-
if (SEEDED_SOURCES.includes(posix(relative))) continue;
|
|
150
|
+
if (posix(relative) === RULES_SOURCE || SEEDED_SOURCES.includes(posix(relative))) continue;
|
|
244
151
|
const source = path.join(packageRoot, relative);
|
|
245
152
|
if (!(await fs.stat(source)).isFile()) continue;
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const target = path.join(host[mapping.target], targetRelative);
|
|
153
|
+
const relativeToTarget = posix(targetRelative(packageRoot, source, mapping));
|
|
154
|
+
const target = path.join(targetBase(host, mapping), relativeToTarget);
|
|
155
|
+
const relativeToRoot = posix(path.relative(rootFor(host, mapping), target));
|
|
250
156
|
files.push({
|
|
251
157
|
source,
|
|
252
158
|
target,
|
|
159
|
+
root: mapping.root,
|
|
160
|
+
relativeToRoot,
|
|
253
161
|
relativeToHost: posix(path.relative(host.root, target)),
|
|
254
162
|
processing: mapping.processing,
|
|
163
|
+
installationRoot: mapping.processing === 'placeholders'
|
|
164
|
+
? host.brandRunnerDir
|
|
165
|
+
: rootFor(host, mapping),
|
|
255
166
|
});
|
|
256
167
|
}
|
|
257
168
|
claimedSources.push(mapping.source);
|
|
258
169
|
}
|
|
259
|
-
return files.sort((a, b) => a.
|
|
170
|
+
return files.sort((a, b) => recordFileKey(a).localeCompare(recordFileKey(b)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function targetForPlanItem(host, item) {
|
|
174
|
+
return recordTarget(host, fileEntry({ root: item.root, path: item.relativeToRoot }));
|
|
260
175
|
}
|
package/cli/projects.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Implements the projects command. */
|
|
2
2
|
import { listProjectRuns, listProjects } from './runs-inventory.mjs';
|
|
3
3
|
import { renderTable } from './table.mjs';
|
|
4
|
+
import { allLiveRuns } from '../src/hooks/live-runs.mjs';
|
|
5
|
+
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
6
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
|
|
4
7
|
|
|
5
8
|
// The table renders whatever it is given; how a byte count or a timestamp reads is this command's
|
|
6
9
|
// business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
|
|
@@ -75,9 +78,21 @@ function parseArgs(args) {
|
|
|
75
78
|
return { json, projectName: positional[0] || null };
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Silence when nothing is running: this line exists to interrupt the operator who believes every
|
|
83
|
+
* run is stopped, and a "0 runs" line printed under every listing is the fastest way to teach them
|
|
84
|
+
* not to read it.
|
|
85
|
+
*/
|
|
86
|
+
function workingRunsLine(root) {
|
|
87
|
+
const count = allLiveRuns(root, { requireConfirmedIdentity: true }).length;
|
|
88
|
+
if (!count) return '';
|
|
89
|
+
const noun = count === 1 ? 'run' : 'runs';
|
|
90
|
+
return `\n${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function output(rows, columns, options, root) {
|
|
79
94
|
if (options.json) return JSON.stringify(rows, null, 2);
|
|
80
|
-
return renderTable(columns, rows, options.terminalWidth)
|
|
95
|
+
return `${renderTable(columns, rows, options.terminalWidth)}${workingRunsLine(root)}`;
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
/** Lists projects or runs for one project. */
|
|
@@ -86,7 +101,7 @@ export function projects(argv = [], options = {}) {
|
|
|
86
101
|
if (parsed.error) return { exitCode: 2, output: parsed.error };
|
|
87
102
|
|
|
88
103
|
const { projectName } = parsed;
|
|
89
|
-
const root = options.runsRootPath;
|
|
104
|
+
const root = options.runsRootPath || runsRoot();
|
|
90
105
|
const renderOptions = { json: parsed.json, terminalWidth: options.terminalWidth };
|
|
91
106
|
|
|
92
107
|
if (projectName) {
|
|
@@ -97,9 +112,9 @@ export function projects(argv = [], options = {}) {
|
|
|
97
112
|
output: `codex-bridge projects: unknown project "${projectName}"`,
|
|
98
113
|
};
|
|
99
114
|
}
|
|
100
|
-
return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions) };
|
|
115
|
+
return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions, root) };
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
const rows = listProjects(root);
|
|
104
|
-
return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions) };
|
|
119
|
+
return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions, root) };
|
|
105
120
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Removes emptied directories of an installed layout without walking out of the package's own. */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* These four helpers existed as two copies — one in uninstall.mjs, one in update.mjs — and the
|
|
7
|
+
* copies had already drifted before this file was written: update's removeEmptyParents inlined the
|
|
8
|
+
* readdir/rmdir pair instead of calling removeEmpty, and only uninstall's removeEmptyLayout
|
|
9
|
+
* guarded a missing directory. Nothing failed, which is exactly the problem: the same divergence
|
|
10
|
+
* between the installer's file list and the hook's list went unnoticed until Plan_19 had to
|
|
11
|
+
* reconcile them. Removal logic decides what disappears from the operator's ~/.claude, so it gets
|
|
12
|
+
* one definition.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export async function removeEmpty(directory) {
|
|
16
|
+
try {
|
|
17
|
+
if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code !== 'ENOENT') throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function removeEmptyParents(target, boundary) {
|
|
24
|
+
let current = path.dirname(target);
|
|
25
|
+
while (current !== boundary && current.startsWith(`${boundary}${path.sep}`)) {
|
|
26
|
+
await removeEmpty(current);
|
|
27
|
+
current = path.dirname(current);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The directory an emptied-parent walk must stop at. Plan_25 gave the host four package-owned
|
|
33
|
+
* directories at once — the current agents and commands subdirectories and the two the previous
|
|
34
|
+
* layout used — and a walk that started inside one of them but stopped at host.root would delete
|
|
35
|
+
* the operator's own emptied directories on the way up.
|
|
36
|
+
*/
|
|
37
|
+
export function claudeBoundary(host, target) {
|
|
38
|
+
const directories = [
|
|
39
|
+
host.agentsDir,
|
|
40
|
+
host.commandsDir,
|
|
41
|
+
host.legacyAgentsDir,
|
|
42
|
+
host.legacyCommandsDir,
|
|
43
|
+
].filter(Boolean);
|
|
44
|
+
return directories.find((directory) => target === directory
|
|
45
|
+
|| target.startsWith(`${directory}${path.sep}`)) || host.root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Takes down one emptied package-owned layout directory — and stops there.
|
|
50
|
+
*
|
|
51
|
+
* It deliberately does not walk into the parent: the parents here are `~/.claude/agents` and
|
|
52
|
+
* `~/.claude/commands`, which belong to Claude Code and are shared with every other agent the
|
|
53
|
+
* operator has. An operator whose only agents were ours would have had those directories deleted
|
|
54
|
+
* out from under Claude Code by an uninstall that was asked to remove our files. Directories
|
|
55
|
+
* *inside* our own are a different case and are still walked, bounded by claudeBoundary().
|
|
56
|
+
*/
|
|
57
|
+
export async function removeEmptyLayout(directory) {
|
|
58
|
+
await removeEmpty(directory);
|
|
59
|
+
}
|
package/cli/settings-merge.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Merges and removes named codex-bridge hooks without disturbing host settings. */
|
|
2
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, statSync } from 'node:fs';
|
|
3
5
|
import fs from 'node:fs/promises';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import { randomUUID } from 'node:crypto';
|
|
@@ -12,6 +14,74 @@ export function commandFor(guardPath) {
|
|
|
12
14
|
return `node "${path.resolve(guardPath)}"`;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
export function shortCommandFor(name) {
|
|
18
|
+
return `codex-bridge hook ${name}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathValue(env) {
|
|
22
|
+
const key = Object.keys(env).find((entry) => entry.toLowerCase() === 'path');
|
|
23
|
+
return key ? env[key] : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function commandReachable(name = 'codex-bridge', env = process.env) {
|
|
27
|
+
const extensions = process.platform === 'win32'
|
|
28
|
+
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
|
|
29
|
+
: [''];
|
|
30
|
+
return pathValue(env).split(path.delimiter).filter(Boolean).some((directory) =>
|
|
31
|
+
extensions.some((extension) => {
|
|
32
|
+
const candidate = path.join(directory, `${name}${extension}`);
|
|
33
|
+
try {
|
|
34
|
+
return existsSync(candidate) && statSync(candidate).isFile();
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The version the command on PATH reports, or null when there is none or it will not say.
|
|
43
|
+
*
|
|
44
|
+
* Existence used to be the only question, and on 2026-08-11 that cost the operator every guard on
|
|
45
|
+
* the machine: an install from the clone wrote `codex-bridge hook <name>` while the command on
|
|
46
|
+
* PATH was the previous release, which has no `hook` subcommand. Every guard then failed before it
|
|
47
|
+
* could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
|
|
48
|
+
*/
|
|
49
|
+
export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
|
|
50
|
+
if (!commandReachable(name, env)) return null;
|
|
51
|
+
const result = spawnSync(name, ['--version'], {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
env,
|
|
54
|
+
// The launcher npm writes on Windows is a .cmd shim, which spawn cannot execute without a
|
|
55
|
+
// shell. The name is a constant of this module, never operator input.
|
|
56
|
+
shell: process.platform === 'win32',
|
|
57
|
+
});
|
|
58
|
+
if (result.error || result.status !== 0) return null;
|
|
59
|
+
return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hookRegistration(name, target, env = process.env, packageVersion = null) {
|
|
63
|
+
// Requiring the versions to match is stricter than asking whether `hook` exists, deliberately:
|
|
64
|
+
// a command of another version would run code this install has never seen, which is the hazard
|
|
65
|
+
// README describes when a global install and a clone coexist. Anything short of a match falls
|
|
66
|
+
// back to the copy this install placed itself.
|
|
67
|
+
const reachable = reachableCommandVersion('codex-bridge', env);
|
|
68
|
+
if (reachable && packageVersion && reachable === packageVersion) {
|
|
69
|
+
return {
|
|
70
|
+
command: shortCommandFor(name),
|
|
71
|
+
form: 'short',
|
|
72
|
+
reason: `codex-bridge ${reachable} on PATH matches this package; the global command will execute`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const reason = reachable
|
|
76
|
+
? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
|
|
77
|
+
: 'codex-bridge is not reachable from PATH; the installed copy will execute';
|
|
78
|
+
return { command: commandFor(target), form: 'path', reason };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function commandForm(command) {
|
|
82
|
+
return command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
83
|
+
}
|
|
84
|
+
|
|
15
85
|
function normalizeSpec(specOrPath) {
|
|
16
86
|
if (typeof specOrPath === 'string') {
|
|
17
87
|
return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
|
|
@@ -26,6 +96,9 @@ function normalizeSpec(specOrPath) {
|
|
|
26
96
|
event: specOrPath.event,
|
|
27
97
|
matcher: specOrPath.matcher,
|
|
28
98
|
command: specOrPath.command,
|
|
99
|
+
alternateCommands: Array.isArray(specOrPath.alternateCommands)
|
|
100
|
+
? specOrPath.alternateCommands.filter((command) => typeof command === 'string')
|
|
101
|
+
: [],
|
|
29
102
|
};
|
|
30
103
|
}
|
|
31
104
|
|
|
@@ -55,8 +128,10 @@ function groupHooks(group) {
|
|
|
55
128
|
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
56
129
|
}
|
|
57
130
|
|
|
58
|
-
const hasOwnCommand = (group,
|
|
59
|
-
|
|
131
|
+
const hasOwnCommand = (group, commands) => {
|
|
132
|
+
const expected = Array.isArray(commands) ? commands : [commands];
|
|
133
|
+
return groupHooks(group).some((hook) => hook?.type === 'command' && expected.includes(hook.command));
|
|
134
|
+
};
|
|
60
135
|
|
|
61
136
|
/**
|
|
62
137
|
* Presence is decided by the command, not by the matcher it currently sits under.
|
|
@@ -71,9 +146,17 @@ const hasOwnCommand = (group, command) =>
|
|
|
71
146
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
72
147
|
const state = await readSettings(settingsPath);
|
|
73
148
|
const spec = normalizeSpec(specOrPath);
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
149
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
150
|
+
let matchedCommand;
|
|
151
|
+
for (const group of groups(state.settings, spec.event)) {
|
|
152
|
+
const hook = groupHooks(group).find((entry) =>
|
|
153
|
+
entry?.type === 'command' && commands.includes(entry.command));
|
|
154
|
+
if (hook) {
|
|
155
|
+
matchedCommand = hook.command;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ...state, ...spec, present: Boolean(matchedCommand), matchedCommand };
|
|
77
160
|
}
|
|
78
161
|
|
|
79
162
|
function backupName(settingsPath) {
|
|
@@ -173,10 +256,11 @@ export async function removeHook(settingsPath, specOrPath, { createdGroup = fals
|
|
|
173
256
|
// sits in another group.
|
|
174
257
|
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
175
258
|
const group = eventGroups[index];
|
|
176
|
-
|
|
259
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
260
|
+
if (!hasOwnCommand(group, commands)) continue;
|
|
177
261
|
if (!Array.isArray(group?.hooks)) continue;
|
|
178
262
|
group.hooks = group.hooks.filter((hook) =>
|
|
179
|
-
!(hook?.type === 'command' && hook.command
|
|
263
|
+
!(hook?.type === 'command' && commands.includes(hook.command)));
|
|
180
264
|
if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
181
265
|
}
|
|
182
266
|
await atomicWrite(settingsPath, settings, state);
|