@evomap/evolver-mcp 2.0.0-beta.2 → 2.0.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/installer.js CHANGED
@@ -7,12 +7,13 @@
7
7
  // the agent discovers evolver's tools, AND merge a SessionStart hook into .claude/settings.json so memory is
8
8
  // pushed at session start (MCP alone can't push — the agent must pull). Hardened like v1: atomic writes
9
9
  // (tmp+rename), refusal to follow a symlink at any adapter-owned path (a hostile workspace could redirect
10
- // writes/unlinks outside the project), marker-managed so reinstall/uninstall only touch evolver's own entries,
10
+ // writes/unlinks outside the project), conditionally committed so concurrent runtime updates are preserved,
11
+ // marker-managed so reinstall/uninstall only touch evolver's own entries,
11
12
  // and a hooks-UNION merge that preserves the user's existing hooks.
12
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
- import { randomUUID } from 'node:crypto';
13
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs';
14
+ import { createHash } from 'node:crypto';
14
15
  import { homedir } from 'node:os';
15
- import { join } from 'node:path';
16
+ import { dirname, join } from 'node:path';
16
17
  import { util } from '@evomap/evolver-core';
17
18
  import { planInjection } from './injection.js';
18
19
  // codex installer lives in its own module (TOML, different config path) but plugs into the same install/uninstall
@@ -22,49 +23,26 @@ import { installCodex, uninstallCodex } from './codexInstaller.js';
22
23
  // cursor injection is a different mechanism again (a project rules file, not a config/MCP writer): it renders
23
24
  // top genes into .cursor/rules/evolver.mdc. It plugs into the same install/uninstall dispatch below.
24
25
  import { installCursorRules, uninstallCursorRules } from './cursorRulesInstaller.js';
26
+ import { withClaudeProductBridge, isOwnedProductBridge, restoreProductBridgeEntry, PRODUCT_BRIDGE_SERVER_ID } from './productBridge.js';
25
27
  import { installAntigravity, uninstallAntigravity } from './antigravityInstaller.js';
28
+ import { installOpenCode, uninstallOpenCode } from './opencodeInstaller.js';
29
+ import { installKiro, uninstallKiro } from './kiroInstaller.js';
30
+ import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
31
+ import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
32
+ export { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
26
33
  /** Marks a config file as containing evolver-managed entries, so uninstall only removes what we added. */
27
34
  export const MANAGED_MARKER = '_evolver_managed';
28
- /** A hook entry is evolver-owned if any of its commands mention this used to replace-not-duplicate on reinstall. */
29
- const EVOLVER_HOOK_TAG = 'evolver';
35
+ /** Official command-handler metadata, also gives custom commands an ownership marker for reinstall/uninstall. */
36
+ export const EVOLVER_HOOK_STATUS = 'Loading Evolver memory';
30
37
  /** Default command the SessionStart hook runs to render + print the memory injection. The `--hook-stdin` flag opts
31
38
  * the entrypoint into reading the runtime's SessionStart JSON from stdin (to capture session_id, #205); only the
32
39
  * installed hook sets it, so a manual `evolver inject session-start` never reads stdin. */
33
40
  export const DEFAULT_HOOK_COMMAND = 'evolver inject session-start --hook-stdin';
41
+ /** Local-only prompt recall. The handler is default-off and reads stdin only when EVOLVER_RECALL_MODE opts in. */
42
+ export const DEFAULT_PROMPT_RECALL_HOOK_COMMAND = 'evolver inject prompt-recall --hook-stdin';
34
43
  const SHARED_USER_CONFIG_MODE = 0o600;
35
44
  const SHARED_USER_DIR_MODE = 0o700;
36
45
  const SHARED_USER_CONFIG_WRITE_RETRIES = 5;
37
- export class SymlinkRefusedError extends Error {
38
- constructor(label, path) {
39
- super(`[setup-hooks] refusing to operate: ${label} ${path} is a symbolic link — evolver will not follow symlinks for adapter-owned paths (a hostile workspace could redirect writes/unlinks outside the project). Replace it with a real directory/file and rerun.`);
40
- this.name = 'SymlinkRefusedError';
41
- }
42
- }
43
- /**
44
- * Thrown when a SHARED user config (~/.claude.json or ~/.claude/settings.json) exists but does not parse as JSON.
45
- * These files are Claude Code's own state (projects/oauthAccount/userID/history/settings),
46
- * and user-scope install merges into them via a full-file atomic replace. The lenient readJson() returns {} on
47
- * a parse failure, which would make the merge emit ONLY evolver's entry and silently WIPE the whole file — a
48
- * realistic data-loss path because Claude Code writes these files non-atomically (a concurrent session can leave
49
- * one truncated). For the shared-config read we therefore refuse instead of clobbering. Project-scoped
50
- * .mcp.json/.claude/settings.json are evolver-owned, so their lenient fresh-start behavior stays unchanged.
51
- */
52
- export class UnparseableConfigError extends Error {
53
- constructor(label, path, owner = 'Claude Code') {
54
- super(`[setup-hooks] refusing to overwrite ${label} (${path}): the file exists and is non-empty but is not valid JSON. This is ${owner}'s own shared config; merging into it would replace the whole file and could wipe its contents. Fix or remove the corrupt file, then rerun.`);
55
- this.name = 'UnparseableConfigError';
56
- }
57
- }
58
- /**
59
- * Thrown when a SHARED user config exists but is empty or whitespace-only. Claude Code writes these files with a
60
- * truncating write, so present-empty can be a concurrent-write window rather than a fresh config.
61
- */
62
- export class EmptySharedConfigError extends Error {
63
- constructor(label, path, owner = 'Claude Code') {
64
- super(`[setup-hooks] refusing to overwrite ${label} (${path}): the file exists but is empty or contains only whitespace. ${owner} may be in the middle of a truncating write, and treating it as fresh config could wipe shared config data. Fix the empty file or retry after ${owner} finishes writing it.`);
65
- this.name = 'EmptySharedConfigError';
66
- }
67
- }
68
46
  // ── fs hardening ────────────────────────────────────────────────────────────
69
47
  /** Refuse to read/write through a symlink at an adapter-owned path. Missing path is fine (install creates it). */
70
48
  function assertNotSymlink(path, label) {
@@ -80,23 +58,33 @@ function assertNotSymlink(path, label) {
80
58
  if (st.isSymbolicLink())
81
59
  throw new SymlinkRefusedError(label, path);
82
60
  }
83
- function readJson(path) {
61
+ /** Validate a shared config path without following either its parent or the target. */
62
+ function validatedSharedConfigMode(path, label) {
63
+ const parentPath = dirname(path);
64
+ const parent = lstatSync(parentPath);
65
+ if (parent.isSymbolicLink())
66
+ throw new SymlinkRefusedError(`${label} parent directory`, parentPath);
67
+ if (!parent.isDirectory()) {
68
+ throw new Error(`[setup-hooks] refusing to operate: parent of ${label} (${parentPath}) is not a directory.`);
69
+ }
70
+ let target;
84
71
  try {
85
- if (!existsSync(path))
86
- return {};
87
- const raw = readFileSync(path, 'utf8').trim();
88
- return raw ? JSON.parse(raw) : {};
72
+ target = lstatSync(path);
89
73
  }
90
- catch {
91
- return {}; // unparseable → start fresh (merge will re-add evolver entries)
74
+ catch (error) {
75
+ if (error.code === 'ENOENT')
76
+ return undefined;
77
+ throw error;
92
78
  }
79
+ if (target.isSymbolicLink())
80
+ throw new SymlinkRefusedError(label, path);
81
+ if (!target.isFile()) {
82
+ throw new Error(`[setup-hooks] refusing to operate: ${label} ${path} is not a regular file.`);
83
+ }
84
+ return target.mode & 0o777;
93
85
  }
94
86
  /**
95
- * Strict variant for SHARED user configs (Claude Code's own ~/.claude.json / ~/.claude/settings.json). Only
96
- * ENOENT is treated as {} so a fresh user-scope install works. A present empty/whitespace file is refused because
97
- * it can be Claude Code's truncating-write window; a present non-empty parse failure is refused because returning
98
- * {} would make the subsequent full-file atomic write clobber Claude Code's state. Use this only for the
99
- * shared-config read; project-scoped evolver-owned files keep the lenient readJson() above.
87
+ * Only ENOENT is treated as a fresh config. Empty or malformed existing files are never replaced.
100
88
  */
101
89
  function readJsonStrictShared(path, label) {
102
90
  return readJsonStrictSharedSnapshot(path, label).data;
@@ -131,18 +119,8 @@ function readRawIfExists(path) {
131
119
  throw e;
132
120
  }
133
121
  }
134
- function existingFileMode(path) {
135
- try {
136
- return statSync(path).mode & 0o777;
137
- }
138
- catch (e) {
139
- if (e.code === 'ENOENT')
140
- return undefined;
141
- throw e;
142
- }
143
- }
144
- function sharedUserConfigWriteMode(path) {
145
- const existingMode = existingFileMode(path);
122
+ function sharedUserConfigWriteMode(path, label) {
123
+ const existingMode = validatedSharedConfigMode(path, label);
146
124
  return existingMode === undefined ? SHARED_USER_CONFIG_MODE : existingMode & 0o700;
147
125
  }
148
126
  function ensureSharedUserClaudeDir(path) {
@@ -186,38 +164,6 @@ function hardenSharedUserConfigFile(path, label) {
186
164
  if (hardenedMode !== currentMode)
187
165
  chmodSync(path, hardenedMode);
188
166
  }
189
- function writeJsonAtomic(path, data, options = {}) {
190
- const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
191
- const content = `${JSON.stringify(data, null, 2)}\n`;
192
- const mode = options.mode ?? existingFileMode(path);
193
- const restoreMode = process.platform === 'win32' ? mode : undefined;
194
- try {
195
- writeFileSync(tmp, content, mode === undefined
196
- ? { encoding: 'utf8', flag: 'wx' }
197
- : { encoding: 'utf8', flag: 'wx', mode });
198
- if (mode !== undefined)
199
- chmodSync(tmp, mode);
200
- if (process.platform === 'win32' && mode !== undefined && existsSync(path)) {
201
- chmodSync(path, mode | 0o200);
202
- }
203
- renameSync(tmp, path);
204
- if (mode !== undefined)
205
- chmodSync(path, mode);
206
- }
207
- catch (e) {
208
- rmSync(tmp, { force: true });
209
- if (restoreMode !== undefined) {
210
- try {
211
- if (existsSync(path))
212
- chmodSync(path, restoreMode);
213
- }
214
- catch (rollbackError) {
215
- e.rollbackError = rollbackError;
216
- }
217
- }
218
- throw e;
219
- }
220
- }
221
167
  let sharedConfigRaceHookForTest;
222
168
  export function _setSharedConfigRaceHookForTest(hook) {
223
169
  sharedConfigRaceHookForTest = hook;
@@ -225,23 +171,64 @@ export function _setSharedConfigRaceHookForTest(hook) {
225
171
  function writeSharedJsonWithRetry(path, label, update) {
226
172
  const lockPath = `${path}.evolver.lock`;
227
173
  util.acquireLock(lockPath);
174
+ let operationResult = false;
175
+ let operationFailed = false;
176
+ let operationError;
228
177
  try {
229
- for (let attempt = 1; attempt <= SHARED_USER_CONFIG_WRITE_RETRIES; attempt++) {
230
- const snapshot = readJsonStrictSharedSnapshot(path, label);
231
- const next = update(snapshot.data);
232
- if (!next.changed)
233
- return false;
234
- sharedConfigRaceHookForTest?.(path, attempt);
235
- if (readRawIfExists(path) !== snapshot.raw)
236
- continue;
237
- writeJsonAtomic(path, next.data, { mode: sharedUserConfigWriteMode(path) });
238
- return true;
239
- }
178
+ operationResult = (() => {
179
+ for (let attempt = 1; attempt <= SHARED_USER_CONFIG_WRITE_RETRIES; attempt++) {
180
+ validatedSharedConfigMode(path, label);
181
+ const snapshot = readJsonStrictSharedSnapshot(path, label);
182
+ validatedSharedConfigMode(path, label);
183
+ const next = update(snapshot.data);
184
+ if (!next.changed)
185
+ return false;
186
+ sharedConfigRaceHookForTest?.(path, attempt);
187
+ validatedSharedConfigMode(path, label);
188
+ const currentRaw = readRawIfExists(path);
189
+ validatedSharedConfigMode(path, label);
190
+ if (currentRaw !== snapshot.raw)
191
+ continue;
192
+ try {
193
+ commitSharedFile({
194
+ path,
195
+ expectedRaw: snapshot.raw ?? undefined,
196
+ nextRaw: `${JSON.stringify(next.data, null, 2)}\n`,
197
+ mode: sharedUserConfigWriteMode(path, label),
198
+ });
199
+ return true;
200
+ }
201
+ catch (error) {
202
+ if (error instanceof SharedFileConflictError)
203
+ continue;
204
+ throw error;
205
+ }
206
+ }
207
+ throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the file changed repeatedly while evolver was merging it. Rerun setup-hooks after Claude Code finishes writing this config.`);
208
+ })();
209
+ }
210
+ catch (error) {
211
+ operationFailed = true;
212
+ operationError = error;
240
213
  }
241
- finally {
242
- util.releaseLock(lockPath);
214
+ let releaseError;
215
+ try {
216
+ const released = util.releaseLock(lockPath);
217
+ if (!released.released)
218
+ releaseError = new util.LockReleaseError(released.reason);
219
+ }
220
+ catch (error) {
221
+ releaseError = error;
243
222
  }
244
- throw new Error(`[setup-hooks] refusing to overwrite ${label} (${path}): the file changed repeatedly while evolver was merging it. Rerun setup-hooks after Claude Code finishes writing this config.`);
223
+ if (operationFailed) {
224
+ if (operationError instanceof Error && releaseError !== undefined) {
225
+ operationError.lockReleaseError = releaseError;
226
+ }
227
+ throw operationError;
228
+ }
229
+ if (releaseError !== undefined)
230
+ throw releaseError;
231
+ return operationResult;
245
232
  }
246
233
  // ── pure merge (exported for tests) ──────────────────────────────────────────
247
234
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -273,7 +260,60 @@ function collectCommands(entry) {
273
260
  out.push(h['command']);
274
261
  return out;
275
262
  }
276
- const isEvolverOwned = (entry) => collectCommands(entry).some((c) => c.includes(EVOLVER_HOOK_TAG));
263
+ const LEGACY_NODE_COMMAND = /^"?node(?:\.exe)?"?\s+/i;
264
+ const LEGACY_EVOLVER_SCRIPT_BASENAME = /(?:^|[\\/'"\s])(?:evolver-session-start|evolver-session-end|evolver-signal-detect|evolver-task-recall|evolver-daemon-start)\.js(?=$|[\\/'"\s])/i;
265
+ const LEGACY_V2_CLI_HOOK_COMMAND = /^"?evolver(?:\.cmd|\.exe)?"?\s+inject\s+(?:session-start|prompt-recall)(?:\s|$)/i;
266
+ function isKnownEvolverHookCommand(command) {
267
+ // V1 invoked only these copied script basenames through node; pre-marker V2 used the two anchored CLI verbs.
268
+ // Do not use a generic `evolver` substring: user script paths and messages commonly contain that project name.
269
+ const trimmed = command.trim();
270
+ return LEGACY_V2_CLI_HOOK_COMMAND.test(trimmed)
271
+ || (LEGACY_NODE_COMMAND.test(trimmed) && LEGACY_EVOLVER_SCRIPT_BASENAME.test(trimmed));
272
+ }
273
+ /** Bind custom-command ownership to that exact command without adding undocumented hook-schema fields. */
274
+ export function evolverManagedHookStatus(command) {
275
+ if (isKnownEvolverHookCommand(command))
276
+ return EVOLVER_HOOK_STATUS;
277
+ const commandTag = createHash('sha256').update(command, 'utf8').digest('hex').slice(0, 16);
278
+ return `${EVOLVER_HOOK_STATUS} [evolver:${commandTag}]`;
279
+ }
280
+ const NO_MANAGED_HOOK_COMMANDS = new Set();
281
+ const isEvolverHandler = (handler, trustManagedStatus, managedCommands) => {
282
+ if (!isObj(handler) || typeof handler['command'] !== 'string')
283
+ return false;
284
+ const command = handler['command'];
285
+ return isKnownEvolverHookCommand(command)
286
+ || (trustManagedStatus && (managedCommands.has(command)
287
+ || handler['statusMessage'] === evolverManagedHookStatus(command)));
288
+ };
289
+ /** Remove only Evolver-owned handlers, retaining user handlers that share the same matcher group. */
290
+ export function stripEvolverHookEntries(entries, trustManagedStatus = false, managedCommands = NO_MANAGED_HOOK_COMMANDS) {
291
+ let changed = false;
292
+ const keptEntries = [];
293
+ for (const entry of entries) {
294
+ if (!isObj(entry)) {
295
+ keptEntries.push(entry);
296
+ continue;
297
+ }
298
+ const handlers = entry['hooks'];
299
+ if (!Array.isArray(handlers)) {
300
+ if (isEvolverHandler(entry, trustManagedStatus, managedCommands))
301
+ changed = true;
302
+ else
303
+ keptEntries.push(entry);
304
+ continue;
305
+ }
306
+ const keptHandlers = handlers.filter((handler) => !isEvolverHandler(handler, trustManagedStatus, managedCommands));
307
+ if (keptHandlers.length === handlers.length) {
308
+ keptEntries.push(entry);
309
+ continue;
310
+ }
311
+ changed = true;
312
+ if (keptHandlers.length > 0)
313
+ keptEntries.push({ ...entry, hooks: keptHandlers });
314
+ }
315
+ return { changed, entries: keptEntries };
316
+ }
277
317
  /**
278
318
  * deepMerge, but for `hooks.<event>` arrays keep the user's existing entries and only replace evolver-owned
279
319
  * ones — so reinstalling refreshes evolver's hook without clobbering a user's own SessionStart/Stop hooks.
@@ -282,16 +322,41 @@ export function mergeHooksUnion(target, source) {
282
322
  const result = deepMerge(target, source);
283
323
  const tHooks = target['hooks'];
284
324
  const sHooks = source['hooks'];
325
+ const trustManagedStatus = target[MANAGED_MARKER] === true;
285
326
  if (isObj(tHooks) && isObj(sHooks)) {
286
- const merged = { ...(isObj(result['hooks']) ? result['hooks'] : {}) };
327
+ const merged = {};
328
+ const managedCommands = new Set();
329
+ for (const value of Object.values(sHooks)) {
330
+ if (!Array.isArray(value))
331
+ continue;
332
+ for (const entry of value)
333
+ for (const command of collectCommands(entry))
334
+ managedCommands.add(command);
335
+ }
336
+ // First remove every stale Evolver-owned entry, including V1 events (Stop/PostToolUse) that V2 deliberately
337
+ // no longer installs. User entries and non-array hook metadata remain untouched.
338
+ for (const event of Object.keys(tHooks)) {
339
+ if (POLLUTION_KEYS.has(event))
340
+ continue;
341
+ const value = tHooks[event];
342
+ if (!Array.isArray(value)) {
343
+ merged[event] = value;
344
+ continue;
345
+ }
346
+ const kept = stripEvolverHookEntries(value, trustManagedStatus, managedCommands).entries;
347
+ if (kept.length > 0)
348
+ merged[event] = kept;
349
+ }
287
350
  for (const event of Object.keys(sHooks)) {
288
351
  if (POLLUTION_KEYS.has(event))
289
352
  continue; // same guard for the hooks-union branch
290
- const tArr = tHooks[event];
291
353
  const sArr = sHooks[event];
292
- if (Array.isArray(tArr) && Array.isArray(sArr)) {
293
- merged[event] = [...tArr.filter((e) => !isEvolverOwned(e)), ...sArr];
354
+ if (Array.isArray(sArr)) {
355
+ const prior = Array.isArray(merged[event]) ? merged[event] : [];
356
+ merged[event] = [...prior, ...sArr];
294
357
  }
358
+ else
359
+ merged[event] = sArr;
295
360
  }
296
361
  result['hooks'] = merged;
297
362
  }
@@ -301,14 +366,16 @@ export function mergeHooksUnion(target, source) {
301
366
  export function stripManaged(data) {
302
367
  let changed = false;
303
368
  const out = { ...data };
369
+ const trustManagedStatus = data[MANAGED_MARKER] === true;
304
370
  const hooks = out['hooks'];
305
371
  if (isObj(hooks)) {
306
372
  const nextHooks = {};
307
373
  for (const event of Object.keys(hooks)) {
308
374
  const arr = hooks[event];
309
375
  if (Array.isArray(arr)) {
310
- const kept = arr.filter((e) => !isEvolverOwned(e));
311
- if (kept.length !== arr.length)
376
+ const stripped = stripEvolverHookEntries(arr, trustManagedStatus);
377
+ const kept = stripped.entries;
378
+ if (stripped.changed)
312
379
  changed = true;
313
380
  if (kept.length > 0)
314
381
  nextHooks[event] = kept;
@@ -323,12 +390,22 @@ export function stripManaged(data) {
323
390
  delete out['hooks'];
324
391
  }
325
392
  }
326
- // remove evolver MCP server registration
393
+ // remove evolver MCP server registration and a managed evox-product entry
327
394
  const mcp = out['mcpServers'];
328
- if (isObj(mcp) && 'evolver' in mcp) {
395
+ if (isObj(mcp)) {
329
396
  const next = { ...mcp };
330
- delete next['evolver'];
331
- changed = true;
397
+ if ('evolver' in next) {
398
+ delete next['evolver'];
399
+ changed = true;
400
+ }
401
+ if (PRODUCT_BRIDGE_SERVER_ID in next && isOwnedProductBridge(next[PRODUCT_BRIDGE_SERVER_ID])) {
402
+ const restored = restoreProductBridgeEntry(next[PRODUCT_BRIDGE_SERVER_ID]);
403
+ if (restored.restored)
404
+ next[PRODUCT_BRIDGE_SERVER_ID] = restored.entry;
405
+ else
406
+ delete next[PRODUCT_BRIDGE_SERVER_ID];
407
+ changed = true;
408
+ }
332
409
  if (Object.keys(next).length > 0)
333
410
  out['mcpServers'] = next;
334
411
  else {
@@ -341,8 +418,26 @@ export function stripManaged(data) {
341
418
  }
342
419
  return { changed, data: out };
343
420
  }
344
- function sessionStartHookPatch(hookCommand) {
345
- return { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: hookCommand }] }] } };
421
+ function runtimeHookPatch(sessionStartCommand, promptRecallCommand) {
422
+ return {
423
+ hooks: {
424
+ SessionStart: [{ hooks: [{
425
+ type: 'command', command: sessionStartCommand, statusMessage: evolverManagedHookStatus(sessionStartCommand),
426
+ }] }],
427
+ // Claude Code documents command-hook timeouts in seconds. A timeout discards output and lets the prompt
428
+ // proceed, so this remains fail-open even if the local CLI process stalls before its own deadline.
429
+ UserPromptSubmit: [{ hooks: [{
430
+ type: 'command', command: promptRecallCommand, timeout: 5,
431
+ statusMessage: evolverManagedHookStatus(promptRecallCommand),
432
+ }] }],
433
+ },
434
+ };
435
+ }
436
+ function hasExactHookCommand(config, event, command) {
437
+ const hooks = config['hooks'];
438
+ if (!isObj(hooks) || !Array.isArray(hooks[event]))
439
+ return false;
440
+ return hooks[event].some((entry) => collectCommands(entry).includes(command));
346
441
  }
347
442
  /** True when a parsed config already carries evolver's MCP registration (mcpServers.evolver) — the same entry
348
443
  * stripManaged removes on uninstall. The "already installed" short-circuit checks this in addition to the hook
@@ -360,11 +455,11 @@ function hasEvolverMcpRegistration(config) {
360
455
  * silently do nothing outside $HOME). The SessionStart hook lands in ~/.claude/settings.json (already user-level).
361
456
  * PROJECT scope keeps the legacy paths under configRoot.
362
457
  */
363
- function claudeCodeTargets(scope, configRoot) {
458
+ function claudeCodeTargets(scope, configRoot, homeDir = homedir()) {
364
459
  if (scope === 'user') {
365
- const claudeDir = join(homedir(), '.claude');
460
+ const claudeDir = join(homeDir, '.claude');
366
461
  return {
367
- mcpConfigPath: join(homedir(), '.claude.json'),
462
+ mcpConfigPath: join(homeDir, '.claude.json'),
368
463
  mcpIsSharedUserConfig: true,
369
464
  claudeDir,
370
465
  settingsPath: join(claudeDir, 'settings.json'),
@@ -404,13 +499,19 @@ export function installInjection(plan, opts) {
404
499
  if (plan.runtime === 'antigravity') {
405
500
  return installAntigravity(plan, opts);
406
501
  }
502
+ if (plan.runtime === 'opencode') {
503
+ return installOpenCode(plan, opts);
504
+ }
505
+ if (plan.runtime === 'kiro') {
506
+ return installKiro(plan, opts);
507
+ }
407
508
  if (plan.runtime !== 'claude-code') {
408
- // kiro/opencode are passive (handled above); any other active runtime is not yet ported.
409
- return { ok: false, runtime: plan.runtime, mode: plan.mode, files: [], error: `installer not yet implemented for ${plan.runtime} (supported: claude-code, codex, cursor, antigravity)` };
509
+ return { ok: false, runtime: plan.runtime, mode: plan.mode, files: [], error: `installer not yet implemented for ${plan.runtime} (supported: claude-code, codex, cursor, antigravity, opencode, kiro)` };
410
510
  }
411
511
  const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
512
+ const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
412
513
  const scope = opts.scope ?? 'project';
413
- const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
514
+ const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot, opts.homeDir);
414
515
  const mcpLabel = mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json';
415
516
  const settingsLabel = mcpIsSharedUserConfig ? '~/.claude/settings.json' : '.claude/settings.json';
416
517
  // project scope owns configRoot; user scope writes only home-anchored paths, so configRoot is irrelevant there.
@@ -419,12 +520,8 @@ export function installInjection(plan, opts) {
419
520
  assertNotSymlink(mcpConfigPath, mcpLabel);
420
521
  assertNotSymlink(claudeDir, mcpIsSharedUserConfig ? '~/.claude' : '.claude');
421
522
  assertNotSymlink(settingsPath, settingsLabel);
422
- // For the SHARED user config (~/.claude.json + ~/.claude/settings.json) read strictly: a present, non-empty,
423
- // unparseable file aborts the install (UnparseableConfigError) instead of being treated as {} and clobbered by
424
- // the full-file atomic write below. Project-scoped evolver-owned files keep the lenient readJson fresh-start.
425
- const readConfig = mcpIsSharedUserConfig
426
- ? (p, label) => readJsonStrictShared(p, label)
427
- : (p, _label) => readJson(p);
523
+ // Validate both targets before either write so malformed input cannot cause a partial install.
524
+ const readConfig = (p, label) => readJsonStrictShared(p, label);
428
525
  if (mcpIsSharedUserConfig) {
429
526
  ensureSharedUserClaudeDir(claudeDir);
430
527
  hardenSharedUserConfigFile(mcpConfigPath, mcpLabel);
@@ -436,22 +533,33 @@ export function installInjection(plan, opts) {
436
533
  // settings marker alone missed user-scope upgrades: a legacy global install stamped ~/.claude/settings.json but
437
534
  // registered the MCP in ~/.mcp.json (never ~/.claude.json), so a non-force reinstall returned alreadyInstalled
438
535
  // and left #290 unfixed. The hook and the MCP live in different files for user scope, so check both.
439
- if (!opts.force && existingSettings[MANAGED_MARKER] === true && hasEvolverMcpRegistration(existingMcp)) {
536
+ if (!opts.force
537
+ && existingSettings[MANAGED_MARKER] === true
538
+ && hasEvolverMcpRegistration(existingMcp)
539
+ && hasExactHookCommand(existingSettings, 'SessionStart', hookCommand)
540
+ && hasExactHookCommand(existingSettings, 'UserPromptSubmit', promptRecallHookCommand)) {
541
+ const product = withClaudeProductBridge(existingMcp, false);
542
+ if (product.changed) {
543
+ writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => withClaudeProductBridge(current, false));
544
+ return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [mcpConfigPath], alreadyInstalled: true };
545
+ }
440
546
  return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
441
547
  }
442
548
  // MCP server registration. project → <root>/.mcp.json (stamped _evolver_managed). user → ~/.claude.json's
443
549
  // top-level mcpServers (Claude Code's real user scope); we do NOT stamp the marker into ~/.claude.json because
444
550
  // it's Claude Code's own shared config, so uninstall keys off the mcpServers.evolver entry there instead.
445
551
  if (mcpIsSharedUserConfig) {
446
- writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => ({
447
- changed: true,
448
- data: deepMerge(current, plan.config),
449
- }));
552
+ writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => {
553
+ const merged = withClaudeProductBridge(deepMerge(current, plan.config), opts.force === true);
554
+ return { changed: true, data: merged.data };
555
+ });
450
556
  }
451
557
  else {
452
- const mcpMerged = deepMerge(existingMcp, plan.config);
453
- mcpMerged[MANAGED_MARKER] = true;
454
- writeJsonAtomic(mcpConfigPath, mcpMerged);
558
+ writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => {
559
+ const mcpMerged = deepMerge(current, plan.config);
560
+ mcpMerged[MANAGED_MARKER] = true;
561
+ return { changed: true, data: withClaudeProductBridge(mcpMerged, opts.force === true).data };
562
+ });
455
563
  }
456
564
  // .claude/settings.json ← SessionStart hook (hooks-union preserves the user's own hooks). For user scope this
457
565
  // is ~/.claude/settings.json, which is already Claude Code's user-level hook config.
@@ -461,15 +569,17 @@ export function installInjection(plan, opts) {
461
569
  mkdirSync(claudeDir, { recursive: true });
462
570
  if (mcpIsSharedUserConfig) {
463
571
  writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
464
- const settingsMerged = mergeHooksUnion(current, sessionStartHookPatch(hookCommand));
572
+ const settingsMerged = mergeHooksUnion(current, runtimeHookPatch(hookCommand, promptRecallHookCommand));
465
573
  settingsMerged[MANAGED_MARKER] = true;
466
574
  return { changed: true, data: settingsMerged };
467
575
  });
468
576
  }
469
577
  else {
470
- const settingsMerged = mergeHooksUnion(existingSettings, sessionStartHookPatch(hookCommand));
471
- settingsMerged[MANAGED_MARKER] = true;
472
- writeJsonAtomic(settingsPath, settingsMerged);
578
+ writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
579
+ const settingsMerged = mergeHooksUnion(current, runtimeHookPatch(hookCommand, promptRecallHookCommand));
580
+ settingsMerged[MANAGED_MARKER] = true;
581
+ return { changed: true, data: settingsMerged };
582
+ });
473
583
  }
474
584
  return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [mcpConfigPath, settingsPath] };
475
585
  }
@@ -487,11 +597,17 @@ export function uninstallInjection(runtime, opts) {
487
597
  if (runtime === 'antigravity') {
488
598
  return uninstallAntigravity(runtime, opts);
489
599
  }
600
+ if (runtime === 'opencode') {
601
+ return uninstallOpenCode(runtime, opts);
602
+ }
603
+ if (runtime === 'kiro') {
604
+ return uninstallKiro(runtime, opts);
605
+ }
490
606
  if (runtime !== 'claude-code') {
491
607
  return { ok: false, runtime, mode: 'n/a', files: [], error: `uninstall not implemented for ${runtime}` };
492
608
  }
493
609
  const scope = opts.scope ?? 'project';
494
- const { mcpConfigPath, mcpIsSharedUserConfig, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
610
+ const { mcpConfigPath, mcpIsSharedUserConfig, settingsPath } = claudeCodeTargets(scope, opts.configRoot, opts.homeDir);
495
611
  const cleaned = [];
496
612
  const targets = [
497
613
  [mcpConfigPath, mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json'],
@@ -499,20 +615,15 @@ export function uninstallInjection(runtime, opts) {
499
615
  ];
500
616
  for (const [path, label] of targets) {
501
617
  assertNotSymlink(path, label);
618
+ if (existsSync(path))
619
+ readJsonStrictShared(path, label);
620
+ }
621
+ for (const [path, label] of targets) {
502
622
  if (!existsSync(path))
503
623
  continue;
504
- if (mcpIsSharedUserConfig) {
505
- const changed = writeSharedJsonWithRetry(path, label, (current) => stripManaged(current));
506
- if (changed)
507
- cleaned.push(path);
508
- }
509
- else {
510
- const { changed, data } = stripManaged(readJson(path));
511
- if (changed) {
512
- writeJsonAtomic(path, data);
513
- cleaned.push(path);
514
- }
515
- }
624
+ const changed = writeSharedJsonWithRetry(path, label, (current) => stripManaged(current));
625
+ if (changed)
626
+ cleaned.push(path);
516
627
  }
517
628
  return { ok: true, runtime, mode: 'uninstall', files: cleaned };
518
629
  }