@lyupro/codex-bridge 0.3.1 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +88 -84
  3. package/bin/codex-bridge.mjs +38 -11
  4. package/cli/copy.mjs +7 -6
  5. package/cli/doctor.mjs +73 -12
  6. package/cli/hook-targets.mjs +39 -0
  7. package/cli/hook.mjs +34 -0
  8. package/cli/hosts.mjs +28 -4
  9. package/cli/install-record.mjs +287 -0
  10. package/cli/install.mjs +73 -39
  11. package/cli/invoked-directly.mjs +37 -0
  12. package/cli/manifest.mjs +90 -164
  13. package/cli/permissions.mjs +179 -0
  14. package/cli/projects.mjs +20 -5
  15. package/cli/prune-args.mjs +1 -1
  16. package/cli/remove-layout.mjs +59 -0
  17. package/cli/rules-owners.mjs +6 -8
  18. package/cli/runs-inventory.mjs +2 -1
  19. package/cli/settings-merge.mjs +182 -50
  20. package/cli/stop.mjs +33 -20
  21. package/cli/uninstall.mjs +64 -36
  22. package/cli/unlock.mjs +239 -0
  23. package/cli/update.mjs +109 -48
  24. package/package.json +2 -2
  25. package/scripts/check-file-size.mjs +8 -10
  26. package/scripts/isolated-roots.mjs +13 -0
  27. package/scripts/run-tests.mjs +14 -4
  28. package/src/agents/codex-build.md +17 -13
  29. package/src/agents/codex-review.md +15 -9
  30. package/src/agents/codex-scout.md +16 -11
  31. package/src/commands/env.md +4 -4
  32. package/src/commands/usage.md +8 -6
  33. package/src/heartbeat.mjs +1 -1
  34. package/src/hook-definitions.mjs +17 -4
  35. package/src/hooks/guard-tries.mjs +59 -0
  36. package/src/hooks/live-runs.mjs +31 -20
  37. package/src/hooks/order-gate.mjs +7 -2
  38. package/src/hooks/reply-guard.mjs +4 -60
  39. package/src/hooks/stop-guard.mjs +71 -0
  40. package/src/json-file.mjs +30 -0
  41. package/src/meta/chain.mjs +8 -6
  42. package/src/meta/paths.mjs +5 -9
  43. package/src/meta/pre-start.mjs +30 -0
  44. package/src/meta/run-state.mjs +31 -16
  45. package/src/process-identity.mjs +140 -0
  46. package/src/required-inputs.mjs +42 -0
  47. package/src/run-config.mjs +4 -8
  48. package/src/runner/args.mjs +26 -3
  49. package/src/runner/attach.mjs +32 -19
  50. package/src/runner/codex-cmd.mjs +1 -1
  51. package/src/runner/continuation.mjs +34 -3
  52. package/src/runner/launcher.mjs +54 -28
  53. package/src/runner/project-dir.mjs +4 -3
  54. package/src/runner/scope-check.mjs +159 -0
  55. package/src/runner/worker.mjs +13 -5
  56. package/src/stop-contract.mjs +15 -0
  57. package/src/write-meta.mjs +12 -1
  58. package/cli/sweep.mjs +0 -144
package/cli/hook.mjs ADDED
@@ -0,0 +1,34 @@
1
+ /** Dispatches a named host hook without changing the guard's stdin or process lifecycle. */
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
5
+
6
+ const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'hooks');
7
+ const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
8
+
9
+ function validNames() {
10
+ return VALID_NAMES.join(', ');
11
+ }
12
+
13
+ function usageError(io, message) {
14
+ io.error(`codex-bridge hook ${message} Valid hook names: ${validNames()}.`);
15
+ return 2;
16
+ }
17
+
18
+ export async function hook(args = [], io = console) {
19
+ if (!Array.isArray(args) || args.length !== 1) {
20
+ const detail = Array.isArray(args) && args.length
21
+ ? `accepts exactly one hook name; received ${args.length}.`
22
+ : 'requires exactly one hook name.';
23
+ return usageError(io, detail);
24
+ }
25
+
26
+ const definition = HOOK_DEFINITIONS.find(({ name }) => name === args[0]);
27
+ if (!definition) return usageError(io, `unknown hook name "${args[0]}".`);
28
+
29
+ // Plan_25 replaces absolute paths in foreign settings.json with this command. Importing the
30
+ // proven top-level script keeps its stdin reader and process.exit() contract intact; exporting
31
+ // guard functions would be a second implementation of the live hook boundary.
32
+ await import(pathToFileURL(path.join(HOOKS_DIR, definition.file)).href);
33
+ return typeof process.exitCode === 'number' ? process.exitCode : 0;
34
+ }
package/cli/hosts.mjs CHANGED
@@ -13,24 +13,48 @@ function repositoryRoot(start) {
13
13
  }
14
14
  }
15
15
 
16
+ function claudePaths(root) {
17
+ return {
18
+ agentsDir: path.join(root, 'agents', 'codex-bridge'),
19
+ commandsDir: path.join(root, 'commands', 'codex-bridge'),
20
+ legacyAgentsDir: path.join(root, 'agents', 'codex'),
21
+ legacyCommandsDir: path.join(root, 'commands', 'codex'),
22
+ };
23
+ }
24
+
16
25
  export function resolveHost({
17
26
  scope = 'user',
18
27
  host,
19
28
  cwd = process.cwd(),
20
29
  homedir = os.homedir(),
21
30
  codexHome,
31
+ brandRoot,
22
32
  } = {}) {
23
33
  const resolvedCodexHome = codexHome || process.env.CODEX_HOME || path.join(homedir, '.codex');
24
34
  const codexRulesDir = path.join(resolvedCodexHome, 'rules');
35
+ // Plan_25 moves package-owned runtime files out of foreign Claude settings, whose absolute
36
+ // hook paths caused the package-file-layout incident. Keep the brand root overrideable so tests
37
+ // and later installation steps never need to know the operator's home directory.
38
+ const resolvedBrandRoot = path.resolve(
39
+ brandRoot || process.env.CODEX_BRIDGE_HOME || path.join(homedir, '.lyupro', '.codex-bridge'),
40
+ );
41
+ const brandPaths = {
42
+ brandRoot: resolvedBrandRoot,
43
+ brandHooksDir: path.join(resolvedBrandRoot, 'hooks'),
44
+ brandRunnerDir: path.join(resolvedBrandRoot, 'lib'),
45
+ brandConfigPath: path.join(resolvedBrandRoot, 'config.json'),
46
+ brandConventionsPath: path.join(resolvedBrandRoot, 'conventions.md'),
47
+ brandInstallRecordPath: path.join(resolvedBrandRoot, '.installed.json'),
48
+ };
25
49
  if (host) {
26
50
  const root = path.resolve(host);
27
51
  return {
28
52
  root,
29
- agentsDir: path.join(root, 'agents', 'codex'),
30
- commandsDir: path.join(root, 'commands', 'codex'),
53
+ ...claudePaths(root),
31
54
  codexRulesDir,
32
55
  settingsPath: path.join(root, 'settings.json'),
33
56
  scope: 'host',
57
+ ...brandPaths,
34
58
  };
35
59
  }
36
60
  if (scope !== 'user' && scope !== 'project') {
@@ -41,10 +65,10 @@ export function resolveHost({
41
65
  );
42
66
  return {
43
67
  root,
44
- agentsDir: path.join(root, 'agents', 'codex'),
45
- commandsDir: path.join(root, 'commands', 'codex'),
68
+ ...claudePaths(root),
46
69
  codexRulesDir,
47
70
  settingsPath: path.join(root, 'settings.json'),
48
71
  scope,
72
+ ...brandPaths,
49
73
  };
50
74
  }
@@ -0,0 +1,287 @@
1
+ /** Reads, validates, and writes the installation record shared by installer commands. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { readJsonFile } from '../src/json-file.mjs';
5
+ import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
6
+
7
+ export const INSTALL_RECORD_NAME = '.installed.json';
8
+ export const LEGACY_INSTALL_RECORD_NAME = '.codex-bridge-install.json';
9
+ export const RULES_NAME = 'codex-bridge.rules';
10
+
11
+ const ROOTS = new Set(['claude', 'brand']);
12
+
13
+ const posix = (value) => value.split(path.sep).join('/');
14
+
15
+ export function fileEntry(file) {
16
+ if (typeof file === 'string') return { root: 'claude', path: file };
17
+ // Plan items carry the same root but call the relative field relativeToRoot; normalizing that
18
+ // shape here keeps matching, fingerprint lookup, and display on one record-key contract.
19
+ if (file?.path === undefined && file?.relativeToRoot !== undefined) {
20
+ return { root: file.root, path: file.relativeToRoot };
21
+ }
22
+ return file;
23
+ }
24
+
25
+ export function recordFileKey(file) {
26
+ const entry = fileEntry(file);
27
+ return `${entry.root}:${entry.path}`;
28
+ }
29
+
30
+ export function recordTarget(host, file) {
31
+ const entry = fileEntry(file);
32
+ const root = entry.root === 'brand' ? host.brandRoot : host.root;
33
+ if (!root) throw new Error(`host has no ${entry.root} installation root`);
34
+ return path.join(root, entry.path);
35
+ }
36
+
37
+ export function recordRelativeToHost(host, file) {
38
+ return posix(path.relative(host.root, recordTarget(host, file)));
39
+ }
40
+
41
+ export function definitionForRecordedHook(hook) {
42
+ return HOOK_DEFINITIONS.find((definition) => definition.event === hook?.event
43
+ && path.basename(String(hook?.path ?? '')) === definition.file);
44
+ }
45
+
46
+ function validPath(value, label) {
47
+ if (typeof value !== 'string' || !value) throw new Error(`${label} must be a non-empty string`);
48
+ if (path.isAbsolute(value) || value.split(/[\\/]/).some((part) => part === '..' || part === '.')) {
49
+ throw new Error(`${label} must stay relative to its installation root`);
50
+ }
51
+ return value;
52
+ }
53
+
54
+ function normalizedFiles(record) {
55
+ if (!Array.isArray(record.files) || !record.files.length) {
56
+ throw new Error('installation record files must be a non-empty list');
57
+ }
58
+ const files = record.files.map((file) => {
59
+ const entry = fileEntry(file);
60
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
61
+ throw new Error('installation record files must contain root and path entries');
62
+ }
63
+ if (!ROOTS.has(entry.root)) throw new Error('installation record file root must be claude or brand');
64
+ validPath(entry.path, 'installation record file path');
65
+ // Run artifacts are the user's data, not ours. Refusing them here means neither uninstall nor
66
+ // update needs its own guard against deleting a run folder someone listed as an installed file.
67
+ if (entry.path === 'codex-runs' || entry.path.split(/[\\/]/)[0] === 'codex-runs') {
68
+ throw new Error('installation record files must not name run artifacts under codex-runs');
69
+ }
70
+ return { root: entry.root, path: entry.path };
71
+ });
72
+ if (new Set(files.map(recordFileKey)).size !== files.length) {
73
+ throw new Error('installation record files must not contain duplicates');
74
+ }
75
+ return files;
76
+ }
77
+
78
+ function fingerprintPairs(files, fingerprints) {
79
+ if (!fingerprints || typeof fingerprints !== 'object' || Array.isArray(fingerprints)) {
80
+ throw new Error('installation record fingerprints must be an object');
81
+ }
82
+ const fingerprintKeys = Object.keys(fingerprints);
83
+ const nested = fingerprintKeys.some((key) => ROOTS.has(key));
84
+ const pairs = files.map((file) => [file, nested
85
+ ? fingerprints[file.root]?.[file.path]
86
+ : file.root === 'claude' ? fingerprints[file.path] : undefined]);
87
+ if (!nested) {
88
+ if (files.some((file) => file.root !== 'claude')
89
+ || fingerprintKeys.length !== files.length
90
+ || files.some((file) => !Object.hasOwn(fingerprints, file.path))) {
91
+ throw new Error('installation record fingerprints keys must exactly match files');
92
+ }
93
+ } else {
94
+ const expectedRoots = new Set(files.map((file) => file.root));
95
+ const actualRoots = new Set(fingerprintKeys);
96
+ if (fingerprintKeys.some((root) => !ROOTS.has(root))
97
+ || actualRoots.size !== expectedRoots.size
98
+ || [...actualRoots].some((root) => !expectedRoots.has(root))
99
+ || [...expectedRoots].some((root) => {
100
+ const expected = files.filter((file) => file.root === root).map((file) => file.path);
101
+ const actual = Object.keys(fingerprints[root] ?? {});
102
+ return actual.length !== expected.length || expected.some((file) => !actual.includes(file));
103
+ })) {
104
+ throw new Error('installation record fingerprints keys must exactly match files');
105
+ }
106
+ }
107
+ if (pairs.some(([, fingerprint]) => fingerprint === undefined)) {
108
+ throw new Error('installation record fingerprints keys must exactly match files');
109
+ }
110
+ if (pairs.some(([, fingerprint]) => typeof fingerprint !== 'string'
111
+ || !/^[a-f0-9]{64}$/i.test(fingerprint))) {
112
+ throw new Error('installation record fingerprints must be 64-character hexadecimal SHA256 strings');
113
+ }
114
+ return pairs;
115
+ }
116
+
117
+ function validateHooks(record, files) {
118
+ if (record.hooks !== undefined && record.hook !== undefined) {
119
+ throw new Error('installation record must use hooks instead of hook');
120
+ }
121
+ const hooks = record.hooks !== undefined ? record.hooks : record.hook !== undefined ? [record.hook] : null;
122
+ if (!Array.isArray(hooks) || !hooks.length) {
123
+ throw new Error('installation record hooks must be a non-empty list');
124
+ }
125
+ for (const hook of hooks) {
126
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
127
+ throw new Error('installation record hook must be an object');
128
+ }
129
+ const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
130
+ const entry = fileEntry(hook.root ? hook : { root: 'claude', path: hook.path });
131
+ if (!definitions.length || typeof entry.path !== 'string' || !entry.path) {
132
+ throw new Error('installation record hook must identify a supported event and its path');
133
+ }
134
+ if (!ROOTS.has(entry.root)) throw new Error('installation record hook root must be claude or brand');
135
+ validPath(entry.path, 'installation record hook path');
136
+ const definition = definitions.find((candidate) => path.basename(entry.path) === candidate.file);
137
+ if (!definition || !files.some((file) => recordFileKey(file) === recordFileKey(entry))) {
138
+ const names = definitions.map((candidate) => candidate.file).join(' or ');
139
+ throw new Error(`installation record hook path must name the installed ${names}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ export function validateInstallRecord(record) {
145
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
146
+ throw new Error('installation record must be an object');
147
+ }
148
+ for (const key of ['name', 'version', 'installedAt', 'mode']) {
149
+ if (typeof record[key] !== 'string' || !record[key]) throw new Error(`installation record has invalid ${key}`);
150
+ }
151
+ if (record.mode !== 'copy') throw new Error('installation record mode must be copy');
152
+ if (Number.isNaN(Date.parse(record.installedAt))) throw new Error('installation record installedAt is invalid');
153
+ const files = normalizedFiles(record);
154
+ if (record.fingerprints !== undefined) fingerprintPairs(files, record.fingerprints);
155
+ if (record.rules !== undefined) {
156
+ if (!record.rules || typeof record.rules !== 'object' || Array.isArray(record.rules)) {
157
+ throw new Error('installation record rules must be an object');
158
+ }
159
+ if (typeof record.rules.path !== 'string' || !record.rules.path) {
160
+ throw new Error('installation record rules path must be a non-empty string');
161
+ }
162
+ if (path.basename(record.rules.path) !== RULES_NAME) {
163
+ throw new Error(`installation record rules path must name ${RULES_NAME}`);
164
+ }
165
+ if (typeof record.rules.fingerprint !== 'string'
166
+ || !/^[a-f0-9]{64}$/i.test(record.rules.fingerprint)) {
167
+ throw new Error('installation record rules fingerprint must be a 64-character hexadecimal SHA256 string');
168
+ }
169
+ }
170
+ validateHooks(record, files);
171
+ return record;
172
+ }
173
+
174
+ function normalizedHook(hook) {
175
+ return hook.root ? { ...hook, root: hook.root, path: hook.path }
176
+ : { ...hook, root: 'claude', path: hook.path };
177
+ }
178
+
179
+ function normalizedFingerprints(files, fingerprints) {
180
+ if (fingerprints === undefined) return undefined;
181
+ const pairs = fingerprintPairs(files, fingerprints);
182
+ const result = {};
183
+ for (const [file, fingerprint] of pairs) {
184
+ result[file.root] ??= {};
185
+ result[file.root][file.path] = fingerprint;
186
+ }
187
+ return result;
188
+ }
189
+
190
+ export function normalizeInstallRecord(record) {
191
+ validateInstallRecord(record);
192
+ const files = normalizedFiles(record);
193
+ const hooks = record.hooks !== undefined ? record.hooks : [record.hook];
194
+ const { hook: _legacyHook, ...withoutLegacyHook } = record;
195
+ const normalized = {
196
+ ...withoutLegacyHook,
197
+ files,
198
+ hooks: hooks.map(normalizedHook),
199
+ };
200
+ const fingerprints = normalizedFingerprints(files, record.fingerprints);
201
+ if (fingerprints !== undefined) normalized.fingerprints = fingerprints;
202
+ return normalized;
203
+ }
204
+
205
+ function legacySeedEntries(host) {
206
+ const agentDirs = [host.agentsDir, host.legacyAgentsDir].filter(Boolean);
207
+ return new Set(agentDirs.flatMap((agentsDir) => [
208
+ { root: 'claude', path: posix(path.relative(host.root, path.join(agentsDir, 'run-config.json'))) },
209
+ { root: 'claude', path: posix(path.relative(host.root, path.join(agentsDir, 'conventions.md'))) },
210
+ ]).map(recordFileKey));
211
+ }
212
+
213
+ function newSeedEntries() {
214
+ return new Set(['brand:config.json', 'brand:conventions.md']);
215
+ }
216
+
217
+ function withoutSeededFiles(host, record) {
218
+ const seeded = new Set([...legacySeedEntries(host), ...newSeedEntries()]);
219
+ record.files = record.files.filter((file) => !seeded.has(recordFileKey(file)));
220
+ if (record.fingerprints) {
221
+ const fingerprints = {};
222
+ for (const file of record.files) {
223
+ const value = record.fingerprints[file.root]?.[file.path];
224
+ if (value !== undefined) {
225
+ fingerprints[file.root] ??= {};
226
+ fingerprints[file.root][file.path] = value;
227
+ }
228
+ }
229
+ record.fingerprints = fingerprints;
230
+ }
231
+ return record;
232
+ }
233
+
234
+ async function readAt(recordPath) {
235
+ try {
236
+ return await readJsonFile(recordPath);
237
+ } catch (err) {
238
+ if (err.code === 'ENOENT') return null;
239
+ if (err.cause) {
240
+ throw new Error(`invalid installation record JSON: ${err.cause.message}`, { cause: err.cause });
241
+ }
242
+ throw err;
243
+ }
244
+ }
245
+
246
+ export function installRecordPath(host) {
247
+ return host.brandInstallRecordPath || path.join(host.brandRoot, INSTALL_RECORD_NAME);
248
+ }
249
+
250
+ export function legacyInstallRecordPath(host) {
251
+ return path.join(host.legacyAgentsDir, LEGACY_INSTALL_RECORD_NAME);
252
+ }
253
+
254
+ export async function readInstallRecord(host) {
255
+ const parsed = await readAt(installRecordPath(host));
256
+ const legacy = parsed === null ? await readAt(legacyInstallRecordPath(host)) : null;
257
+ if (parsed === null && legacy === null) return null;
258
+ return withoutSeededFiles(host, normalizeInstallRecord(parsed ?? legacy));
259
+ }
260
+
261
+ export async function writeInstallRecord(host, record) {
262
+ const normalized = normalizeInstallRecord(record);
263
+ const target = installRecordPath(host);
264
+ await fs.mkdir(path.dirname(target), { recursive: true });
265
+ await fs.writeFile(target, `${JSON.stringify(normalized, null, 2)}\n`);
266
+ }
267
+
268
+ export function fingerprintFor(record, file) {
269
+ const entry = fileEntry(file);
270
+ if (!record?.fingerprints) return undefined;
271
+ if (record.fingerprints[entry.root]?.[entry.path] !== undefined) {
272
+ return record.fingerprints[entry.root][entry.path];
273
+ }
274
+ return entry.root === 'claude' ? record.fingerprints[entry.path] : undefined;
275
+ }
276
+
277
+ export function recordMatchesPackage(record, plan, currentPackage, fingerprints, ruleState) {
278
+ return Boolean(record)
279
+ && record.name === currentPackage.name
280
+ && record.version === currentPackage.version
281
+ && record.files.length === plan.length
282
+ && record.files.every((file, index) => recordFileKey(file) === recordFileKey(plan[index]))
283
+ && Boolean(record.fingerprints)
284
+ && plan.every((item) => fingerprintFor(record, item) === fingerprints.get(recordFileKey(item)))
285
+ && (!ruleState || (record.rules?.path === ruleState.path
286
+ && record.rules.fingerprint === ruleState.fingerprint));
287
+ }
package/cli/install.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /** Installs codex-bridge files and its host hook with conflict-safe idempotency. */
1
+ /** Installs codex-bridge files into the Claude and brand roots with conflict-safe idempotency. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import {
@@ -7,6 +7,7 @@ import {
7
7
  HOOK_DEFINITIONS,
8
8
  installedHookPath,
9
9
  packageInfo,
10
+ recordFileKey,
10
11
  readInstallRecord,
11
12
  recordMatchesPackage,
12
13
  rulesPlan,
@@ -14,7 +15,15 @@ import {
14
15
  writeInstallRecord,
15
16
  } from './manifest.mjs';
16
17
  import { copyPlannedFile, targetMatches } from './copy.mjs';
17
- import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
18
+ import { hookTargets } from './hook-targets.mjs';
19
+ import { fingerprintFor } from './install-record.mjs';
20
+ import {
21
+ commandFor,
22
+ hookRegistration,
23
+ inspectHook,
24
+ mergeHook,
25
+ withSettingsRun,
26
+ } from './settings-merge.mjs';
18
27
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
19
28
  import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
20
29
 
@@ -31,26 +40,14 @@ async function targetExists(target) {
31
40
  }
32
41
  }
33
42
 
34
- function hookTargets(host) {
35
- return HOOK_DEFINITIONS.map((definition) => {
36
- const target = installedHookPath(host, definition);
37
- return {
38
- definition,
39
- target,
40
- relative: path.relative(host.root, target).split(path.sep).join('/'),
41
- spec: { event: definition.event, matcher: definition.matcher, command: commandFor(target) },
42
- };
43
- });
44
- }
45
-
46
43
  function recordHasHooks(record, targets) {
47
44
  return Boolean(record)
48
45
  && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
49
- hook.event === definition.event && hook.path === relative));
46
+ hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
50
47
  }
51
48
 
52
49
  function retentionLine(host) {
53
- const notice = retentionNotice(readRunConfig(path.join(host.agentsDir, 'run-config.json')));
50
+ const notice = retentionNotice(readRunConfig(host.brandConfigPath));
54
51
  return notice.enabled ? `${WARNING}${notice.text}${RESET}` : notice.text;
55
52
  }
56
53
 
@@ -58,7 +55,24 @@ function retentionOutput(line, output) {
58
55
  return `${output}\n${line}`;
59
56
  }
60
57
 
61
- export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
58
+ async function migrateLegacySeed(host, seed) {
59
+ if (await targetExists(seed.target)) return;
60
+ const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
61
+ const legacy = path.join(host.legacyAgentsDir, legacyName);
62
+ if (legacy === seed.target || !(await targetExists(legacy))) return;
63
+ // Existing seeded files hold operator decisions, so Plan_25 moves them rather than reading a
64
+ // fresh default: copy first, compare the bytes, and only then drop the old path. Leaving the old
65
+ // copy behind — the first version of this migration did — keeps agents/codex non-empty forever,
66
+ // so the previous layout is never taken down and the operator goes on editing a file nothing
67
+ // reads. If the comparison fails the old file stays: a half-copied config is the one case where
68
+ // having two is better than having none.
69
+ await fs.mkdir(path.dirname(seed.target), { recursive: true });
70
+ await fs.copyFile(legacy, seed.target);
71
+ if (await fileFingerprint(legacy) !== await fileFingerprint(seed.target)) return;
72
+ await fs.rm(legacy, { force: true });
73
+ }
74
+
75
+ async function installInRun({ host, dryRun = false, force = false, packageRoot, env = process.env } = {}) {
62
76
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
63
77
  await readRulesRegistry(host);
64
78
  const configuredRetentionLine = retentionLine(host);
@@ -66,9 +80,9 @@ export async function install({ host, dryRun = false, force = false, packageRoot
66
80
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
67
81
  const currentPackage = await packageInfo(packageRoot);
68
82
  const record = await readInstallRecord(host);
69
- const targets = hookTargets(host);
83
+ const targets = hookTargets(host, env, currentPackage.version);
70
84
  for (const target of targets) {
71
- if (!plan.some((item) => item.relativeToHost === target.relative)) {
85
+ if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
72
86
  throw new Error(`install plan does not contain hooks/${target.definition.file}`);
73
87
  }
74
88
  }
@@ -77,24 +91,24 @@ export async function install({ host, dryRun = false, force = false, packageRoot
77
91
  const states = await Promise.all(plan.map(async (item) => ({
78
92
  item,
79
93
  exists: await targetExists(item.target),
80
- matches: await targetMatches(item, host.agentsDir),
94
+ matches: await targetMatches(item, host.brandRoot),
81
95
  fingerprint: await fileFingerprint(item.target),
82
96
  })));
83
97
  const ruleState = {
84
98
  item: rule,
85
99
  exists: await targetExists(rule.target),
86
- matches: await targetMatches(rule, host.agentsDir),
100
+ matches: await targetMatches(rule, host.brandRoot),
87
101
  fingerprint: await fileFingerprint(rule.target),
88
102
  };
89
103
  const conflicts = [
90
104
  ...states.filter((state) => state.exists && !state.matches
91
105
  && (!record || (record.fingerprints
92
- && record.fingerprints[state.item.relativeToHost] !== state.fingerprint))),
106
+ && fingerprintFor(record, state.item) !== state.fingerprint))),
93
107
  ...(ruleState.exists && !ruleState.matches
94
108
  && (!record?.rules || record.rules.fingerprint !== ruleState.fingerprint) ? [ruleState] : []),
95
109
  ];
96
110
  if (conflicts.length && !force) {
97
- const files = conflicts.map(({ item }) => ` ${item.relativeToHost || item.name}`).join('\n');
111
+ const files = conflicts.map(({ item }) => ` ${item.relativeToRoot || item.name}`).join('\n');
98
112
  return {
99
113
  exitCode: 1,
100
114
  output: retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`),
@@ -104,7 +118,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
104
118
  const changedFiles = states.filter((state) => !state.matches);
105
119
  const changedRule = !ruleState.matches;
106
120
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
107
- new Map(states.map((state) => [state.item.relativeToHost, state.fingerprint])),
121
+ new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
108
122
  { path: rule.target, fingerprint: ruleState.fingerprint });
109
123
  if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
110
124
  && recordHasHooks(record, targets) && sameRecord) {
@@ -114,17 +128,17 @@ export async function install({ host, dryRun = false, force = false, packageRoot
114
128
 
115
129
  if (dryRun) {
116
130
  const lines = changedFiles.map(({ item, exists }) =>
117
- `${exists ? 'Would overwrite' : 'Would create'} ${item.relativeToHost}`);
131
+ `${exists ? 'Would overwrite' : 'Would create'} ${item.root}/${item.relativeToRoot}`);
118
132
  if (changedRule) {
119
133
  lines.push(`${ruleState.exists ? 'Would overwrite' : 'Would create'} ${rule.target}`);
120
134
  }
121
135
  inspectedHooks.forEach((state, index) => {
122
- const { definition } = targets[index];
136
+ const { definition, registration } = targets[index];
123
137
  lines.push(state.present
124
- ? `${definition.event} hook is already registered.`
125
- : `Would register ${definition.event} hook for matcher ${definition.matcher}.`);
138
+ ? `${definition.event} hook is already registered (${registration.form} command).`
139
+ : `Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
126
140
  });
127
- lines.push('Would write installation record.');
141
+ lines.push('Would write installation record in the brand root.');
128
142
  return { exitCode: 0, output: retentionOutput(configuredRetentionLine, lines.join('\n')) };
129
143
  }
130
144
 
@@ -132,28 +146,42 @@ export async function install({ host, dryRun = false, force = false, packageRoot
132
146
  // failure at that step left a fully installed host absent from the registry, and the next
133
147
  // uninstall elsewhere would then delete the rules out from under it.
134
148
  await addRulesOwner(host);
135
- for (const { item } of changedFiles) await copyPlannedFile(item, host.agentsDir);
136
- if (changedRule) await copyPlannedFile(rule, host.agentsDir);
149
+ for (const { item } of changedFiles) await copyPlannedFile(item, host.brandRoot);
150
+ if (changedRule) await copyPlannedFile(rule, host.brandRoot);
137
151
  // Seeded files are written once and then belong to the operator: an existing one is left
138
152
  // exactly as it is, including under --force, because --force is about our files, not theirs.
139
153
  for (const seed of seedPlan(host, packageRoot)) {
140
- if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.agentsDir);
154
+ if (!(await targetExists(seed.target))) {
155
+ await migrateLegacySeed(host, seed);
156
+ if (!(await targetExists(seed.target))) await copyPlannedFile(seed, host.brandRoot);
157
+ }
141
158
  }
142
159
  const hookResults = [];
143
160
  for (const { spec } of targets) hookResults.push(await mergeHook(host.settingsPath, spec));
144
- const fingerprints = Object.fromEntries(await Promise.all(plan.map(async (item) =>
145
- [item.relativeToHost, await fileFingerprint(item.target)])));
146
- const hooks = targets.map(({ definition, relative }, index) => {
147
- const prior = record?.hooks?.find((hook) => hook.event === definition.event);
161
+ const fingerprints = {};
162
+ for (const item of plan) {
163
+ fingerprints[item.root] ??= {};
164
+ fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
165
+ }
166
+ const hooks = targets.map(({ definition, relative, registration }, index) => {
167
+ const prior = record?.hooks?.find((hook) => hook.event === definition.event
168
+ && hook.root === 'brand' && hook.path === relative);
148
169
  const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
149
- return createdGroup ? { event: definition.event, path: relative, createdGroup: true }
150
- : { event: definition.event, path: relative };
170
+ const command = inspectedHooks[index].matchedCommand || registration.command;
171
+ return {
172
+ event: definition.event,
173
+ root: 'brand',
174
+ path: relative,
175
+ command,
176
+ form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
177
+ ...(createdGroup ? { createdGroup: true } : {}),
178
+ };
151
179
  });
152
180
  await writeInstallRecord(host, {
153
181
  ...currentPackage,
154
182
  installedAt: new Date().toISOString(),
155
183
  mode: 'copy',
156
- files: plan.map((item) => item.relativeToHost),
184
+ files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
157
185
  fingerprints,
158
186
  rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
159
187
  hooks,
@@ -163,3 +191,9 @@ export async function install({ host, dryRun = false, force = false, packageRoot
163
191
  output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
164
192
  };
165
193
  }
194
+
195
+ export async function install(options = {}) {
196
+ const host = options?.host;
197
+ if (!host?.settingsPath) return installInRun(options);
198
+ return withSettingsRun(host.settingsPath, () => installInRun(options));
199
+ }
@@ -0,0 +1,37 @@
1
+ /** Decides whether this process was started as the CLI or merely imported by another module. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ /**
7
+ * Resolves a path through symlinks, falling back to plain resolution when that is impossible.
8
+ *
9
+ * This is the one place in the package where symlinks are deliberately resolved. Everywhere else
10
+ * they are not — realpath returns `\\?\` and UNC spellings, and host paths are compared as written.
11
+ * Here the question is not which path a file has but whether two paths name the same file, and
12
+ * both sides go through the same function, so the exotic spellings match each other.
13
+ */
14
+ function sameFileKey(target) {
15
+ try {
16
+ return fs.realpathSync(target);
17
+ } catch {
18
+ return path.resolve(target);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * True when argv[1] names this very module.
24
+ *
25
+ * Comparing the two paths as written looked equivalent until `npm i -g .` linked the global
26
+ * package at the clone: argv[1] then held the path inside the global node_modules while
27
+ * import.meta.url held the clone, because Node resolves the module's own URL through the link and
28
+ * argv[1] keeps the spelling used to start it. The comparison failed, main() never ran, and the
29
+ * CLI exited 0 having printed nothing. That silence was the dangerous part — every guard is a hook
30
+ * that must print a refusal to refuse anything, so five guards went on reporting `[ok]` in doctor
31
+ * while permitting everything the operator installed them to stop (2026-08-11).
32
+ */
33
+ export function isInvokedDirectly(argv1, moduleUrl) {
34
+ if (!argv1 || !moduleUrl) return false;
35
+ const modulePath = moduleUrl.startsWith('file:') ? fileURLToPath(moduleUrl) : moduleUrl;
36
+ return sameFileKey(argv1) === sameFileKey(modulePath);
37
+ }