crbro-memory 2.9.1 → 2.11.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 (197) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +256 -456
  3. package/SECURITY.md +135 -8
  4. package/bin/crbro.mjs +555 -299
  5. package/dist/build-id.json +10 -0
  6. package/dist/cli/common.js +211 -0
  7. package/dist/cli/export.js +522 -0
  8. package/dist/cli/import.js +683 -0
  9. package/dist/cli/markdown.js +313 -0
  10. package/dist/cli/mcpclient.js +107 -0
  11. package/dist/cli/serve.js +453 -0
  12. package/dist/cli/view.js +358 -0
  13. package/dist/cli/viewer-page.js +378 -0
  14. package/dist/daemon/daemon.js +119 -11
  15. package/dist/daemon/endpoint.js +222 -21
  16. package/dist/daemon/lines.js +102 -13
  17. package/dist/daemon/proxy.js +480 -125
  18. package/dist/daemon/surface.js +116 -0
  19. package/dist/daemon/trace.js +73 -0
  20. package/dist/engine/atrest.js +132 -0
  21. package/dist/engine/backup.js +143 -19
  22. package/dist/engine/brain.js +22 -6
  23. package/dist/engine/codexconfig.js +476 -0
  24. package/dist/engine/cortex.js +762 -311
  25. package/dist/engine/doctor.js +676 -0
  26. package/dist/engine/evidence.js +171 -0
  27. package/dist/engine/heat.js +81 -37
  28. package/dist/engine/hippocampus.js +50 -36
  29. package/dist/engine/hookrefresh.js +296 -0
  30. package/dist/engine/hookverify.js +15 -3
  31. package/dist/engine/installer.js +1022 -0
  32. package/dist/engine/installio.js +279 -0
  33. package/dist/engine/keychain.js +735 -144
  34. package/dist/engine/maintenance.js +355 -87
  35. package/dist/engine/modinstall.js +98 -45
  36. package/dist/engine/prefrontal.js +126 -69
  37. package/dist/engine/projects.js +80 -0
  38. package/dist/engine/quarantine.js +204 -0
  39. package/dist/engine/secrets.js +41 -5
  40. package/dist/engine/shelf.js +34 -11
  41. package/dist/engine/synapses.js +183 -80
  42. package/dist/engine/triggers.js +141 -11
  43. package/dist/index.js +37 -3
  44. package/dist/mcp-surface.json +1 -0
  45. package/dist/miner/extractor.js +64 -17
  46. package/dist/miner/scheduler.js +144 -68
  47. package/dist/search/index.js +810 -176
  48. package/dist/search/quality.js +31 -0
  49. package/dist/search/semantic.js +208 -18
  50. package/dist/search/tokenize.js +44 -0
  51. package/dist/server.js +649 -343
  52. package/dist/sync/git.js +203 -72
  53. package/dist/sync/materialize.js +33 -8
  54. package/dist/sync/space.js +286 -101
  55. package/dist/sync/validate.js +204 -0
  56. package/dist/utils/budget.js +15 -2
  57. package/dist/utils/fs.js +40 -6
  58. package/dist/utils/ids.js +128 -14
  59. package/dist/utils/lock.js +219 -52
  60. package/hooks/crbro-codex.mjs +284 -0
  61. package/hooks/crbro-guard.mjs +281 -173
  62. package/hooks/crbro-hooks.shipped.json +20 -0
  63. package/hooks/crbro-lifecycle.mjs +16 -4
  64. package/hooks/crbro-subagent.mjs +180 -180
  65. package/package.json +12 -8
  66. package/dist/daemon/daemon.d.ts +0 -25
  67. package/dist/daemon/daemon.d.ts.map +0 -1
  68. package/dist/daemon/daemon.js.map +0 -1
  69. package/dist/daemon/endpoint.d.ts +0 -66
  70. package/dist/daemon/endpoint.d.ts.map +0 -1
  71. package/dist/daemon/endpoint.js.map +0 -1
  72. package/dist/daemon/lines.d.ts +0 -55
  73. package/dist/daemon/lines.d.ts.map +0 -1
  74. package/dist/daemon/lines.js.map +0 -1
  75. package/dist/daemon/proxy.d.ts +0 -55
  76. package/dist/daemon/proxy.d.ts.map +0 -1
  77. package/dist/daemon/proxy.js.map +0 -1
  78. package/dist/engine/backup.d.ts +0 -68
  79. package/dist/engine/backup.d.ts.map +0 -1
  80. package/dist/engine/backup.js.map +0 -1
  81. package/dist/engine/brain.d.ts +0 -79
  82. package/dist/engine/brain.d.ts.map +0 -1
  83. package/dist/engine/brain.js.map +0 -1
  84. package/dist/engine/cortex.d.ts +0 -670
  85. package/dist/engine/cortex.d.ts.map +0 -1
  86. package/dist/engine/cortex.js.map +0 -1
  87. package/dist/engine/dates.d.ts +0 -28
  88. package/dist/engine/dates.d.ts.map +0 -1
  89. package/dist/engine/dates.js.map +0 -1
  90. package/dist/engine/heat.d.ts +0 -28
  91. package/dist/engine/heat.d.ts.map +0 -1
  92. package/dist/engine/heat.js.map +0 -1
  93. package/dist/engine/hippocampus.d.ts +0 -60
  94. package/dist/engine/hippocampus.d.ts.map +0 -1
  95. package/dist/engine/hippocampus.js.map +0 -1
  96. package/dist/engine/hookverify.d.ts +0 -42
  97. package/dist/engine/hookverify.d.ts.map +0 -1
  98. package/dist/engine/hookverify.js.map +0 -1
  99. package/dist/engine/keychain.d.ts +0 -32
  100. package/dist/engine/keychain.d.ts.map +0 -1
  101. package/dist/engine/keychain.js.map +0 -1
  102. package/dist/engine/maintenance.d.ts +0 -218
  103. package/dist/engine/maintenance.d.ts.map +0 -1
  104. package/dist/engine/maintenance.js.map +0 -1
  105. package/dist/engine/modinstall.d.ts +0 -215
  106. package/dist/engine/modinstall.d.ts.map +0 -1
  107. package/dist/engine/modinstall.js.map +0 -1
  108. package/dist/engine/postmortem.d.ts +0 -81
  109. package/dist/engine/postmortem.d.ts.map +0 -1
  110. package/dist/engine/postmortem.js.map +0 -1
  111. package/dist/engine/prefrontal.d.ts +0 -61
  112. package/dist/engine/prefrontal.d.ts.map +0 -1
  113. package/dist/engine/prefrontal.js.map +0 -1
  114. package/dist/engine/secrets.d.ts +0 -30
  115. package/dist/engine/secrets.d.ts.map +0 -1
  116. package/dist/engine/secrets.js.map +0 -1
  117. package/dist/engine/shelf.d.ts +0 -156
  118. package/dist/engine/shelf.d.ts.map +0 -1
  119. package/dist/engine/shelf.js.map +0 -1
  120. package/dist/engine/source.d.ts +0 -6
  121. package/dist/engine/source.d.ts.map +0 -1
  122. package/dist/engine/source.js.map +0 -1
  123. package/dist/engine/synapses.d.ts +0 -82
  124. package/dist/engine/synapses.d.ts.map +0 -1
  125. package/dist/engine/synapses.js.map +0 -1
  126. package/dist/engine/triggers.d.ts +0 -53
  127. package/dist/engine/triggers.d.ts.map +0 -1
  128. package/dist/engine/triggers.js.map +0 -1
  129. package/dist/engine/usage.d.ts +0 -61
  130. package/dist/engine/usage.d.ts.map +0 -1
  131. package/dist/engine/usage.js.map +0 -1
  132. package/dist/index.d.ts +0 -2
  133. package/dist/index.d.ts.map +0 -1
  134. package/dist/index.js.map +0 -1
  135. package/dist/miner/extractor.d.ts +0 -12
  136. package/dist/miner/extractor.d.ts.map +0 -1
  137. package/dist/miner/extractor.js.map +0 -1
  138. package/dist/miner/index.d.ts +0 -61
  139. package/dist/miner/index.d.ts.map +0 -1
  140. package/dist/miner/index.js.map +0 -1
  141. package/dist/miner/scheduler.d.ts +0 -30
  142. package/dist/miner/scheduler.d.ts.map +0 -1
  143. package/dist/miner/scheduler.js.map +0 -1
  144. package/dist/miner/types.d.ts +0 -11
  145. package/dist/miner/types.d.ts.map +0 -1
  146. package/dist/miner/types.js.map +0 -1
  147. package/dist/search/index.d.ts +0 -323
  148. package/dist/search/index.d.ts.map +0 -1
  149. package/dist/search/index.js.map +0 -1
  150. package/dist/search/semantic.d.ts +0 -80
  151. package/dist/search/semantic.d.ts.map +0 -1
  152. package/dist/search/semantic.js.map +0 -1
  153. package/dist/search/synonyms.d.ts +0 -2
  154. package/dist/search/synonyms.d.ts.map +0 -1
  155. package/dist/search/synonyms.js.map +0 -1
  156. package/dist/search/tokenize.d.ts +0 -39
  157. package/dist/search/tokenize.d.ts.map +0 -1
  158. package/dist/search/tokenize.js.map +0 -1
  159. package/dist/server.d.ts +0 -41
  160. package/dist/server.d.ts.map +0 -1
  161. package/dist/server.js.map +0 -1
  162. package/dist/sync/git.d.ts +0 -36
  163. package/dist/sync/git.d.ts.map +0 -1
  164. package/dist/sync/git.js.map +0 -1
  165. package/dist/sync/materialize.d.ts +0 -35
  166. package/dist/sync/materialize.d.ts.map +0 -1
  167. package/dist/sync/materialize.js.map +0 -1
  168. package/dist/sync/ops.d.ts +0 -142
  169. package/dist/sync/ops.d.ts.map +0 -1
  170. package/dist/sync/ops.js.map +0 -1
  171. package/dist/sync/space.d.ts +0 -116
  172. package/dist/sync/space.d.ts.map +0 -1
  173. package/dist/sync/space.js.map +0 -1
  174. package/dist/types/index.d.ts +0 -346
  175. package/dist/types/index.d.ts.map +0 -1
  176. package/dist/types/index.js.map +0 -1
  177. package/dist/utils/budget.d.ts +0 -44
  178. package/dist/utils/budget.d.ts.map +0 -1
  179. package/dist/utils/budget.js.map +0 -1
  180. package/dist/utils/fs.d.ts +0 -59
  181. package/dist/utils/fs.d.ts.map +0 -1
  182. package/dist/utils/fs.js.map +0 -1
  183. package/dist/utils/hash.d.ts +0 -20
  184. package/dist/utils/hash.d.ts.map +0 -1
  185. package/dist/utils/hash.js.map +0 -1
  186. package/dist/utils/ids.d.ts +0 -47
  187. package/dist/utils/ids.d.ts.map +0 -1
  188. package/dist/utils/ids.js.map +0 -1
  189. package/dist/utils/lock.d.ts +0 -11
  190. package/dist/utils/lock.d.ts.map +0 -1
  191. package/dist/utils/lock.js.map +0 -1
  192. package/dist/utils/transcripts.d.ts +0 -63
  193. package/dist/utils/transcripts.d.ts.map +0 -1
  194. package/dist/utils/transcripts.js.map +0 -1
  195. package/dist/version.d.ts +0 -26
  196. package/dist/version.d.ts.map +0 -1
  197. package/dist/version.js.map +0 -1
@@ -0,0 +1,1022 @@
1
+ "use strict";
2
+ // ─── One command in, one command out ─────────────────────────────
3
+ //
4
+ // `crbro-memory install` puts CRBRO on this machine for every MCP client it
5
+ // finds, the same way each time:
6
+ //
7
+ // 1. The engine. The running package and its runtime dependencies are copied
8
+ // to a stable folder outside the brain — ~/.crbro-engine/<version>, with
9
+ // current.json naming the live one and crbro.mjs, a launcher that runs it.
10
+ // Under npx the dependencies sit in the same node_modules tree as the
11
+ // package: they are resolved there and copied, never downloaded. Clients
12
+ // then start `node ~/.crbro-engine/crbro.mjs`: no npx at startup, one
13
+ // build for every client (one daemon), hooks that never point at a cache
14
+ // folder that vanishes.
15
+ // 2. The registrations. Claude Code through its own CLI
16
+ // (`claude mcp add-json --scope user`; ~/.claude.json is never edited by
17
+ // hand), Claude Desktop's claude_desktop_config.json, Codex's config.toml
18
+ // (with startup and tool timeouts, added only when missing), Cursor's
19
+ // ~/.cursor/mcp.json.
20
+ // 3. The start of every session: what install-boot does (wireBoot below).
21
+ //
22
+ // `crbro-memory uninstall` takes back what CRBRO added and nothing else:
23
+ // registrations that run CRBRO, CRBRO's hooks and boot wiring, the mod, the
24
+ // engine folder. Backups stay. The brain (~/.crbro) is never touched; the
25
+ // command says where it is.
26
+ //
27
+ // Both take --dry-run, which prints the same list without writing a byte, and
28
+ // both print exactly what they did. Every file write goes through
29
+ // installio.writeGuarded: a timestamped backup first, refused when the file
30
+ // changed between read and write.
31
+ var __importDefault = (this && this.__importDefault) || function (mod) {
32
+ return (mod && mod.__esModule) ? mod : { "default": mod };
33
+ };
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.LAUNCHER_SOURCE = exports.findOnPath = exports.ENGINE_STAMP = exports.CURRENT = exports.LAUNCHER = exports.CODEX_HOOK_TIMEOUT = exports.CODEX_HOOK = exports.BOOT_MATCHER = exports.CLIENT_NAMES = exports.ALL_CLIENTS = void 0;
36
+ exports.winQuote = winQuote;
37
+ exports.claudeCli = claudeCli;
38
+ exports.packageVersion = packageVersion;
39
+ exports.referencedFiles = referencedFiles;
40
+ exports.isInstallBootHook = isInstallBootHook;
41
+ exports.wireClaudeBoot = wireClaudeBoot;
42
+ exports.wireCodex = wireCodex;
43
+ exports.wireBoot = wireBoot;
44
+ exports.runtimeDependencies = runtimeDependencies;
45
+ exports.packageHash = packageHash;
46
+ exports.installEngine = installEngine;
47
+ exports.detectClients = detectClients;
48
+ exports.parseClients = parseClients;
49
+ exports.install = install;
50
+ exports.uninstall = uninstall;
51
+ exports.formatSteps = formatSteps;
52
+ exports.failed = failed;
53
+ exports.codexRegistration = codexRegistration;
54
+ const child_process_1 = require("child_process");
55
+ const crypto_1 = require("crypto");
56
+ const fs_1 = require("fs");
57
+ const path_1 = __importDefault(require("path"));
58
+ const installio_js_1 = require("./installio.js");
59
+ const codexconfig_js_1 = require("./codexconfig.js");
60
+ const hookrefresh_js_1 = require("./hookrefresh.js");
61
+ exports.ALL_CLIENTS = ['claude-code', 'claude-desktop', 'codex', 'cursor'];
62
+ exports.CLIENT_NAMES = {
63
+ 'claude-code': 'Claude Code', 'claude-desktop': 'Claude Desktop', codex: 'Codex', cursor: 'Cursor',
64
+ };
65
+ exports.BOOT_MATCHER = 'startup|resume|clear|compact';
66
+ exports.CODEX_HOOK = 'crbro-codex.mjs';
67
+ exports.CODEX_HOOK_TIMEOUT = 20;
68
+ exports.LAUNCHER = 'crbro.mjs';
69
+ exports.CURRENT = 'current.json';
70
+ exports.ENGINE_STAMP = '.crbro-engine.json';
71
+ // ─── Small helpers ───────────────────────────────────────────────
72
+ function step(steps, s) {
73
+ steps.push(s);
74
+ return s;
75
+ }
76
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
77
+ var installio_js_2 = require("./installio.js");
78
+ Object.defineProperty(exports, "findOnPath", { enumerable: true, get: function () { return installio_js_2.findOnPath; } });
79
+ /** One argument quoted for the Windows C runtime (what node.exe and claude.exe parse). */
80
+ function winQuote(arg) {
81
+ if (arg && !/[\s"]/.test(arg))
82
+ return arg;
83
+ let out = '"';
84
+ let slashes = 0;
85
+ for (const c of arg) {
86
+ if (c === '\\') {
87
+ slashes++;
88
+ continue;
89
+ }
90
+ if (c === '"') {
91
+ out += '\\'.repeat(slashes * 2 + 1) + '"';
92
+ slashes = 0;
93
+ continue;
94
+ }
95
+ out += '\\'.repeat(slashes) + c;
96
+ slashes = 0;
97
+ }
98
+ return out + '\\'.repeat(slashes * 2) + '"';
99
+ }
100
+ /** The `claude` CLI as found on PATH. A .cmd/.bat shim runs through cmd.exe with its arguments quoted by hand. */
101
+ function claudeCli(env = process.env, platform = process.platform) {
102
+ const found = (0, installio_js_1.findOnPath)('claude', env, platform);
103
+ if (!found)
104
+ return null;
105
+ const run = (args) => {
106
+ let r;
107
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(found)) {
108
+ if (args.some(a => /[%!\r\n]/.test(a))) {
109
+ return { status: null, stdout: '', stderr: 'an argument holds % or ! (cmd.exe would expand it); run the command by hand' };
110
+ }
111
+ const line = [winQuote(found), ...args.map(winQuote)].join(' ');
112
+ r = (0, child_process_1.spawnSync)(env.ComSpec || env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `"${line}"`], { env, encoding: 'utf8', windowsVerbatimArguments: true, windowsHide: true, timeout: 60_000 });
113
+ }
114
+ else {
115
+ r = (0, child_process_1.spawnSync)(found, args, { env, encoding: 'utf8', windowsHide: true, timeout: 60_000 });
116
+ }
117
+ return { status: r.error ? null : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? r.error.message : '') };
118
+ };
119
+ return { run, path: found };
120
+ }
121
+ function pkgJson(root) {
122
+ try {
123
+ return JSON.parse((0, fs_1.readFileSync)(path_1.default.join(root, 'package.json'), 'utf8'));
124
+ }
125
+ catch {
126
+ return {};
127
+ }
128
+ }
129
+ function packageVersion(root) {
130
+ const v = pkgJson(root).version;
131
+ return typeof v === 'string' && v ? v : 'unknown';
132
+ }
133
+ /** Paths a hook command runs or reads: quoted or bare, with ~, $HOME and %USERPROFILE% expanded. */
134
+ function referencedFiles(command, home) {
135
+ const out = [];
136
+ const re = /"([^"]+\.(?:mjs|cjs|js|txt|md|sh|ps1|py))"|'([^']+\.(?:mjs|cjs|js|txt|md|sh|ps1|py))'|((?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|[A-Za-z]:|\/)[^\s"']*\.(?:mjs|cjs|js|txt|md|sh|ps1|py))(?=$|[\s"';|&)])/g;
137
+ let m;
138
+ while ((m = re.exec(command)) !== null) {
139
+ const raw = (m[1] ?? m[2] ?? m[3]).replace(/^(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%)(?=[\\/])/i, home);
140
+ if (path_1.default.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw))
141
+ out.push(raw);
142
+ }
143
+ return out;
144
+ }
145
+ function hookCommands(entry) {
146
+ const out = [];
147
+ for (const h of Array.isArray(entry?.hooks) ? entry.hooks : []) {
148
+ if (h && typeof h.command === 'string')
149
+ out.push(h.command);
150
+ if (h && typeof h.commandWindows === 'string')
151
+ out.push(h.commandWindows);
152
+ }
153
+ return out;
154
+ }
155
+ /** The install-boot entry, this release's or an older one with other wording. */
156
+ function isInstallBootHook(h) {
157
+ return !!h && typeof h.command === 'string' && h.command.startsWith("printf '%s\\n' ") && h.command.includes('crbro_boot');
158
+ }
159
+ /** Codex's install-boot pair before 2.11: the direct mcp_tool call to crbro_boot. */
160
+ function isInstallBootMcpCall(h) {
161
+ return !!h && h.type === 'mcp_tool' && h.server === codexconfig_js_1.SERVER_NAME && h.tool === 'crbro_boot';
162
+ }
163
+ function scanCrbroHooks(list, home, skip = () => false) {
164
+ const scan = { mentions: 0, working: 0, missing: [], npxCache: [] };
165
+ for (const entry of list) {
166
+ const hooks = (Array.isArray(entry?.hooks) ? entry.hooks : []).filter((h) => !skip(h));
167
+ if (!/crbro/i.test(JSON.stringify(hooks)))
168
+ continue;
169
+ scan.mentions++;
170
+ const cmds = hooks.flatMap((h) => [h?.command, h?.commandWindows].filter((c) => typeof c === 'string'));
171
+ const files = cmds.flatMap((c) => referencedFiles(c, home));
172
+ const missing = files.filter((f) => !(0, fs_1.existsSync)(f));
173
+ scan.missing.push(...missing);
174
+ if (!missing.length)
175
+ scan.working++;
176
+ for (const c of cmds)
177
+ if (/[\\/]_npx[\\/]/.test(c))
178
+ scan.npxCache.push(c);
179
+ }
180
+ return scan;
181
+ }
182
+ // ─── Boot wiring (install-boot) ──────────────────────────────────
183
+ /** Claude Code: a SessionStart hook that tells the model to call crbro_boot first. */
184
+ function wireClaudeBoot(ctx, steps) {
185
+ const client = 'Claude Code';
186
+ const dir = (0, installio_js_1.claudeDir)(ctx.home, ctx.env);
187
+ const file = path_1.default.join(dir, 'settings.json');
188
+ if (!(0, fs_1.existsSync)(dir) && !(0, fs_1.existsSync)(file)) {
189
+ step(steps, { client, status: 'skipped', what: `${dir} not found` });
190
+ return;
191
+ }
192
+ const command = `printf '%s\\n' ${JSON.stringify(ctx.bootNotice)}`;
193
+ let outcome = null;
194
+ const apply = (s) => {
195
+ if (s.hooks !== undefined && !isObject(s.hooks))
196
+ throw new Error(`hooks in ${file} is not an object — not touching it.`);
197
+ const hooks = s.hooks ?? {};
198
+ if (hooks.SessionStart !== undefined && !Array.isArray(hooks.SessionStart)) {
199
+ throw new Error(`hooks.SessionStart in ${file} is not a list — not touching it.`);
200
+ }
201
+ const list = hooks.SessionStart ?? [];
202
+ const scan = scanCrbroHooks(list, ctx.home);
203
+ if (scan.working > 0) {
204
+ outcome = { client, status: 'kept', what: 'SessionStart already starts CRBRO. Left alone.', file };
205
+ if (scan.npxCache.length)
206
+ steps.push({ client, status: 'warning', what: 'a SessionStart hook runs CRBRO out of the npx cache, which disappears when the cache is cleaned: run npx crbro-memory install for a stable path', file });
207
+ return false;
208
+ }
209
+ if (scan.mentions > 0) {
210
+ steps.push({ client, status: 'warning', what: `a CRBRO SessionStart hook points at files that do not exist (${scan.missing.join(', ')}): it does nothing. Adding the packaged one; remove the broken entry by hand.`, file });
211
+ }
212
+ list.push({ matcher: exports.BOOT_MATCHER, hooks: [{ type: 'command', command, shell: 'bash', timeout: 10, statusMessage: 'Loading CRBRO memory...' }] });
213
+ hooks.SessionStart = list;
214
+ s.hooks = hooks;
215
+ outcome = { client, status: ctx.dryRun ? 'would' : 'done', what: 'SessionStart hook added.', file };
216
+ return !ctx.dryRun;
217
+ };
218
+ const r = ctx.dryRun ? dry(file, apply) : (0, installio_js_1.updateJsonFile)(file, apply);
219
+ if (!r.ok) {
220
+ step(steps, { client, status: 'failed', what: r.error, file });
221
+ return;
222
+ }
223
+ if (outcome)
224
+ step(steps, { ...outcome, backup: r.changed ? r.backup : undefined });
225
+ }
226
+ /** Runs a change against a JSON file without writing: for --dry-run. */
227
+ function dry(file, change) {
228
+ const read = (0, installio_js_1.readJsonObject)(file);
229
+ if (!read.ok)
230
+ return read;
231
+ try {
232
+ change(read.value ?? {}, read.value !== null);
233
+ }
234
+ catch (e) {
235
+ return { ok: false, error: e.message };
236
+ }
237
+ return { ok: true, changed: false, backup: null };
238
+ }
239
+ /** Codex: config.toml (registration + timeouts) and hooks.json (the packaged boot hook). */
240
+ function wireCodex(ctx, steps, wiring) {
241
+ const client = 'Codex';
242
+ const dir = (0, installio_js_1.codexHome)(ctx.home, ctx.env);
243
+ if (!(0, fs_1.existsSync)(dir)) {
244
+ step(steps, { client, status: 'skipped', what: `${dir} not found` });
245
+ return;
246
+ }
247
+ // — config.toml —
248
+ const tomlFile = path_1.default.join(dir, 'config.toml');
249
+ for (let attempt = 0; attempt < 3; attempt++) {
250
+ let read;
251
+ try {
252
+ read = (0, installio_js_1.readText)(tomlFile);
253
+ }
254
+ catch (e) {
255
+ step(steps, { client, status: 'failed', what: e.message, file: tomlFile });
256
+ break;
257
+ }
258
+ const r = (0, codexconfig_js_1.ensureCodexServer)((read.bom ? '\ufeff' : '') + read.text, wiring);
259
+ if (r.refused) {
260
+ step(steps, { client, status: 'warning', what: `config.toml: ${r.refused}`, file: tomlFile });
261
+ break;
262
+ }
263
+ for (const k of r.kept)
264
+ step(steps, { client, status: 'kept', what: `config.toml: kept your ${k}`, file: tomlFile });
265
+ if (!r.changed) {
266
+ if (!r.kept.length)
267
+ step(steps, { client, status: 'kept', what: 'config.toml: CRBRO already registered.', file: tomlFile });
268
+ break;
269
+ }
270
+ if (ctx.dryRun) {
271
+ for (const a of r.actions)
272
+ step(steps, { client, status: 'would', what: `config.toml: ${a}`, file: tomlFile });
273
+ break;
274
+ }
275
+ try {
276
+ const backup = (0, installio_js_1.writeGuarded)(tomlFile, r.text, { expectHash: read.hash });
277
+ for (const a of r.actions)
278
+ step(steps, { client, status: 'done', what: `config.toml: ${a}`, file: tomlFile, backup });
279
+ break;
280
+ }
281
+ catch (e) {
282
+ if (e.code === 'ESETTINGSCHANGED' && attempt < 2)
283
+ continue;
284
+ step(steps, { client, status: 'failed', what: `config.toml: ${e.message}`, file: tomlFile });
285
+ break;
286
+ }
287
+ }
288
+ // — the hook script, at a stable path —
289
+ const hookDir = path_1.default.join(dir, 'crbro-hooks');
290
+ const script = path_1.default.join(hookDir, exports.CODEX_HOOK);
291
+ if (ctx.dryRun) {
292
+ step(steps, { client, status: 'would', what: `copy the boot hook to ${script}` });
293
+ }
294
+ else {
295
+ try {
296
+ (0, hookrefresh_js_1.installHookFile)(path_1.default.join(ctx.packageRoot, 'hooks'), exports.CODEX_HOOK, hookDir);
297
+ step(steps, { client, status: 'done', what: `boot hook copied to ${script}` });
298
+ }
299
+ catch (e) {
300
+ step(steps, { client, status: 'failed', what: `could not copy the boot hook: ${e.message}`, file: script });
301
+ return;
302
+ }
303
+ }
304
+ // — hooks.json —
305
+ const hooksFile = path_1.default.join(dir, 'hooks.json');
306
+ const base = `node "${script.split('\\').join('/')}"${wiring.engineArg ? ` --engine "${wiring.engineArg.split('\\').join('/')}"` : ''}`;
307
+ const ours = (h) => !!h && typeof h.command === 'string' && h.command.includes(exports.CODEX_HOOK);
308
+ const outcomes = [];
309
+ const apply = (s) => {
310
+ if (s.hooks !== undefined && !isObject(s.hooks))
311
+ throw new Error(`hooks in ${hooksFile} is not an object — not touching it.`);
312
+ const hooks = s.hooks ?? {};
313
+ let changed = false;
314
+ for (const [event, matcher, status] of [
315
+ ['SessionStart', exports.BOOT_MATCHER, 'Loading CRBRO memory...'],
316
+ ['UserPromptSubmit', undefined, 'Checking CRBRO memory...'],
317
+ ]) {
318
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
319
+ throw new Error(`hooks.${event} in ${hooksFile} is not a list — not touching it.`);
320
+ const list = hooks[event] ?? [];
321
+ const mine = list.flatMap(e => (Array.isArray(e?.hooks) ? e.hooks : []).filter(ours));
322
+ if (mine.length) {
323
+ let refreshed = false;
324
+ for (const h of mine)
325
+ if (h.command !== base) {
326
+ h.command = base;
327
+ refreshed = true;
328
+ }
329
+ if (refreshed)
330
+ changed = true;
331
+ outcomes.push({ client, status: refreshed ? (ctx.dryRun ? 'would' : 'done') : 'kept',
332
+ what: `${event}: packaged hook ${refreshed ? 'pointed at the current paths' : 'already installed'}.`, file: hooksFile });
333
+ continue;
334
+ }
335
+ // The pair install-boot wrote before 2.11 (direct mcp_tool call + printed notice) gives way to the hook, which does both.
336
+ let replaced = 0;
337
+ if (event === 'SessionStart') {
338
+ for (let i = list.length - 1; i >= 0; i--) {
339
+ const e = list[i];
340
+ const hs = Array.isArray(e?.hooks) ? e.hooks : [];
341
+ const old = hs.filter((h) => isInstallBootMcpCall(h) || isInstallBootHook(h));
342
+ if (!old.length || old.length !== hs.length)
343
+ continue;
344
+ list.splice(i, 1);
345
+ replaced += old.length;
346
+ }
347
+ }
348
+ const scan = scanCrbroHooks(list, ctx.home);
349
+ if (scan.working > 0 && !replaced) {
350
+ outcomes.push({ client, status: 'kept', what: `${event}: your own CRBRO hook is there. Left alone; the packaged one (${script}) is available if you remove yours and run this again.`, file: hooksFile });
351
+ if (scan.npxCache.length)
352
+ outcomes.push({ client, status: 'warning', what: `${event}: a hook runs CRBRO out of the npx cache (${scan.npxCache[0].slice(0, 160)}…), which disappears when the cache is cleaned`, file: hooksFile });
353
+ continue;
354
+ }
355
+ if (scan.mentions > scan.working) {
356
+ outcomes.push({ client, status: 'warning', what: `${event}: a CRBRO hook points at files that do not exist (${scan.missing.join(', ')}): it does nothing. Remove it by hand.`, file: hooksFile });
357
+ }
358
+ const entry = matcher ? { matcher } : {};
359
+ entry.hooks = [{ type: 'command', command: base, timeout: exports.CODEX_HOOK_TIMEOUT, statusMessage: status }];
360
+ list.push(entry);
361
+ hooks[event] = list;
362
+ changed = true;
363
+ outcomes.push({ client, status: ctx.dryRun ? 'would' : 'done',
364
+ what: `${event}: packaged boot hook added${replaced ? ' (replacing the mcp_tool + notice pair an earlier install-boot wrote)' : ''}.`, file: hooksFile });
365
+ }
366
+ s.hooks = hooks;
367
+ return changed && !ctx.dryRun;
368
+ };
369
+ const r = ctx.dryRun ? dry(hooksFile, apply) : (0, installio_js_1.updateJsonFile)(hooksFile, apply);
370
+ if (!r.ok) {
371
+ step(steps, { client, status: 'failed', what: r.error, file: hooksFile });
372
+ return;
373
+ }
374
+ for (const o of outcomes)
375
+ step(steps, { ...o, backup: r.changed && o.status === 'done' ? r.backup : undefined });
376
+ }
377
+ /** install-boot: the start of every session, for the clients that have hooks. */
378
+ function wireBoot(ctx, clients, wiring) {
379
+ const steps = [];
380
+ if (clients.includes('claude-code'))
381
+ wireClaudeBoot(ctx, steps);
382
+ if (clients.includes('codex'))
383
+ wireCodex(ctx, steps, wiring);
384
+ return steps;
385
+ }
386
+ // ─── The engine ──────────────────────────────────────────────────
387
+ /** What the package ships besides package.json (its "files"), minus what never runs. */
388
+ function shippedEntries(root) {
389
+ const files = pkgJson(root).files;
390
+ const list = Array.isArray(files) && files.length ? files : ['dist/', 'bin/', 'hooks/', 'mods/'];
391
+ return list.map((f) => f.replace(/\/$/, '')).filter((f) => (0, fs_1.existsSync)(path_1.default.join(root, f)));
392
+ }
393
+ /** Walks up from `from` the way Node resolves a bare package name. */
394
+ function resolvePackageDir(name, from) {
395
+ let dir = from;
396
+ for (;;) {
397
+ if (path_1.default.basename(dir) !== 'node_modules') {
398
+ const cand = path_1.default.join(dir, 'node_modules', ...name.split('/'));
399
+ if ((0, fs_1.existsSync)(path_1.default.join(cand, 'package.json')))
400
+ return cand;
401
+ }
402
+ const up = path_1.default.dirname(dir);
403
+ if (up === dir)
404
+ return null;
405
+ dir = up;
406
+ }
407
+ }
408
+ /**
409
+ * The runtime dependencies of the package, transitively, as Node would
410
+ * resolve them from where the package sits: dependencies, optional ones that
411
+ * are present, and peers that resolve. Missing required ones are returned as
412
+ * names in `missing`; nothing is downloaded.
413
+ */
414
+ function runtimeDependencies(root) {
415
+ const tops = [];
416
+ for (let d = root;; d = path_1.default.dirname(d)) {
417
+ tops.push(path_1.default.basename(d) === 'node_modules' ? d : path_1.default.join(d, 'node_modules'));
418
+ if (path_1.default.dirname(d) === d)
419
+ break;
420
+ }
421
+ const relOf = (dir) => {
422
+ for (const t of tops) {
423
+ const rel = path_1.default.relative(t, dir);
424
+ if (rel && !rel.startsWith('..') && !path_1.default.isAbsolute(rel))
425
+ return rel;
426
+ }
427
+ return null;
428
+ };
429
+ const seen = new Map();
430
+ const missing = [];
431
+ const queue = [];
432
+ const enqueue = (pj, from) => {
433
+ for (const n of Object.keys(pj.dependencies ?? {}))
434
+ queue.push({ name: n, from, required: true });
435
+ for (const n of Object.keys(pj.optionalDependencies ?? {}))
436
+ queue.push({ name: n, from, required: false });
437
+ for (const n of Object.keys(pj.peerDependencies ?? {}))
438
+ queue.push({ name: n, from, required: false });
439
+ };
440
+ enqueue(pkgJson(root), root);
441
+ while (queue.length) {
442
+ const { name, from, required } = queue.shift();
443
+ const dir = resolvePackageDir(name, from);
444
+ if (!dir) {
445
+ if (required && !missing.includes(name))
446
+ missing.push(name);
447
+ continue;
448
+ }
449
+ if (seen.has(dir))
450
+ continue;
451
+ const rel = relOf(dir) ?? name;
452
+ seen.set(dir, { name, dir, rel });
453
+ enqueue(pkgJson(dir), dir);
454
+ }
455
+ return { deps: [...seen.values()], missing };
456
+ }
457
+ /**
458
+ * Copy a file or folder, following links, leaving out every path that holds a
459
+ * node_modules below `base` (the rule the fs.cpSync filter had). Written out
460
+ * instead of fs.cpSync (2.11): in CI the engine copy came out without its
461
+ * dependencies on Node 20 under Windows, while Node 22 and 24 copied them.
462
+ */
463
+ function copyTree(src, dest, base) {
464
+ if (/[\\/]node_modules([\\/]|$)/.test(path_1.default.relative(base, src)))
465
+ return;
466
+ const st = (0, fs_1.statSync)(src);
467
+ if (st.isDirectory()) {
468
+ (0, fs_1.mkdirSync)(dest, { recursive: true });
469
+ for (const name of (0, fs_1.readdirSync)(src))
470
+ copyTree(path_1.default.join(src, name), path_1.default.join(dest, name), base);
471
+ }
472
+ else if (st.isFile()) {
473
+ (0, fs_1.mkdirSync)(path_1.default.dirname(dest), { recursive: true });
474
+ (0, fs_1.copyFileSync)(src, dest);
475
+ }
476
+ }
477
+ function walkFiles(dir, rel = '', skipNodeModules = true) {
478
+ const out = [];
479
+ let names;
480
+ try {
481
+ names = (0, fs_1.readdirSync)(path_1.default.join(dir, rel)).sort();
482
+ }
483
+ catch {
484
+ return out;
485
+ }
486
+ for (const name of names) {
487
+ if (skipNodeModules && name === 'node_modules')
488
+ continue;
489
+ const r = rel ? `${rel}/${name}` : name;
490
+ const st = (0, fs_1.statSync)(path_1.default.join(dir, r));
491
+ if (st.isDirectory())
492
+ out.push(...walkFiles(dir, r, skipNodeModules));
493
+ else if (st.isFile())
494
+ out.push(r);
495
+ }
496
+ return out;
497
+ }
498
+ /** One hash for the package files an engine copy is made of (LF-read), to tell two builds of one version apart. */
499
+ function packageHash(root) {
500
+ const h = (0, crypto_1.createHash)('sha256');
501
+ for (const entry of ['package.json', ...shippedEntries(root)]) {
502
+ const st = (0, fs_1.statSync)(path_1.default.join(root, entry));
503
+ const files = st.isDirectory() ? walkFiles(root, entry) : [entry];
504
+ for (const f of files) {
505
+ if (/(^|\/)\.crbro-engine\.json$/.test(f))
506
+ continue;
507
+ h.update(f).update('\0').update((0, installio_js_1.lfSha)((0, fs_1.readFileSync)(path_1.default.join(root, ...f.split('/'))))).update('\0');
508
+ }
509
+ }
510
+ return h.digest('hex');
511
+ }
512
+ exports.LAUNCHER_SOURCE = `#!/usr/bin/env node
513
+ // CRBRO launcher, written by \`crbro-memory install\`. MCP clients run this file;
514
+ // it starts the engine version current.json names, from this same folder.
515
+ // \`crbro-memory install\` with a newer CRBRO adds that version and updates
516
+ // current.json; \`crbro-memory uninstall\` removes this whole folder.
517
+ import { readFileSync } from 'node:fs';
518
+ import { dirname, isAbsolute, join } from 'node:path';
519
+ import { fileURLToPath, pathToFileURL } from 'node:url';
520
+
521
+ const here = dirname(fileURLToPath(import.meta.url));
522
+ let dir;
523
+ try {
524
+ const current = JSON.parse(readFileSync(join(here, 'current.json'), 'utf8'));
525
+ dir = isAbsolute(current.dir) ? current.dir : join(here, current.dir);
526
+ } catch (e) {
527
+ process.stderr.write('[crbro] ' + join(here, 'current.json') + ' is missing or unreadable: run npx crbro-memory install again.\\n');
528
+ process.exit(1);
529
+ }
530
+ await import(pathToFileURL(join(dir, 'bin', 'crbro.mjs')).href);
531
+ `;
532
+ /** True when the package runs from inside the engine folder already. */
533
+ function inside(child, parent) {
534
+ const rel = path_1.default.relative(path_1.default.resolve(parent), path_1.default.resolve(child));
535
+ return !!rel && !rel.startsWith('..') && !path_1.default.isAbsolute(rel);
536
+ }
537
+ function installEngine(ctx, steps) {
538
+ const client = 'Engine';
539
+ const root = ctx.engineDir ? path_1.default.resolve(ctx.engineDir) : (0, installio_js_1.engineRoot)(ctx.home, ctx.env);
540
+ const version = packageVersion(ctx.packageRoot);
541
+ const dir = path_1.default.join(root, version);
542
+ const launcher = path_1.default.join(root, exports.LAUNCHER);
543
+ const result = { root, dir, launcher, version };
544
+ if (!(0, fs_1.existsSync)(path_1.default.join(ctx.packageRoot, 'dist', 'index.js'))) {
545
+ step(steps, { client, status: 'failed', what: `${ctx.packageRoot} has no dist/index.js (run npm run build first)` });
546
+ return null;
547
+ }
548
+ const running = inside(ctx.packageRoot, root);
549
+ const { deps, missing } = running ? { deps: [], missing: [] } : runtimeDependencies(ctx.packageRoot);
550
+ if (missing.length) {
551
+ step(steps, { client, status: 'failed', what: `runtime dependencies not found next to ${ctx.packageRoot}: ${missing.join(', ')}` });
552
+ return null;
553
+ }
554
+ const hash = running ? null : packageHash(ctx.packageRoot);
555
+ let stamp = null;
556
+ try {
557
+ stamp = JSON.parse((0, fs_1.readFileSync)(path_1.default.join(dir, exports.ENGINE_STAMP), 'utf8'));
558
+ }
559
+ catch { /* not there */ }
560
+ const current = running || (stamp?.hash === hash && (0, fs_1.existsSync)(path_1.default.join(dir, 'dist', 'index.js')));
561
+ if (ctx.dryRun) {
562
+ step(steps, { client, status: current ? 'kept' : 'would',
563
+ what: current ? `${version} already in ${dir}` : `copy CRBRO ${version} and ${deps.length} runtime package(s) to ${dir}` });
564
+ }
565
+ else if (!current) {
566
+ const staging = `${dir}.${process.pid}.tmp`;
567
+ const aside = `${dir}.${process.pid}.old`;
568
+ try {
569
+ (0, fs_1.rmSync)(staging, { recursive: true, force: true });
570
+ (0, fs_1.mkdirSync)(staging, { recursive: true });
571
+ for (const entry of ['package.json', ...shippedEntries(ctx.packageRoot)]) {
572
+ copyTree(path_1.default.join(ctx.packageRoot, entry), path_1.default.join(staging, entry), ctx.packageRoot);
573
+ }
574
+ for (const d of deps) {
575
+ copyTree(d.dir, path_1.default.join(staging, 'node_modules', d.rel), d.dir);
576
+ }
577
+ (0, fs_1.writeFileSync)(path_1.default.join(staging, exports.ENGINE_STAMP), JSON.stringify({ version, hash, installed: new Date().toISOString(), from: ctx.packageRoot }, null, 2) + '\n');
578
+ let setAside = false;
579
+ if ((0, fs_1.existsSync)(dir)) {
580
+ (0, fs_1.renameSync)(dir, aside);
581
+ setAside = true;
582
+ }
583
+ try {
584
+ (0, fs_1.renameSync)(staging, dir);
585
+ }
586
+ catch (e) {
587
+ if (setAside)
588
+ try {
589
+ (0, fs_1.renameSync)(aside, dir);
590
+ }
591
+ catch { /* left as .old */ }
592
+ throw e;
593
+ }
594
+ if (setAside)
595
+ (0, fs_1.rmSync)(aside, { recursive: true, force: true, maxRetries: 3 });
596
+ step(steps, { client, status: 'done', what: `CRBRO ${version} and ${deps.length} runtime package(s) copied to ${dir}` });
597
+ }
598
+ catch (e) {
599
+ (0, fs_1.rmSync)(staging, { recursive: true, force: true });
600
+ step(steps, { client, status: 'failed', what: `could not copy the engine to ${dir}: ${e.message}` });
601
+ return null;
602
+ }
603
+ }
604
+ else {
605
+ step(steps, { client, status: 'kept', what: `CRBRO ${version} already in ${dir}` });
606
+ }
607
+ // current.json and the launcher.
608
+ let previous = null;
609
+ try {
610
+ previous = JSON.parse((0, fs_1.readFileSync)(path_1.default.join(root, exports.CURRENT), 'utf8')).dir ?? null;
611
+ }
612
+ catch { /* first install */ }
613
+ if (!ctx.dryRun) {
614
+ try {
615
+ (0, fs_1.mkdirSync)(root, { recursive: true });
616
+ if (previous !== version) {
617
+ const tmp = path_1.default.join(root, `${exports.CURRENT}.${process.pid}.tmp`);
618
+ (0, fs_1.writeFileSync)(tmp, JSON.stringify({ version, dir: version, updated: new Date().toISOString() }, null, 2) + '\n');
619
+ (0, fs_1.renameSync)(tmp, path_1.default.join(root, exports.CURRENT));
620
+ step(steps, { client, status: 'done', what: `current version: ${version}${previous ? ` (was ${previous})` : ''}`, file: path_1.default.join(root, exports.CURRENT) });
621
+ }
622
+ let launcherText = '';
623
+ try {
624
+ launcherText = (0, fs_1.readFileSync)(launcher, 'utf8');
625
+ }
626
+ catch { /* none */ }
627
+ if (launcherText !== exports.LAUNCHER_SOURCE) {
628
+ (0, fs_1.writeFileSync)(launcher, exports.LAUNCHER_SOURCE);
629
+ step(steps, { client, status: 'done', what: `launcher written: ${launcher}` });
630
+ }
631
+ }
632
+ catch (e) {
633
+ step(steps, { client, status: 'failed', what: `could not write ${path_1.default.join(root, exports.CURRENT)}: ${e.message}` });
634
+ return null;
635
+ }
636
+ // Older versions go; the one just replaced stays, in case a running client still uses it.
637
+ try {
638
+ for (const name of (0, fs_1.readdirSync)(root)) {
639
+ const p = path_1.default.join(root, name);
640
+ if (name === version || name === previous || !/^\d+\.\d+\.\d+/.test(name) || !(0, fs_1.statSync)(p).isDirectory())
641
+ continue;
642
+ if (/\.\d+\.(tmp|old)$/.test(name))
643
+ continue;
644
+ (0, fs_1.rmSync)(p, { recursive: true, force: true, maxRetries: 3 });
645
+ step(steps, { client, status: 'done', what: `old engine ${name} removed` });
646
+ }
647
+ }
648
+ catch { /* best effort */ }
649
+ }
650
+ else if (previous !== version) {
651
+ step(steps, { client, status: 'would', what: `point ${path_1.default.join(root, exports.CURRENT)} at ${version} and write the launcher ${launcher}` });
652
+ }
653
+ return result;
654
+ }
655
+ function sameSpec(entry, spec) {
656
+ return isObject(entry) && entry.command === spec.command && JSON.stringify(entry.args ?? []) === JSON.stringify(spec.args);
657
+ }
658
+ function specOf(entry) {
659
+ return isObject(entry) ? [entry.command ?? entry.url ?? '?', ...(Array.isArray(entry.args) ? entry.args : [])].join(' ') : '?';
660
+ }
661
+ /** Claude Desktop and Cursor: mcpServers.crbro in a JSON file the client reads. */
662
+ function registerJson(ctx, steps, client, file, spec) {
663
+ if (!(0, fs_1.existsSync)(path_1.default.dirname(file))) {
664
+ step(steps, { client, status: 'skipped', what: `${path_1.default.dirname(file)} not found` });
665
+ return;
666
+ }
667
+ let outcome = null;
668
+ const apply = (s) => {
669
+ if (s.mcpServers !== undefined && !isObject(s.mcpServers))
670
+ throw new Error(`mcpServers in ${file} is not an object — not touching it.`);
671
+ const servers = s.mcpServers ?? {};
672
+ const existing = servers[codexconfig_js_1.SERVER_NAME];
673
+ if (sameSpec(existing, spec)) {
674
+ outcome = { client, status: 'kept', what: 'already runs the stable engine.', file };
675
+ return false;
676
+ }
677
+ const env = isObject(existing?.env) ? existing.env : undefined;
678
+ servers[codexconfig_js_1.SERVER_NAME] = { ...(isObject(existing) ? existing : {}), command: spec.command, args: spec.args, ...(env ? { env } : {}) };
679
+ delete servers[codexconfig_js_1.SERVER_NAME].url;
680
+ s.mcpServers = servers;
681
+ outcome = { client, status: ctx.dryRun ? 'would' : 'done',
682
+ what: existing ? `registration now runs ${specOf(spec)} (was: ${specOf(existing)}${env ? '; its env is kept' : ''})` : `registered: ${specOf(spec)}`, file };
683
+ return !ctx.dryRun;
684
+ };
685
+ const r = ctx.dryRun ? dry(file, apply) : (0, installio_js_1.updateJsonFile)(file, apply);
686
+ if (!r.ok) {
687
+ step(steps, { client, status: 'failed', what: r.error, file });
688
+ return;
689
+ }
690
+ if (outcome)
691
+ step(steps, { ...outcome, backup: r.changed ? r.backup : undefined });
692
+ }
693
+ /** Claude Code: through `claude mcp add-json --scope user`, never by editing ~/.claude.json. */
694
+ function registerClaudeCode(ctx, steps, spec) {
695
+ const client = 'Claude Code';
696
+ const file = (0, installio_js_1.claudeJson)(ctx.home, ctx.env);
697
+ const read = (0, installio_js_1.readJsonObject)(file);
698
+ const existing = read.ok ? read.value?.mcpServers?.[codexconfig_js_1.SERVER_NAME] : undefined;
699
+ if (sameSpec(existing, spec)) {
700
+ step(steps, { client, status: 'kept', what: 'MCP server already runs the stable engine (user scope).' });
701
+ return;
702
+ }
703
+ const env = isObject(existing?.env) ? existing.env : undefined;
704
+ const json = JSON.stringify({ type: 'stdio', command: spec.command, args: spec.args, ...(env ? { env } : {}) });
705
+ const manual = `claude mcp add-json --scope user ${codexconfig_js_1.SERVER_NAME} '${json}'`;
706
+ if (!ctx.claude) {
707
+ step(steps, { client, status: 'warning', what: `the claude CLI is not on PATH, so CRBRO was not registered. Run: ${manual}` });
708
+ return;
709
+ }
710
+ if (ctx.dryRun) {
711
+ step(steps, { client, status: 'would', what: existing ? `re-register through the claude CLI: ${specOf(spec)} (was: ${specOf(existing)})` : `register through the claude CLI: ${specOf(spec)}` });
712
+ return;
713
+ }
714
+ if (existing !== undefined) {
715
+ const rm = ctx.claude.run(['mcp', 'remove', '--scope', 'user', codexconfig_js_1.SERVER_NAME]);
716
+ if (rm.status !== 0) {
717
+ step(steps, { client, status: 'failed', what: `claude mcp remove failed (${(rm.stderr || rm.stdout).trim().slice(0, 200)}); nothing changed` });
718
+ return;
719
+ }
720
+ }
721
+ const add = ctx.claude.run(['mcp', 'add-json', '--scope', 'user', codexconfig_js_1.SERVER_NAME, json]);
722
+ if (add.status !== 0) {
723
+ step(steps, { client, status: 'failed', what: `claude mcp add-json failed (${(add.stderr || add.stdout).trim().slice(0, 200)}). Run: ${manual}` });
724
+ return;
725
+ }
726
+ step(steps, { client, status: 'done', what: existing ? `MCP server re-registered (user scope): ${specOf(spec)} (was: ${specOf(existing)}${env ? '; its env is kept' : ''})` : `MCP server registered (user scope): ${specOf(spec)}` });
727
+ }
728
+ // ─── Which clients ───────────────────────────────────────────────
729
+ function detectClients(home, env, platform, claude) {
730
+ const out = [];
731
+ if (claude || (0, fs_1.existsSync)((0, installio_js_1.claudeDir)(home, env)))
732
+ out.push('claude-code');
733
+ if ((0, fs_1.existsSync)(path_1.default.dirname((0, installio_js_1.claudeDesktopConfig)(home, env, platform))))
734
+ out.push('claude-desktop');
735
+ if ((0, fs_1.existsSync)((0, installio_js_1.codexHome)(home, env)))
736
+ out.push('codex');
737
+ if ((0, fs_1.existsSync)(path_1.default.dirname((0, installio_js_1.cursorConfig)(home))))
738
+ out.push('cursor');
739
+ return out;
740
+ }
741
+ /** "auto" or a comma list of client ids; throws on an unknown one. */
742
+ function parseClients(spec, detected) {
743
+ if (!spec || spec === 'auto')
744
+ return detected();
745
+ const list = spec.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
746
+ const bad = list.filter(c => !exports.ALL_CLIENTS.includes(c));
747
+ if (bad.length || !list.length)
748
+ throw new Error(`unknown client "${bad.join(', ') || spec}": use auto or a comma list of ${exports.ALL_CLIENTS.join(', ')}`);
749
+ return [...new Set(list)];
750
+ }
751
+ function install(ctx, clients) {
752
+ const steps = [];
753
+ const engine = installEngine(ctx, steps);
754
+ if (!engine)
755
+ return { steps, clients, engine, hooks: [] };
756
+ const spec = { command: ctx.nodePath, args: [engine.launcher] };
757
+ if (!clients.length)
758
+ step(steps, { client: 'Clients', status: 'warning', what: 'no MCP client found (Claude Code, Claude Desktop, Codex, Cursor); pass --clients to name them' });
759
+ for (const c of clients) {
760
+ if (c === 'claude-code') {
761
+ registerClaudeCode(ctx, steps, spec);
762
+ wireClaudeBoot(ctx, steps);
763
+ }
764
+ else if (c === 'claude-desktop') {
765
+ registerJson(ctx, steps, 'Claude Desktop', (0, installio_js_1.claudeDesktopConfig)(ctx.home, ctx.env, ctx.platform), spec);
766
+ }
767
+ else if (c === 'cursor') {
768
+ registerJson(ctx, steps, 'Cursor', (0, installio_js_1.cursorConfig)(ctx.home), spec);
769
+ }
770
+ else if (c === 'codex') {
771
+ wireCodex(ctx, steps, { command: spec.command, args: spec.args, replaceCommand: true, engineArg: engine.root });
772
+ }
773
+ }
774
+ // Hook copies installed earlier (install-hooks) are brought up to this version when unmodified.
775
+ const hooks = (0, hookrefresh_js_1.refreshHooks)({ home: ctx.home, env: ctx.env, pkgHooks: path_1.default.join(ctx.packageRoot, 'hooks'), dryRun: ctx.dryRun });
776
+ for (const h of hooks) {
777
+ if (h.state === 'stale')
778
+ step(steps, { client: 'Hooks', status: ctx.dryRun ? 'would' : h.error ? 'failed' : 'done', what: `${h.name} ${ctx.dryRun ? 'refresh to' : h.error ? `could not be refreshed (${h.error}) to` : 'refreshed to'} this version`, file: path_1.default.join(h.dir, h.name) });
779
+ if (h.state === 'modified')
780
+ step(steps, { client: 'Hooks', status: 'warning', what: `${h.name} was edited by hand: left as it is (install-hooks replaces it after a backup)`, file: path_1.default.join(h.dir, h.name) });
781
+ }
782
+ return { steps, clients, engine, hooks };
783
+ }
784
+ // ─── uninstall ───────────────────────────────────────────────────
785
+ const OUR_CLAUDE_HOOKS = /crbro-(lifecycle|guard|subagent)\.mjs/;
786
+ /** Takes out of a hooks object every hook CRBRO's installers write. Returns how many went. */
787
+ function stripOurHooks(hooks, isOurs) {
788
+ let removed = 0;
789
+ for (const event of Object.keys(hooks)) {
790
+ const list = hooks[event];
791
+ if (!Array.isArray(list))
792
+ continue;
793
+ const kept = [];
794
+ for (const entry of list) {
795
+ const hs = Array.isArray(entry?.hooks) ? entry.hooks : null;
796
+ if (!hs) {
797
+ kept.push(entry);
798
+ continue;
799
+ }
800
+ const rest = hs.filter((h) => !isOurs(h));
801
+ removed += hs.length - rest.length;
802
+ if (rest.length)
803
+ kept.push(rest.length === hs.length ? entry : { ...entry, hooks: rest });
804
+ }
805
+ if (kept.length)
806
+ hooks[event] = kept;
807
+ else
808
+ delete hooks[event];
809
+ }
810
+ return removed;
811
+ }
812
+ function removeHookFolder(ctx, steps, client, dir) {
813
+ if (!(0, fs_1.existsSync)(dir))
814
+ return;
815
+ const ours = (0, fs_1.readdirSync)(dir).filter(n => /^crbro-[\w-]+\.mjs$/.test(n) || n === hookrefresh_js_1.HOOK_MANIFEST || /^crbro-[\w-]+\.replaced\.json$/.test(n));
816
+ if (ctx.dryRun) {
817
+ if (ours.length)
818
+ step(steps, { client, status: 'would', what: `delete ${ours.length} CRBRO hook file(s) in ${dir} (backups kept)` });
819
+ return;
820
+ }
821
+ for (const n of ours)
822
+ (0, fs_1.rmSync)(path_1.default.join(dir, n), { force: true });
823
+ let left = [];
824
+ try {
825
+ left = (0, fs_1.readdirSync)(dir);
826
+ }
827
+ catch { /* gone */ }
828
+ if (!left.length) {
829
+ try {
830
+ (0, fs_1.rmSync)(dir, { recursive: true, force: true });
831
+ }
832
+ catch { /* keep */ }
833
+ }
834
+ if (ours.length)
835
+ step(steps, { client, status: 'done', what: `${ours.length} CRBRO hook file(s) deleted from ${dir}${left.length ? ` (${left.length} other file(s) kept there, backups included)` : ''}` });
836
+ }
837
+ function uninstall(ctx, clients, opts) {
838
+ const steps = [];
839
+ for (const c of clients) {
840
+ if (c === 'claude-code')
841
+ uninstallClaudeCode(ctx, steps, opts.uninstallMod);
842
+ else if (c === 'claude-desktop')
843
+ unregisterJson(ctx, steps, 'Claude Desktop', (0, installio_js_1.claudeDesktopConfig)(ctx.home, ctx.env, ctx.platform));
844
+ else if (c === 'cursor')
845
+ unregisterJson(ctx, steps, 'Cursor', (0, installio_js_1.cursorConfig)(ctx.home));
846
+ else if (c === 'codex')
847
+ uninstallCodex(ctx, steps);
848
+ }
849
+ // The engine folder goes too, unless --clients named only some clients: the others still run it.
850
+ if (opts.removeEngine) {
851
+ const root = ctx.engineDir ? path_1.default.resolve(ctx.engineDir) : (0, installio_js_1.engineRoot)(ctx.home, ctx.env);
852
+ if ((0, fs_1.existsSync)(root)) {
853
+ if (ctx.dryRun)
854
+ step(steps, { client: 'Engine', status: 'would', what: `delete ${root}` });
855
+ else {
856
+ try {
857
+ (0, fs_1.rmSync)(root, { recursive: true, force: true, maxRetries: 3 });
858
+ step(steps, { client: 'Engine', status: 'done', what: `deleted ${root}` });
859
+ }
860
+ catch (e) {
861
+ step(steps, { client: 'Engine', status: 'failed', what: `could not delete ${root} (${e.message}); close the clients that run it and run uninstall again` });
862
+ }
863
+ }
864
+ }
865
+ }
866
+ return { steps, brain: opts.brainDir };
867
+ }
868
+ function uninstallClaudeCode(ctx, steps, uninstallMod) {
869
+ const client = 'Claude Code';
870
+ // Registration.
871
+ const read = (0, installio_js_1.readJsonObject)((0, installio_js_1.claudeJson)(ctx.home, ctx.env));
872
+ const existing = read.ok ? read.value?.mcpServers?.[codexconfig_js_1.SERVER_NAME] : undefined;
873
+ if (existing !== undefined) {
874
+ if (!(0, codexconfig_js_1.pointsAtCrbro)(existing?.command ?? null, Array.isArray(existing?.args) ? existing.args : [])) {
875
+ step(steps, { client, status: 'kept', what: `an MCP server named ${codexconfig_js_1.SERVER_NAME} runs ${specOf(existing)}, not CRBRO: left alone` });
876
+ }
877
+ else if (!ctx.claude) {
878
+ step(steps, { client, status: 'warning', what: `the claude CLI is not on PATH. Run: claude mcp remove --scope user ${codexconfig_js_1.SERVER_NAME}` });
879
+ }
880
+ else if (ctx.dryRun) {
881
+ step(steps, { client, status: 'would', what: `claude mcp remove --scope user ${codexconfig_js_1.SERVER_NAME}` });
882
+ }
883
+ else {
884
+ const r = ctx.claude.run(['mcp', 'remove', '--scope', 'user', codexconfig_js_1.SERVER_NAME]);
885
+ step(steps, r.status === 0 ? { client, status: 'done', what: 'MCP server unregistered (user scope).' }
886
+ : { client, status: 'failed', what: `claude mcp remove failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}` });
887
+ }
888
+ }
889
+ // Hooks in settings.json.
890
+ const dir = (0, installio_js_1.claudeDir)(ctx.home, ctx.env);
891
+ const file = path_1.default.join(dir, 'settings.json');
892
+ if ((0, fs_1.existsSync)(file)) {
893
+ let removed = 0;
894
+ const apply = (s) => {
895
+ if (!isObject(s.hooks))
896
+ return false;
897
+ removed = stripOurHooks(s.hooks, h => isInstallBootHook(h) || (typeof h?.command === 'string' && OUR_CLAUDE_HOOKS.test(h.command)));
898
+ if (Object.keys(s.hooks).length === 0)
899
+ delete s.hooks;
900
+ const left = scanCrbroHooks(Object.values(isObject(s.hooks) ? s.hooks : {}).flat(), ctx.home);
901
+ if (left.mentions)
902
+ steps.push({ client, status: 'warning', what: `${left.mentions} hook(s) you wrote yourself still mention CRBRO: left alone`, file });
903
+ return removed > 0 && !ctx.dryRun;
904
+ };
905
+ const r = ctx.dryRun ? dry(file, apply) : (0, installio_js_1.updateJsonFile)(file, apply);
906
+ if (!r.ok)
907
+ step(steps, { client, status: 'failed', what: r.error, file });
908
+ else if (removed)
909
+ step(steps, { client, status: ctx.dryRun ? 'would' : 'done', what: `${removed} CRBRO hook(s) removed from settings.json`, file, backup: r.changed ? r.backup : undefined });
910
+ }
911
+ removeHookFolder(ctx, steps, client, path_1.default.join(dir, 'crbro-hooks'));
912
+ // The mod, through uninstall-mod's own logic.
913
+ if (uninstallMod && (0, fs_1.existsSync)(path_1.default.join(dir, 'crbro-mods'))) {
914
+ if (ctx.dryRun)
915
+ step(steps, { client, status: 'would', what: 'uninstall the mod (npx crbro-memory uninstall-mod)' });
916
+ else {
917
+ const m = uninstallMod(ctx.home);
918
+ step(steps, m.ok ? { client, status: 'done', what: 'mod removed (uninstall-mod); CRBRO will not put it back on its own' }
919
+ : { client, status: 'failed', what: `uninstall-mod: ${m.error}` });
920
+ }
921
+ }
922
+ }
923
+ function unregisterJson(ctx, steps, client, file) {
924
+ if (!(0, fs_1.existsSync)(file))
925
+ return;
926
+ let outcome = null;
927
+ const apply = (s) => {
928
+ const e = isObject(s.mcpServers) ? s.mcpServers[codexconfig_js_1.SERVER_NAME] : undefined;
929
+ if (e === undefined)
930
+ return false;
931
+ if (!(0, codexconfig_js_1.pointsAtCrbro)(e?.command ?? null, Array.isArray(e?.args) ? e.args : [])) {
932
+ outcome = { client, status: 'kept', what: `${codexconfig_js_1.SERVER_NAME} runs ${specOf(e)}, not CRBRO: left alone`, file };
933
+ return false;
934
+ }
935
+ delete s.mcpServers[codexconfig_js_1.SERVER_NAME];
936
+ outcome = { client, status: ctx.dryRun ? 'would' : 'done', what: 'MCP server unregistered.', file };
937
+ return !ctx.dryRun;
938
+ };
939
+ const r = ctx.dryRun ? dry(file, apply) : (0, installio_js_1.updateJsonFile)(file, apply);
940
+ if (!r.ok) {
941
+ step(steps, { client, status: 'failed', what: r.error, file });
942
+ return;
943
+ }
944
+ if (outcome)
945
+ step(steps, { ...outcome, backup: r.changed ? r.backup : undefined });
946
+ }
947
+ function uninstallCodex(ctx, steps) {
948
+ const client = 'Codex';
949
+ const dir = (0, installio_js_1.codexHome)(ctx.home, ctx.env);
950
+ const tomlFile = path_1.default.join(dir, 'config.toml');
951
+ if ((0, fs_1.existsSync)(tomlFile)) {
952
+ const read = (0, installio_js_1.readText)(tomlFile);
953
+ const r = (0, codexconfig_js_1.removeCodexServer)((read.bom ? '\ufeff' : '') + read.text);
954
+ if (r.refused)
955
+ step(steps, { client, status: 'warning', what: `config.toml: ${r.refused}`, file: tomlFile });
956
+ else if (r.notOurs)
957
+ step(steps, { client, status: 'kept', what: `config.toml: ${codexconfig_js_1.SERVER_NAME} runs ${r.notOurs}, not CRBRO: left alone`, file: tomlFile });
958
+ else if (r.changed) {
959
+ if (ctx.dryRun)
960
+ step(steps, { client, status: 'would', what: `config.toml: remove [mcp_servers.${codexconfig_js_1.SERVER_NAME}]`, file: tomlFile });
961
+ else {
962
+ try {
963
+ const backup = (0, installio_js_1.writeGuarded)(tomlFile, r.text, { expectHash: read.hash });
964
+ step(steps, { client, status: 'done', what: `config.toml: [mcp_servers.${codexconfig_js_1.SERVER_NAME}] removed`, file: tomlFile, backup });
965
+ }
966
+ catch (e) {
967
+ step(steps, { client, status: 'failed', what: `config.toml: ${e.message}`, file: tomlFile });
968
+ }
969
+ }
970
+ }
971
+ }
972
+ const hooksFile = path_1.default.join(dir, 'hooks.json');
973
+ if ((0, fs_1.existsSync)(hooksFile)) {
974
+ let removed = 0;
975
+ const apply = (s) => {
976
+ if (!isObject(s.hooks))
977
+ return false;
978
+ removed = stripOurHooks(s.hooks, h => isInstallBootMcpCall(h) || isInstallBootHook(h)
979
+ || (typeof h?.command === 'string' && (h.command.includes(exports.CODEX_HOOK) || OUR_CLAUDE_HOOKS.test(h.command))));
980
+ const left = scanCrbroHooks(Object.values(s.hooks).flat(), ctx.home);
981
+ if (left.mentions)
982
+ steps.push({ client, status: 'warning', what: `${left.mentions} hook(s) you wrote yourself still mention CRBRO: left alone`, file: hooksFile });
983
+ if (Object.keys(s.hooks).length === 0)
984
+ delete s.hooks;
985
+ return removed > 0 && !ctx.dryRun;
986
+ };
987
+ const r = ctx.dryRun ? dry(hooksFile, apply) : (0, installio_js_1.updateJsonFile)(hooksFile, apply);
988
+ if (!r.ok)
989
+ step(steps, { client, status: 'failed', what: r.error, file: hooksFile });
990
+ else if (removed)
991
+ step(steps, { client, status: ctx.dryRun ? 'would' : 'done', what: `${removed} CRBRO hook(s) removed from hooks.json`, file: hooksFile, backup: r.changed ? r.backup : undefined });
992
+ }
993
+ removeHookFolder(ctx, steps, client, path_1.default.join(dir, 'crbro-hooks'));
994
+ }
995
+ // ─── Output ──────────────────────────────────────────────────────
996
+ const ICON = { done: '✅', would: '→ ', kept: '⚪', skipped: '⚪', warning: '⚠️ ', failed: '❌' };
997
+ function formatSteps(steps) {
998
+ const lines = [];
999
+ for (const s of steps) {
1000
+ lines.push(` ${ICON[s.status]} ${s.client}: ${s.what}`);
1001
+ if (s.file && s.status !== 'kept' && s.status !== 'skipped')
1002
+ lines.push(` ${s.file}`);
1003
+ if (s.backup)
1004
+ lines.push(` backup: ${s.backup}`);
1005
+ }
1006
+ return lines;
1007
+ }
1008
+ function failed(steps) {
1009
+ return steps.some(s => s.status === 'failed');
1010
+ }
1011
+ /** For doctor: does a Codex registration carry both timeouts, and does its command exist? */
1012
+ function codexRegistration(home, env) {
1013
+ const file = path_1.default.join((0, installio_js_1.codexHome)(home, env), 'config.toml');
1014
+ try {
1015
+ const info = (0, codexconfig_js_1.readCodexServer)((0, installio_js_1.readText)(file).text);
1016
+ return { file, ...info };
1017
+ }
1018
+ catch {
1019
+ return null;
1020
+ }
1021
+ }
1022
+ //# sourceMappingURL=installer.js.map