@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/antigravityInstaller.d.ts +1 -1
- package/dist/antigravityInstaller.js +17 -29
- package/dist/codexInstaller.d.ts +8 -3
- package/dist/codexInstaller.js +217 -53
- package/dist/cursorRulesInstaller.d.ts +1 -1
- package/dist/cursorRulesInstaller.js +66 -17
- package/dist/envFile.d.ts +2 -1
- package/dist/envFile.js +6 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/injection.d.ts +2 -1
- package/dist/injection.js +10 -7
- package/dist/installer.d.ts +52 -24
- package/dist/installer.js +268 -157
- package/dist/installerShared.d.ts +103 -0
- package/dist/installerShared.js +99 -0
- package/dist/jsonMcpInstaller.d.ts +79 -0
- package/dist/jsonMcpInstaller.js +857 -0
- package/dist/kiroInstaller.d.ts +10 -0
- package/dist/kiroInstaller.js +146 -0
- package/dist/opencodeInstaller.d.ts +18 -0
- package/dist/opencodeInstaller.js +531 -0
- package/dist/primer.js +8 -5
- package/dist/productBridge.d.ts +37 -0
- package/dist/productBridge.js +250 -0
- package/dist/productBridgeShim.d.ts +52 -0
- package/dist/productBridgeShim.js +338 -0
- package/dist/proxyClient.d.ts +27 -0
- package/dist/proxyClient.js +135 -26
- package/dist/sharedFileCommit.d.ts +20 -0
- package/dist/sharedFileCommit.js +256 -0
- package/dist/stdio.js +10 -4
- package/dist/tools.js +71 -7
- package/package.json +13 -2
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),
|
|
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
|
|
13
|
-
import {
|
|
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
|
-
/**
|
|
29
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
return {};
|
|
87
|
-
const raw = readFileSync(path, 'utf8').trim();
|
|
88
|
-
return raw ? JSON.parse(raw) : {};
|
|
72
|
+
target = lstatSync(path);
|
|
89
73
|
}
|
|
90
|
-
catch {
|
|
91
|
-
|
|
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
|
-
*
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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(
|
|
293
|
-
merged[event]
|
|
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
|
|
311
|
-
|
|
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)
|
|
395
|
+
if (isObj(mcp)) {
|
|
329
396
|
const next = { ...mcp };
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
345
|
-
return {
|
|
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(
|
|
460
|
+
const claudeDir = join(homeDir, '.claude');
|
|
366
461
|
return {
|
|
367
|
-
mcpConfigPath: join(
|
|
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
|
-
|
|
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
|
-
//
|
|
423
|
-
|
|
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
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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,
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
}
|