@lyupro/codex-bridge 0.6.8 → 0.6.10

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 (128) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +6 -4
  3. package/bin/codex-bridge.mjs +35 -177
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +3 -2
  8. package/cli/doctor-host-side.mjs +110 -0
  9. package/cli/doctor.mjs +17 -1
  10. package/cli/home-inspection.mjs +122 -0
  11. package/cli/home-plan-execute.mjs +140 -0
  12. package/cli/home-removal-plan.mjs +61 -0
  13. package/cli/hook-recognizer.mjs +78 -0
  14. package/cli/host-contract.mjs +11 -5
  15. package/cli/host-inspection.mjs +156 -0
  16. package/cli/host-removal.mjs +91 -0
  17. package/cli/image-evidence.mjs +44 -0
  18. package/cli/install-owners.mjs +244 -0
  19. package/cli/install-record.mjs +71 -9
  20. package/cli/install.mjs +88 -75
  21. package/cli/inventory-removal.mjs +84 -0
  22. package/cli/inventory-transition.mjs +66 -0
  23. package/cli/lifecycle-lock-clear.mjs +104 -0
  24. package/cli/lifecycle-lock-inspect.mjs +92 -0
  25. package/cli/lifecycle-lock.mjs +222 -0
  26. package/cli/lifecycle-transaction.mjs +57 -0
  27. package/cli/link-segments.mjs +45 -0
  28. package/cli/model-set.mjs +4 -3
  29. package/cli/model-speed.mjs +9 -2
  30. package/cli/model.mjs +5 -3
  31. package/cli/owner-record.mjs +40 -0
  32. package/cli/owner-sync-apply.mjs +108 -0
  33. package/cli/owner-sync.mjs +115 -0
  34. package/cli/projects.mjs +5 -2
  35. package/cli/prune-args.mjs +9 -4
  36. package/cli/prune.mjs +23 -72
  37. package/cli/purge-live-runs.mjs +98 -0
  38. package/cli/purge-preflight.mjs +103 -0
  39. package/cli/read.mjs +22 -1
  40. package/cli/record-removal.mjs +43 -0
  41. package/cli/removal-outcomes.mjs +115 -0
  42. package/cli/removal-plan.mjs +187 -0
  43. package/cli/remove-layout.mjs +40 -1
  44. package/cli/rules-owners.mjs +4 -2
  45. package/cli/settings-merge.mjs +3 -29
  46. package/cli/terminal-question.mjs +41 -0
  47. package/cli/uninstall-purge.mjs +139 -0
  48. package/cli/uninstall-rules.mjs +72 -0
  49. package/cli/uninstall.mjs +208 -127
  50. package/cli/unlock-lifecycle.mjs +102 -0
  51. package/cli/unlock.mjs +7 -4
  52. package/cli/update.mjs +102 -27
  53. package/package.json +4 -3
  54. package/scripts/home-write-audit.mjs +286 -0
  55. package/scripts/home-write-inventory.mjs +59 -0
  56. package/scripts/home-write-lexer.mjs +182 -0
  57. package/scripts/isolated-roots.mjs +6 -1
  58. package/scripts/open-work/check.mjs +180 -0
  59. package/scripts/open-work/live-thresholds.mjs +155 -0
  60. package/scripts/open-work/register.mjs +56 -0
  61. package/scripts/root-guard/root-entries.mjs +15 -0
  62. package/scripts/run-tests.mjs +26 -1
  63. package/src/agents/codex-advisor.md +23 -10
  64. package/src/agents/codex-build.md +24 -14
  65. package/src/agents/codex-review.md +17 -10
  66. package/src/agents/codex-scout.md +22 -12
  67. package/src/home/conventions.md +9 -0
  68. package/src/home/hooks/guard-tries.mjs +6 -4
  69. package/src/home/hooks/order-gate.mjs +27 -19
  70. package/src/home/hooks/reply-guard.mjs +2 -7
  71. package/src/home/hooks/reply-parser.mjs +1 -1
  72. package/src/home/hooks/worktree-witness.mjs +6 -6
  73. package/src/home/lib/agents.mjs +1 -1
  74. package/src/home/lib/atomic-json.mjs +24 -6
  75. package/src/home/lib/command-path.mjs +32 -0
  76. package/src/home/lib/config-edit.mjs +25 -10
  77. package/src/home/lib/dispatcher-command.mjs +4 -2
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +7 -4
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +7 -4
  85. package/src/home/lib/kernel-lock.mjs +171 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/chain.mjs +19 -8
  89. package/src/home/lib/meta/context-only.mjs +43 -0
  90. package/src/home/lib/meta/events.mjs +16 -3
  91. package/src/home/lib/meta/launch-rows.mjs +23 -0
  92. package/src/home/lib/meta/paths.mjs +24 -24
  93. package/src/home/lib/meta/reply.mjs +37 -3
  94. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  95. package/src/home/lib/meta/run-state.mjs +3 -0
  96. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  97. package/src/home/lib/meta/verdict.mjs +30 -24
  98. package/src/home/lib/required-inputs.mjs +48 -8
  99. package/src/home/lib/run-codex.mjs +6 -3
  100. package/src/home/lib/run-config.mjs +10 -1
  101. package/src/home/lib/runner/advise-carry.mjs +2 -1
  102. package/src/home/lib/runner/args.mjs +16 -5
  103. package/src/home/lib/runner/attach.mjs +21 -14
  104. package/src/home/lib/runner/build-evidence.mjs +29 -0
  105. package/src/home/lib/runner/codex-availability.mjs +50 -0
  106. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  107. package/src/home/lib/runner/continuation.mjs +60 -8
  108. package/src/home/lib/runner/exit-codes.mjs +13 -0
  109. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  110. package/src/home/lib/runner/flag-scan.mjs +165 -0
  111. package/src/home/lib/runner/git-paths.mjs +74 -0
  112. package/src/home/lib/runner/git-state.mjs +37 -66
  113. package/src/home/lib/runner/launcher.mjs +233 -273
  114. package/src/home/lib/runner/order-claim.mjs +84 -0
  115. package/src/home/lib/runner/order-owner.mjs +18 -0
  116. package/src/home/lib/runner/pass-gate.mjs +109 -0
  117. package/src/home/lib/runner/preflight.mjs +22 -18
  118. package/src/home/lib/runner/prompts.mjs +42 -4
  119. package/src/home/lib/runner/question-kind.mjs +41 -0
  120. package/src/home/lib/runner/run-env.mjs +2 -1
  121. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  122. package/src/home/lib/runner/schemas.mjs +4 -1
  123. package/src/home/lib/runner/scope-check.mjs +55 -28
  124. package/src/home/lib/runner/task-input.mjs +11 -2
  125. package/src/home/lib/runner/worker-admission.mjs +43 -0
  126. package/src/home/lib/runner/worker.mjs +19 -18
  127. package/src/home/lib/task-header.mjs +127 -0
  128. package/src/home/lib/write-meta.mjs +17 -5
package/cli/model-set.mjs CHANGED
@@ -5,6 +5,7 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
5
5
  import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
6
6
  import { editRunConfig } from '../src/home/lib/config-edit.mjs';
7
7
  import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
8
+ import { withCommandHelp } from './command-help.mjs';
8
9
 
9
10
  const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
10
11
  const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -17,14 +18,14 @@ function parseChanges(args) {
17
18
  let value = argument;
18
19
  if (argument.startsWith('-')) {
19
20
  if (argument !== '--model' && argument !== '--effort') {
20
- throw new Error(`unexpected argument "${argument}".`);
21
+ throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
21
22
  }
22
23
  field = argument.slice(2);
23
24
  value = args[++index];
24
25
  if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${argument}.`);
25
26
  } else {
26
27
  field = ['model', 'effort'].find((key) => changes[key] === undefined);
27
- if (!field) throw new Error(`unexpected argument "${argument}".`);
28
+ if (!field) throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
28
29
  }
29
30
  if (changes[field] !== undefined) throw new Error(`${field} was supplied more than once.`);
30
31
  if (!value || /\s/.test(value)) {
@@ -65,7 +66,7 @@ export async function editModelProfile(action, argv, options) {
65
66
  }
66
67
  let changes;
67
68
  try {
68
- if (action === 'unset' && args.length) throw new Error(`unexpected argument "${args[0]}".`);
69
+ if (action === 'unset' && args.length) throw new Error(withCommandHelp('model unset', `unexpected argument "${args[0]}".`));
69
70
  if (action === 'set') changes = parseChanges(args);
70
71
  } catch (error) {
71
72
  return failure(2, error.message);
@@ -5,12 +5,19 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
5
5
  import { editRunConfig } from '../src/home/lib/config-edit.mjs';
6
6
  import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
7
7
  import { describe, readProfiles } from './model-set.mjs';
8
+ import { withCommandHelp } from './command-help.mjs';
8
9
 
9
10
  const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
10
11
  const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
12
+ const unexpected = (argument) => {
13
+ const result = failure(2, `unexpected argument "${argument}".`);
14
+ return { ...result, output: withCommandHelp('model speed', result.output) };
15
+ };
11
16
 
12
17
  export async function editModelSpeed(argv, options) {
13
18
  const [role, speed, confirmation, ...extra] = argv;
19
+ const dashArgument = argv.find((arg) => typeof arg === 'string' && arg.startsWith('-'));
20
+ if (dashArgument !== undefined) return unexpected(dashArgument);
14
21
  if (!ROLES.includes(role)) {
15
22
  return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
16
23
  }
@@ -18,9 +25,9 @@ export async function editModelSpeed(argv, options) {
18
25
  return failure(2, 'speed requires a tier identifier: a non-empty single word with no whitespace, or unset.');
19
26
  }
20
27
  if (confirmation !== undefined && (speed === 'unset' || confirmation !== 'confirm')) {
21
- return failure(2, `unexpected argument "${confirmation}".`);
28
+ return unexpected(confirmation);
22
29
  }
23
- if (extra.length) return failure(2, `unexpected argument "${extra[0]}".`);
30
+ if (extra.length) return unexpected(extra[0]);
24
31
 
25
32
  try {
26
33
  const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
package/cli/model.mjs CHANGED
@@ -8,6 +8,7 @@ import { editModelProfile } from './model-set.mjs';
8
8
  import { editModelSpeed } from './model-speed.mjs';
9
9
  import { readCodexUserTier } from './codex-user-config.mjs';
10
10
  import { renderTable } from './table.mjs';
11
+ import { withCommandHelp } from './command-help.mjs';
11
12
 
12
13
  const PROFILE_COLUMNS = ['role', 'model', 'effort', 'speed', 'source']
13
14
  .map((key) => ({ key, header: key, fixed: true }));
@@ -43,10 +44,10 @@ export async function model(argv = [], options = {}) {
43
44
  }
44
45
  if (action === 'speed') return editModelSpeed(optionArgs, options);
45
46
  if (action && action !== 'list') {
46
- return { exitCode: 2, output: `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.` };
47
+ return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.`) };
47
48
  }
48
49
  if (optionArgs.length) {
49
- return { exitCode: 2, output: `codex-bridge model: unexpected argument "${optionArgs[0]}".` };
50
+ return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unexpected argument "${optionArgs[0]}".`) };
50
51
  }
51
52
 
52
53
  try {
@@ -75,7 +76,8 @@ export async function model(argv = [], options = {}) {
75
76
  return {
76
77
  exitCode: 0,
77
78
  output: `${renderTable(PROFILE_COLUMNS, rows, options.terminalWidth)}\n\nConfig file: ${configPath}\n`
78
- + 'Machine-wide: shared by every project on this machine, not per-project.',
79
+ + 'Machine-wide: shared by every project on this machine, not per-project.\n'
80
+ + 'Set: codex-bridge model set <role> <model> [effort] · catalogue: codex-bridge model list',
79
81
  };
80
82
  } catch (error) {
81
83
  const subject = action === 'list' ? 'live catalogue unavailable; refusing to list models' : 'cannot read profile';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Builds one owner's installation-record row from what is on disk after its files and hooks were written.
3
+ * Install and the owner sync (Plan_65 D7) both advance a row; two copies of this construction would let
4
+ * a synced owner carry fingerprints or `createdGroup` flags that its own install would never write.
5
+ */
6
+ import { fileFingerprint } from './manifest.mjs';
7
+ import { INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
8
+
9
+ export async function ownerRecord({ plan, rule, targets, hookResults, prior, currentPackage }) {
10
+ const fingerprints = {};
11
+ for (const item of plan) {
12
+ fingerprints[item.root] ??= {};
13
+ fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
14
+ }
15
+ const hooks = targets.map(({ definition, relative, registration }, index) => {
16
+ const recorded = prior?.hooks?.find((hook) => hook.event === definition.event
17
+ && hook.root === 'brand' && hook.path === relative);
18
+ // A group we created stays ours after a later run merely joins it; losing the flag would make
19
+ // uninstall leave behind an empty group that only this package ever put there.
20
+ const createdGroup = hookResults[index].createdGroup || recorded?.createdGroup === true;
21
+ const command = registration.command;
22
+ return {
23
+ event: definition.event,
24
+ root: 'brand',
25
+ path: relative,
26
+ command,
27
+ form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
28
+ ...(createdGroup ? { createdGroup: true } : {}),
29
+ };
30
+ });
31
+ return {
32
+ ...currentPackage,
33
+ installedAt: new Date().toISOString(),
34
+ [INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
35
+ files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
36
+ fingerprints,
37
+ rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
38
+ hooks,
39
+ };
40
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Applies other owners' host updates under the initiator's lifecycle lock (Plan_65 D7, advice A6).
3
+ * Replacing the shared image must bring eligible owners' files and hooks along, without extending
4
+ * the initiator's force to their edits, permissions, seeds, or Codex rules registry. An owner row
5
+ * advances only after its whole host update verifies; failures must not stop the remaining owners.
6
+ * In-sync rows are stamped with the image fingerprint too (D11): clones of one version ship different
7
+ * images (A6), so only the stamp tells doctor which image a host was last verified against.
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
11
+ import { buildInstallPlan, packageInfo, rulesPlan } from './manifest.mjs';
12
+ import { copyPlannedFile, planHomeWriter } from './copy.mjs';
13
+ import { readInstallRecordFile, writeInstallRecord } from './install-record.mjs';
14
+ import { imageFingerprint, ownerView } from './install-owners.mjs';
15
+ import { ownerRecord } from './owner-record.mjs';
16
+ import { ownerHost, ownerSyncLines, planOwner, planOwnerSync } from './owner-sync.mjs';
17
+ import { mergeHook } from './settings-merge.mjs';
18
+
19
+ async function applyOwner(owner, initiator, { packageRoot, env }) {
20
+ const host = ownerHost(owner, initiator);
21
+ // D7: a root lost after planning must not be recreated by the copy or settings writers.
22
+ if (!(await fs.stat(host.root)).isDirectory()) throw new Error('owner root is not a directory');
23
+ const record = await readInstallRecordFile(host);
24
+ const prior = ownerView(record, host);
25
+ if (!prior) throw new Error('owner is not recorded');
26
+ const plan = await buildInstallPlan(host, packageRoot);
27
+ const { writer } = planHomeWriter(host.brandRoot, plan);
28
+ let files = 0;
29
+ for (const { item, state } of owner.files) {
30
+ if (state !== 'replace' && state !== 'create') continue;
31
+ await copyPlannedFile(item, host.brandRoot, { writer });
32
+ files += 1;
33
+ }
34
+ const hookResults = [];
35
+ let hooks = 0;
36
+ for (const { item, state } of owner.hooks) {
37
+ if (state === 'register') {
38
+ hookResults.push(await mergeHook(host.settingsPath, item.spec));
39
+ hooks += 1;
40
+ } else {
41
+ hookResults.push({ createdGroup: false });
42
+ }
43
+ }
44
+ const verified = await planOwner(record, record.owners[normalizeRepoPath(host.root)], initiator,
45
+ { packageRoot, env });
46
+ // A bare status word made the operator line read "Left <root> behind: conflict", naming no cause.
47
+ if (verified.status !== 'in-sync') throw new Error(`verification after the update found ${verified.status}`);
48
+ // D7: resolving an explicit root must not relabel a recorded user/project owner as host.
49
+ await writeInstallRecord({ ...host, scope: owner.scope }, await ownerRecord({
50
+ plan,
51
+ rule: rulesPlan(host, packageRoot),
52
+ targets: owner.hooks.map(({ item }) => item),
53
+ hookResults,
54
+ prior,
55
+ currentPackage: await packageInfo(packageRoot),
56
+ }), {});
57
+ return { root: owner.root, status: 'updated', files, hooks };
58
+ }
59
+
60
+ export async function applyOwnerSync(plan, initiator, { packageRoot, env } = {}) {
61
+ const record = await readInstallRecordFile(initiator);
62
+ const fingerprint = imageFingerprint(record.image);
63
+ const owners = [];
64
+ for (const owner of plan.owners) {
65
+ if (owner.status === 'in-sync'
66
+ // An image without fingerprints yields null; a row can then never carry a stamp to match.
67
+ && (record.owners[normalizeRepoPath(owner.root)]?.imageFingerprint ?? null) === fingerprint) {
68
+ owners.push({ root: owner.root, status: 'in-sync' });
69
+ } else if (owner.status !== 'eligible' && owner.status !== 'in-sync') {
70
+ owners.push(owner);
71
+ } else {
72
+ try {
73
+ const applied = await applyOwner(owner, initiator, { packageRoot, env });
74
+ owners.push(owner.status === 'in-sync' && applied.files === 0 && applied.hooks === 0
75
+ ? { root: owner.root, status: 'in-sync' } : applied);
76
+ } catch (error) {
77
+ owners.push({ root: owner.root, status: 'failed', reason: error.message });
78
+ }
79
+ }
80
+ }
81
+ return { owners, complete: owners.every(({ status }) => status === 'in-sync' || status === 'updated') };
82
+ }
83
+
84
+ /**
85
+ * One entrance for every command that replaced the image: install and update both call it, so neither
86
+ * can sync the other owners differently. A dry run only plans; it never counts as incomplete.
87
+ */
88
+ export async function syncOtherOwners(initiator, { packageRoot, env, dryRun = false } = {}) {
89
+ const record = await readInstallRecordFile(initiator);
90
+ if (record?.format !== 2) return { complete: true, lines: [] };
91
+ const plan = await planOwnerSync(record, initiator, { packageRoot, env });
92
+ if (dryRun) return { complete: true, lines: ownerSyncLines(plan) };
93
+ // Plan_65 D7: the initiator's force never authorizes another owner's edits.
94
+ const result = await applyOwnerSync(plan, initiator, { packageRoot, env });
95
+ return { complete: result.complete, lines: ownerApplyLines(result) };
96
+ }
97
+
98
+ export function ownerApplyLines(result) {
99
+ return result.owners.flatMap((owner) => {
100
+ if (owner.status === 'updated') {
101
+ return [`Updated ${owner.files} file(s) and ${owner.hooks} hook(s) of ${owner.root}.`];
102
+ }
103
+ if (owner.status === 'failed') {
104
+ return [`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}".`];
105
+ }
106
+ return ownerSyncLines({ owners: [owner] });
107
+ });
108
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Plans other owners' host updates without writes or locks (Plan_65 D7, advice A6).
3
+ * Replacing the shared image leaves their agents, commands, and guards behind. Only an owner's
4
+ * recorded, unedited files may catch up; the initiator's force never authorizes another owner's
5
+ * conflicts, permissions, seeds, or unrelated settings. B13e2 applies and verifies this plan.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
10
+ import { resolveHost } from './hosts.mjs';
11
+ import { buildInstallPlan, fileFingerprint } from './manifest.mjs';
12
+ import { targetMatches } from './copy.mjs';
13
+ import { fingerprintFor, recordFileKey } from './install-record.mjs';
14
+ import { ownerView, validateFormat2 } from './install-owners.mjs';
15
+ import { hookTargets } from './hook-targets.mjs';
16
+ import { inspectHook } from './settings-merge.mjs';
17
+
18
+ async function fileState(item, host, view) {
19
+ let target;
20
+ try {
21
+ target = await fs.lstat(item.target);
22
+ } catch (error) {
23
+ if (error.code !== 'ENOENT') throw error;
24
+ }
25
+ // D7: lstat must precede content reads, including for dangling links and Windows junctions.
26
+ if (target?.isSymbolicLink()) return { item, state: 'conflict', reason: 'link' };
27
+ if (!target) {
28
+ const recorded = view.files.some((file) => recordFileKey(file) === recordFileKey(item));
29
+ return recorded
30
+ ? { item, state: 'conflict', reason: 'missing' }
31
+ : { item, state: 'create' };
32
+ }
33
+ if (target.isFile()) {
34
+ if (await targetMatches(item, host.brandRoot)) return { item, state: 'current' };
35
+ const recorded = fingerprintFor(view, item);
36
+ if (recorded !== undefined && await fileFingerprint(item.target) === recorded) {
37
+ return { item, state: 'replace' };
38
+ }
39
+ }
40
+ return { item, state: 'conflict', reason: 'changed' };
41
+ }
42
+
43
+ /** The owner's host seen from the initiator: same home and Codex home, the owner's own root and scope. */
44
+ export function ownerHost(owner, initiator) {
45
+ return resolveHost({
46
+ host: owner.root,
47
+ scope: owner.scope,
48
+ brandRoot: initiator.brandRoot,
49
+ // A resolved host has no codexHome field; it keeps the Codex home only as `<home>/rules`.
50
+ codexHome: path.dirname(initiator.codexRulesDir),
51
+ });
52
+ }
53
+
54
+ export async function planOwner(record2, owner, initiator, { packageRoot, env }) {
55
+ const entry = { root: owner.root, scope: owner.scope, version: owner.version, files: [], hooks: [] };
56
+ const host = ownerHost(owner, initiator);
57
+ try {
58
+ if (!(await fs.stat(host.root)).isDirectory()) {
59
+ return { ...entry, status: 'unreachable', reason: 'ENOTDIR' };
60
+ }
61
+ await fs.readdir(host.root);
62
+ } catch (error) {
63
+ return { ...entry, status: 'unreachable', reason: error.code === 'ENOENT' ? 'absent' : error.code };
64
+ }
65
+
66
+ const view = ownerView(record2, host);
67
+ const items = (await buildInstallPlan(host, packageRoot)).filter((item) => item.root === 'claude');
68
+ try {
69
+ for (const item of items) entry.files.push(await fileState(item, host, view));
70
+ } catch (error) {
71
+ return { ...entry, status: 'unreachable', reason: error.code ?? error.message };
72
+ }
73
+ const targets = hookTargets(host, env);
74
+ try {
75
+ for (const item of targets) {
76
+ const inspected = await inspectHook(host.settingsPath, item.spec);
77
+ entry.hooks.push({ item, state: inspected.current ? 'current' : 'register' });
78
+ }
79
+ } catch (error) {
80
+ return { ...entry, status: 'unreachable', reason: `settings unreadable: ${error.message}` };
81
+ }
82
+ const conflict = entry.files.some(({ state }) => state === 'conflict');
83
+ const pending = entry.files.some(({ state }) => state === 'replace' || state === 'create')
84
+ || entry.hooks.some(({ state }) => state === 'register');
85
+ return { ...entry, status: conflict ? 'conflict' : pending ? 'eligible' : 'in-sync' };
86
+ }
87
+
88
+ export async function planOwnerSync(record2, initiator, { packageRoot, env } = {}) {
89
+ validateFormat2(record2);
90
+ const key = normalizeRepoPath(initiator.root);
91
+ const others = Object.values(record2.owners)
92
+ .filter((owner) => normalizeRepoPath(owner.root) !== key)
93
+ .sort((a, b) => a.root.localeCompare(b.root));
94
+ const owners = [];
95
+ for (const owner of others) owners.push(await planOwner(record2, owner, initiator, { packageRoot, env }));
96
+ return { owners };
97
+ }
98
+
99
+ export function ownerSyncLines(plan) {
100
+ const lines = [];
101
+ for (const owner of plan.owners) {
102
+ if (owner.status === 'eligible') {
103
+ const files = owner.files.filter(({ state }) => state === 'replace' || state === 'create').length;
104
+ const hooks = owner.hooks.filter(({ state }) => state === 'register').length;
105
+ lines.push(`Would update ${files} file(s) and ${hooks} hook(s) of ${owner.root}.`);
106
+ } else if (owner.status === 'conflict') {
107
+ const conflicts = owner.files.filter(({ state }) => state === 'conflict')
108
+ .map(({ item, reason }) => `${item.relativeToRoot} (${reason})`).join(', ');
109
+ lines.push(`Left ${owner.root} behind: ${conflicts}; run codex-bridge update --host "${owner.root}" after resolving it.`);
110
+ } else if (owner.status === 'unreachable') {
111
+ lines.push(`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}" when it is available.`);
112
+ }
113
+ }
114
+ return lines;
115
+ }
package/cli/projects.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  /** Implements the projects command. */
2
+ import { withCommandHelp } from './command-help.mjs';
2
3
  import { listProjectRuns, listProjects } from './runs-inventory.mjs';
3
4
  import { renderTable } from './table.mjs';
4
5
  import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
@@ -69,11 +70,13 @@ function parseArgs(args) {
69
70
  json = true;
70
71
  continue;
71
72
  }
72
- if (arg.startsWith('-')) return { error: `codex-bridge projects: unknown option "${arg}"` };
73
+ if (arg.startsWith('-')) {
74
+ return { error: withCommandHelp('projects', `codex-bridge projects: unknown option "${arg}"`) };
75
+ }
73
76
  positional.push(arg);
74
77
  }
75
78
  if (positional.length > 1) {
76
- return { error: 'codex-bridge projects accepts at most one project name.' };
79
+ return { error: withCommandHelp('projects', 'codex-bridge projects accepts at most one project name.') };
77
80
  }
78
81
  return { json, projectName: positional[0] || null };
79
82
  }
@@ -1,5 +1,6 @@
1
1
  /** Parses prune scopes and refuses combinations that could widen destructive reach. */
2
2
  import path from 'node:path';
3
+ import { withCommandHelp } from './command-help.mjs';
3
4
 
4
5
  const DURATION = /^(\d+)([dh])$/;
5
6
  const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
@@ -8,6 +9,10 @@ function error(message) {
8
9
  return { error: `codex-bridge prune: ${message}` };
9
10
  }
10
11
 
12
+ function usageError(message) {
13
+ return { error: withCommandHelp('prune', error(message).error) };
14
+ }
15
+
11
16
  function validCalendarDate(value) {
12
17
  const match = DATE.exec(value);
13
18
  if (!match) return false;
@@ -87,18 +92,18 @@ export function parsePruneArgs(argv = []) {
87
92
  olderThanExplicit = true;
88
93
  continue;
89
94
  }
90
- if (arg.startsWith('-')) return error(`unknown option "${arg}"`);
95
+ if (arg.startsWith('-')) return usageError(`unknown option "${arg}"`);
91
96
  positional.push(arg);
92
97
  }
93
98
 
94
99
  if (allProjects && purge) {
95
- return error('--all-projects cannot be combined with --purge; all-projects is gentle only');
100
+ return usageError('--all-projects cannot be combined with --purge; all-projects is gentle only');
96
101
  }
97
102
  if (allProjects && positional.length) {
98
- return error('--all-projects does not accept a project or run name');
103
+ return usageError('--all-projects does not accept a project or run name');
99
104
  }
100
105
  if (!allProjects && (positional.length < 1 || positional.length > 2)) {
101
- return error('expected <project>, optional <run>, or --all-projects');
106
+ return usageError('expected <project>, optional <run>, or --all-projects');
102
107
  }
103
108
 
104
109
  const projectName = positional[0] || null;
package/cli/prune.mjs CHANGED
@@ -1,11 +1,10 @@
1
1
  /** Confirms, executes, and reports the destructive actions in a prune plan. */
2
2
  import fs from 'node:fs';
3
- import path from 'node:path';
4
- import readline from 'node:readline/promises';
5
- import { normalizePath } from '../src/home/hooks/live-runs.mjs';
3
+ import { inspectSegments } from './link-segments.mjs';
6
4
  import { parsePruneArgs } from './prune-args.mjs';
7
5
  import { prunePlan } from './prune-plan.mjs';
8
6
  import { recursiveSize } from './runs-inventory.mjs';
7
+ import { askYesNo, isInteractive } from './terminal-question.mjs';
9
8
 
10
9
  function formatBytes(bytes) {
11
10
  if (!Number.isFinite(bytes)) return 'unknown';
@@ -102,64 +101,6 @@ function result(exitCode, text) {
102
101
  return { exitCode, output: text };
103
102
  }
104
103
 
105
- // One spelling only. A guard that answers to several option names is a guard with several ways
106
- // to be switched off by accident.
107
- function tty(options) {
108
- if (options.isTTY !== undefined) return Boolean(options.isTTY);
109
- return Boolean((options.stdin || process.stdin).isTTY);
110
- }
111
-
112
- async function defaultPrompt(question, options = {}) {
113
- const input = options.stdin || process.stdin;
114
- const outputStream = options.promptOutput || process.stderr;
115
- const interfaceHandle = readline.createInterface({ input, output: outputStream });
116
- try {
117
- const answer = await interfaceHandle.question(`${question} [y/N] `);
118
- return /^(y|yes)$/i.test(answer.trim());
119
- } finally {
120
- interfaceHandle.close();
121
- }
122
- }
123
-
124
- function accepted(answer) {
125
- return answer === true || /^(y|yes)$/i.test(String(answer ?? '').trim());
126
- }
127
-
128
- // The package compares paths through one function, and this guard is the last thing standing
129
- // between a bug and rm -r. Raw string comparison is case-sensitive: a runs root spelled
130
- // `C:\Users\...` against a target resolved as `c:\users\...` would put every target "outside the
131
- // root", and the deletion would fail with a reason that names the wrong problem.
132
- function targetInsideRoot(target, root) {
133
- const normalizedRoot = normalizePath(root);
134
- const normalizedTarget = normalizePath(target);
135
- if (!normalizedRoot || !normalizedTarget) return false;
136
- return normalizedTarget !== normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`);
137
- }
138
-
139
- /**
140
- * Refuses a target reached through a link. Independent review found that lexical containment alone
141
- * is satisfiable: replace `<root>/<project>` with a junction to an external directory and a target
142
- * that still spells out as inside the root deletes what is outside it. The package deliberately
143
- * does not resolve paths (`realpath` returns `\\?\` and UNC forms on Windows and creates a new
144
- * class of mismatch), so the check is the cheap half instead: every segment from the root down to
145
- * the target must be a real directory, verified with lstat right before deletion.
146
- */
147
- function linkedSegment(target, root) {
148
- const normalizedRoot = normalizePath(root);
149
- let current = path.resolve(target);
150
- while (normalizePath(current) !== normalizedRoot) {
151
- try {
152
- if (fs.lstatSync(current).isSymbolicLink()) return current;
153
- } catch {
154
- return null; // Already gone: rmSync will report it honestly.
155
- }
156
- const parent = path.dirname(current);
157
- if (parent === current) return null;
158
- current = parent;
159
- }
160
- return null;
161
- }
162
-
163
104
  function currentTargetBytes(target, strategy) {
164
105
  if (strategy === 'purge') {
165
106
  // The inventory's recursive measurement is the same accounting used by projects and keeps
@@ -174,18 +115,21 @@ function currentTargetBytes(target, strategy) {
174
115
  }
175
116
 
176
117
  async function confirmAll(plan, args, options) {
177
- const prompt = options.prompt || defaultPrompt;
178
118
  for (const action of plan.actions) {
179
119
  const question = action.strategy === 'purge'
180
120
  ? `Delete folder ${action.path}?`
181
121
  : `Delete archived transport from ${action.project}/${action.run}?\n ${action.targets.join('\n ')}`;
182
- let answer;
122
+ let outcome;
183
123
  try {
184
- answer = await prompt(question, action, options);
124
+ const prompt = options.prompt;
125
+ outcome = await askYesNo(question, {
126
+ ...options,
127
+ prompt: prompt ? (text) => prompt(text, action, options) : undefined,
128
+ });
185
129
  } catch (err) {
186
130
  return { error: `codex-bridge prune: confirmation failed: ${err.message}` };
187
131
  }
188
- if (!accepted(answer)) return { declined: true };
132
+ if (outcome !== 'yes') return { declined: true };
189
133
  }
190
134
  return { declined: false };
191
135
  }
@@ -196,21 +140,30 @@ function bytesAfterRemoval(values) {
196
140
  : null;
197
141
  }
198
142
 
143
+ /**
144
+ * Uses the shared top-down check before deletion: the old leaf-up walk inspected descendants under
145
+ * a link, and its catch treated every lstat error as a clear path.
146
+ */
199
147
  async function execute(plan, options) {
200
148
  const removed = [];
201
149
  const sizes = [];
202
150
  const failed = [];
203
151
  for (const action of plan.actions) {
204
152
  for (const target of action.targets) {
205
- if (!targetInsideRoot(target, plan.root)) {
153
+ const inspection = inspectSegments(target, plan.root);
154
+ if (inspection.kind === 'outside') {
206
155
  failed.push({ target, error: 'target is outside the configured runs root' });
207
156
  continue;
208
157
  }
209
- const linked = linkedSegment(target, plan.root);
210
- if (linked) {
211
- failed.push({ target, error: `refusing to delete through a link: ${linked}` });
158
+ if (inspection.kind === 'link') {
159
+ failed.push({ target, error: `refusing to delete through a link: ${inspection.at}` });
212
160
  continue;
213
161
  }
162
+ if (inspection.kind === 'error') {
163
+ failed.push({ target, error: `cannot inspect ${inspection.at} (${inspection.code}); nothing deleted` });
164
+ continue;
165
+ }
166
+ // `missing` goes on: rmSync reports the vanished target honestly, as it always did.
214
167
  const bytes = currentTargetBytes(target, action.strategy);
215
168
  try {
216
169
  fs.rmSync(target, { recursive: action.strategy === 'purge', force: false });
@@ -253,7 +206,7 @@ export async function prune(argv = [], options = {}) {
253
206
  const payload = { status: 'completed', ...basePayload(plan), removed: [], bytesFreed: 0, failed: [] };
254
207
  return result(0, output(payload, args.json));
255
208
  }
256
- if (!tty(options)) {
209
+ if (!isInteractive(options)) {
257
210
  const message = 'No TTY: refusing deletion because deletion is an operator action; no files were changed.';
258
211
  const payload = { status: 'refused', ...basePayload(plan), error: message, removed: [], bytesFreed: 0 };
259
212
  return result(1, output(payload, args.json));
@@ -274,5 +227,3 @@ export async function prune(argv = [], options = {}) {
274
227
  ? output(executed.payload, true)
275
228
  : reportLines(executed.payload, executed.payload.status));
276
229
  }
277
-
278
- export { defaultPrompt };