@evomap/evolver-mcp 2.0.0-beta.8 → 2.0.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.
- package/dist/antigravityInstaller.d.ts +1 -1
- package/dist/antigravityInstaller.js +17 -29
- package/dist/codexInstaller.d.ts +8 -3
- package/dist/codexInstaller.js +203 -50
- 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/installer.d.ts +15 -24
- package/dist/installer.js +224 -142
- package/dist/installerShared.d.ts +103 -0
- package/dist/installerShared.js +99 -0
- package/dist/jsonMcpInstaller.d.ts +5 -1
- package/dist/jsonMcpInstaller.js +54 -21
- package/dist/kiroInstaller.d.ts +3 -3
- package/dist/opencodeInstaller.d.ts +3 -3
- package/dist/proxyClient.d.ts +12 -0
- package/dist/proxyClient.js +117 -26
- package/dist/sharedFileCommit.d.ts +20 -0
- package/dist/sharedFileCommit.js +256 -0
- package/dist/stdio.js +10 -4
- package/package.json +6 -3
package/dist/installer.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { type RuntimeId, type McpServerCmd, type InjectionPlan } from './injection.js';
|
|
2
2
|
import { type CursorGene } from './cursorRulesInstaller.js';
|
|
3
|
+
export { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
|
|
3
4
|
/** Marks a config file as containing evolver-managed entries, so uninstall only removes what we added. */
|
|
4
5
|
export declare const MANAGED_MARKER = "_evolver_managed";
|
|
6
|
+
/** Official command-handler metadata, also gives custom commands an ownership marker for reinstall/uninstall. */
|
|
7
|
+
export declare const EVOLVER_HOOK_STATUS = "Loading Evolver memory";
|
|
5
8
|
/** Default command the SessionStart hook runs to render + print the memory injection. The `--hook-stdin` flag opts
|
|
6
9
|
* the entrypoint into reading the runtime's SessionStart JSON from stdin (to capture session_id, #205); only the
|
|
7
10
|
* installed hook sets it, so a manual `evolver inject session-start` never reads stdin. */
|
|
8
11
|
export declare const DEFAULT_HOOK_COMMAND = "evolver inject session-start --hook-stdin";
|
|
12
|
+
/** Local-only prompt recall. The handler is default-off and reads stdin only when EVOLVER_RECALL_MODE opts in. */
|
|
13
|
+
export declare const DEFAULT_PROMPT_RECALL_HOOK_COMMAND = "evolver inject prompt-recall --hook-stdin";
|
|
9
14
|
/**
|
|
10
15
|
* Where a claude-code install registers the evolver MCP server. This is the fix for "global install doesn't
|
|
11
16
|
* actually globalize" (#290): Claude Code's MCP scopes are local / user / project, and a `.mcp.json` is the
|
|
@@ -30,6 +35,8 @@ export interface InstallOptions {
|
|
|
30
35
|
server: McpServerCmd;
|
|
31
36
|
/** Command the SessionStart hook runs to inject memory. Default 'evolver inject session-start'. */
|
|
32
37
|
hookCommand?: string;
|
|
38
|
+
/** Command the UserPromptSubmit hook runs. Default is local-only, default-off prompt recall. */
|
|
39
|
+
promptRecallHookCommand?: string;
|
|
33
40
|
/** Reinstall even if an evolver install is already present. */
|
|
34
41
|
force?: boolean;
|
|
35
42
|
/** Plan and validate without writing config or backup files. */
|
|
@@ -93,31 +100,16 @@ export interface InstallResult {
|
|
|
93
100
|
backups?: string[];
|
|
94
101
|
error?: string;
|
|
95
102
|
}
|
|
96
|
-
export declare class SymlinkRefusedError extends Error {
|
|
97
|
-
constructor(label: string, path: string);
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Thrown when a SHARED user config (~/.claude.json or ~/.claude/settings.json) exists but does not parse as JSON.
|
|
101
|
-
* These files are Claude Code's own state (projects/oauthAccount/userID/history/settings),
|
|
102
|
-
* and user-scope install merges into them via a full-file atomic replace. The lenient readJson() returns {} on
|
|
103
|
-
* a parse failure, which would make the merge emit ONLY evolver's entry and silently WIPE the whole file — a
|
|
104
|
-
* realistic data-loss path because Claude Code writes these files non-atomically (a concurrent session can leave
|
|
105
|
-
* one truncated). For the shared-config read we therefore refuse instead of clobbering. Project-scoped
|
|
106
|
-
* .mcp.json/.claude/settings.json are evolver-owned, so their lenient fresh-start behavior stays unchanged.
|
|
107
|
-
*/
|
|
108
|
-
export declare class UnparseableConfigError extends Error {
|
|
109
|
-
constructor(label: string, path: string, owner?: string);
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Thrown when a SHARED user config exists but is empty or whitespace-only. Claude Code writes these files with a
|
|
113
|
-
* truncating write, so present-empty can be a concurrent-write window rather than a fresh config.
|
|
114
|
-
*/
|
|
115
|
-
export declare class EmptySharedConfigError extends Error {
|
|
116
|
-
constructor(label: string, path: string, owner?: string);
|
|
117
|
-
}
|
|
118
103
|
type SharedConfigRaceHook = (path: string, attempt: number) => void;
|
|
119
104
|
export declare function _setSharedConfigRaceHookForTest(hook?: SharedConfigRaceHook): void;
|
|
120
105
|
export declare function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
|
|
106
|
+
/** Bind custom-command ownership to that exact command without adding undocumented hook-schema fields. */
|
|
107
|
+
export declare function evolverManagedHookStatus(command: string): string;
|
|
108
|
+
/** Remove only Evolver-owned handlers, retaining user handlers that share the same matcher group. */
|
|
109
|
+
export declare function stripEvolverHookEntries(entries: readonly unknown[], trustManagedStatus?: boolean, managedCommands?: ReadonlySet<string>): {
|
|
110
|
+
changed: boolean;
|
|
111
|
+
entries: unknown[];
|
|
112
|
+
};
|
|
121
113
|
/**
|
|
122
114
|
* deepMerge, but for `hooks.<event>` arrays keep the user's existing entries and only replace evolver-owned
|
|
123
115
|
* ones — so reinstalling refreshes evolver's hook without clobbering a user's own SessionStart/Stop hooks.
|
|
@@ -147,5 +139,4 @@ export declare function installInjection(plan: InjectionPlan, opts: InstallOptio
|
|
|
147
139
|
* the mcpServers.evolver entry (and any evolver-owned hooks/marker), so it is safe on the shared ~/.claude.json. */
|
|
148
140
|
export declare function uninstallInjection(runtime: RuntimeId, opts: UninstallOptions): InstallResult;
|
|
149
141
|
/** Convenience: plan + install in one call for a runtime. */
|
|
150
|
-
export declare function setupRuntime(runtime: RuntimeId, opts: InstallOptions): InstallResult;
|
|
151
|
-
export {};
|
|
142
|
+
export declare function setupRuntime(runtime: RuntimeId, opts: InstallOptions): InstallResult;
|
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
|
|
@@ -25,48 +26,22 @@ import { installCursorRules, uninstallCursorRules } from './cursorRulesInstaller
|
|
|
25
26
|
import { installAntigravity, uninstallAntigravity } from './antigravityInstaller.js';
|
|
26
27
|
import { installOpenCode, uninstallOpenCode } from './opencodeInstaller.js';
|
|
27
28
|
import { installKiro, uninstallKiro } from './kiroInstaller.js';
|
|
29
|
+
import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
|
|
30
|
+
import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
|
|
31
|
+
export { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
|
|
28
32
|
/** Marks a config file as containing evolver-managed entries, so uninstall only removes what we added. */
|
|
29
33
|
export const MANAGED_MARKER = '_evolver_managed';
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
34
|
+
/** Official command-handler metadata, also gives custom commands an ownership marker for reinstall/uninstall. */
|
|
35
|
+
export const EVOLVER_HOOK_STATUS = 'Loading Evolver memory';
|
|
32
36
|
/** Default command the SessionStart hook runs to render + print the memory injection. The `--hook-stdin` flag opts
|
|
33
37
|
* the entrypoint into reading the runtime's SessionStart JSON from stdin (to capture session_id, #205); only the
|
|
34
38
|
* installed hook sets it, so a manual `evolver inject session-start` never reads stdin. */
|
|
35
39
|
export const DEFAULT_HOOK_COMMAND = 'evolver inject session-start --hook-stdin';
|
|
40
|
+
/** Local-only prompt recall. The handler is default-off and reads stdin only when EVOLVER_RECALL_MODE opts in. */
|
|
41
|
+
export const DEFAULT_PROMPT_RECALL_HOOK_COMMAND = 'evolver inject prompt-recall --hook-stdin';
|
|
36
42
|
const SHARED_USER_CONFIG_MODE = 0o600;
|
|
37
43
|
const SHARED_USER_DIR_MODE = 0o700;
|
|
38
44
|
const SHARED_USER_CONFIG_WRITE_RETRIES = 5;
|
|
39
|
-
export class SymlinkRefusedError extends Error {
|
|
40
|
-
constructor(label, path) {
|
|
41
|
-
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.`);
|
|
42
|
-
this.name = 'SymlinkRefusedError';
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Thrown when a SHARED user config (~/.claude.json or ~/.claude/settings.json) exists but does not parse as JSON.
|
|
47
|
-
* These files are Claude Code's own state (projects/oauthAccount/userID/history/settings),
|
|
48
|
-
* and user-scope install merges into them via a full-file atomic replace. The lenient readJson() returns {} on
|
|
49
|
-
* a parse failure, which would make the merge emit ONLY evolver's entry and silently WIPE the whole file — a
|
|
50
|
-
* realistic data-loss path because Claude Code writes these files non-atomically (a concurrent session can leave
|
|
51
|
-
* one truncated). For the shared-config read we therefore refuse instead of clobbering. Project-scoped
|
|
52
|
-
* .mcp.json/.claude/settings.json are evolver-owned, so their lenient fresh-start behavior stays unchanged.
|
|
53
|
-
*/
|
|
54
|
-
export class UnparseableConfigError extends Error {
|
|
55
|
-
constructor(label, path, owner = 'Claude Code') {
|
|
56
|
-
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.`);
|
|
57
|
-
this.name = 'UnparseableConfigError';
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Thrown when a SHARED user config exists but is empty or whitespace-only. Claude Code writes these files with a
|
|
62
|
-
* truncating write, so present-empty can be a concurrent-write window rather than a fresh config.
|
|
63
|
-
*/
|
|
64
|
-
export class EmptySharedConfigError extends Error {
|
|
65
|
-
constructor(label, path, owner = 'Claude Code') {
|
|
66
|
-
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.`);
|
|
67
|
-
this.name = 'EmptySharedConfigError';
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
45
|
// ── fs hardening ────────────────────────────────────────────────────────────
|
|
71
46
|
/** Refuse to read/write through a symlink at an adapter-owned path. Missing path is fine (install creates it). */
|
|
72
47
|
function assertNotSymlink(path, label) {
|
|
@@ -82,23 +57,33 @@ function assertNotSymlink(path, label) {
|
|
|
82
57
|
if (st.isSymbolicLink())
|
|
83
58
|
throw new SymlinkRefusedError(label, path);
|
|
84
59
|
}
|
|
85
|
-
|
|
60
|
+
/** Validate a shared config path without following either its parent or the target. */
|
|
61
|
+
function validatedSharedConfigMode(path, label) {
|
|
62
|
+
const parentPath = dirname(path);
|
|
63
|
+
const parent = lstatSync(parentPath);
|
|
64
|
+
if (parent.isSymbolicLink())
|
|
65
|
+
throw new SymlinkRefusedError(`${label} parent directory`, parentPath);
|
|
66
|
+
if (!parent.isDirectory()) {
|
|
67
|
+
throw new Error(`[setup-hooks] refusing to operate: parent of ${label} (${parentPath}) is not a directory.`);
|
|
68
|
+
}
|
|
69
|
+
let target;
|
|
86
70
|
try {
|
|
87
|
-
|
|
88
|
-
return {};
|
|
89
|
-
const raw = readFileSync(path, 'utf8').trim();
|
|
90
|
-
return raw ? JSON.parse(raw) : {};
|
|
71
|
+
target = lstatSync(path);
|
|
91
72
|
}
|
|
92
|
-
catch {
|
|
93
|
-
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === 'ENOENT')
|
|
75
|
+
return undefined;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (target.isSymbolicLink())
|
|
79
|
+
throw new SymlinkRefusedError(label, path);
|
|
80
|
+
if (!target.isFile()) {
|
|
81
|
+
throw new Error(`[setup-hooks] refusing to operate: ${label} ${path} is not a regular file.`);
|
|
94
82
|
}
|
|
83
|
+
return target.mode & 0o777;
|
|
95
84
|
}
|
|
96
85
|
/**
|
|
97
|
-
*
|
|
98
|
-
* ENOENT is treated as {} so a fresh user-scope install works. A present empty/whitespace file is refused because
|
|
99
|
-
* it can be Claude Code's truncating-write window; a present non-empty parse failure is refused because returning
|
|
100
|
-
* {} would make the subsequent full-file atomic write clobber Claude Code's state. Use this only for the
|
|
101
|
-
* shared-config read; project-scoped evolver-owned files keep the lenient readJson() above.
|
|
86
|
+
* Only ENOENT is treated as a fresh config. Empty or malformed existing files are never replaced.
|
|
102
87
|
*/
|
|
103
88
|
function readJsonStrictShared(path, label) {
|
|
104
89
|
return readJsonStrictSharedSnapshot(path, label).data;
|
|
@@ -133,18 +118,8 @@ function readRawIfExists(path) {
|
|
|
133
118
|
throw e;
|
|
134
119
|
}
|
|
135
120
|
}
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
return statSync(path).mode & 0o777;
|
|
139
|
-
}
|
|
140
|
-
catch (e) {
|
|
141
|
-
if (e.code === 'ENOENT')
|
|
142
|
-
return undefined;
|
|
143
|
-
throw e;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function sharedUserConfigWriteMode(path) {
|
|
147
|
-
const existingMode = existingFileMode(path);
|
|
121
|
+
function sharedUserConfigWriteMode(path, label) {
|
|
122
|
+
const existingMode = validatedSharedConfigMode(path, label);
|
|
148
123
|
return existingMode === undefined ? SHARED_USER_CONFIG_MODE : existingMode & 0o700;
|
|
149
124
|
}
|
|
150
125
|
function ensureSharedUserClaudeDir(path) {
|
|
@@ -188,38 +163,6 @@ function hardenSharedUserConfigFile(path, label) {
|
|
|
188
163
|
if (hardenedMode !== currentMode)
|
|
189
164
|
chmodSync(path, hardenedMode);
|
|
190
165
|
}
|
|
191
|
-
function writeJsonAtomic(path, data, options = {}) {
|
|
192
|
-
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
193
|
-
const content = `${JSON.stringify(data, null, 2)}\n`;
|
|
194
|
-
const mode = options.mode ?? existingFileMode(path);
|
|
195
|
-
const restoreMode = process.platform === 'win32' ? mode : undefined;
|
|
196
|
-
try {
|
|
197
|
-
writeFileSync(tmp, content, mode === undefined
|
|
198
|
-
? { encoding: 'utf8', flag: 'wx' }
|
|
199
|
-
: { encoding: 'utf8', flag: 'wx', mode });
|
|
200
|
-
if (mode !== undefined)
|
|
201
|
-
chmodSync(tmp, mode);
|
|
202
|
-
if (process.platform === 'win32' && mode !== undefined && existsSync(path)) {
|
|
203
|
-
chmodSync(path, mode | 0o200);
|
|
204
|
-
}
|
|
205
|
-
renameSync(tmp, path);
|
|
206
|
-
if (mode !== undefined)
|
|
207
|
-
chmodSync(path, mode);
|
|
208
|
-
}
|
|
209
|
-
catch (e) {
|
|
210
|
-
rmSync(tmp, { force: true });
|
|
211
|
-
if (restoreMode !== undefined) {
|
|
212
|
-
try {
|
|
213
|
-
if (existsSync(path))
|
|
214
|
-
chmodSync(path, restoreMode);
|
|
215
|
-
}
|
|
216
|
-
catch (rollbackError) {
|
|
217
|
-
e.rollbackError = rollbackError;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
throw e;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
166
|
let sharedConfigRaceHookForTest;
|
|
224
167
|
export function _setSharedConfigRaceHookForTest(hook) {
|
|
225
168
|
sharedConfigRaceHookForTest = hook;
|
|
@@ -227,23 +170,64 @@ export function _setSharedConfigRaceHookForTest(hook) {
|
|
|
227
170
|
function writeSharedJsonWithRetry(path, label, update) {
|
|
228
171
|
const lockPath = `${path}.evolver.lock`;
|
|
229
172
|
util.acquireLock(lockPath);
|
|
173
|
+
let operationResult = false;
|
|
174
|
+
let operationFailed = false;
|
|
175
|
+
let operationError;
|
|
230
176
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
177
|
+
operationResult = (() => {
|
|
178
|
+
for (let attempt = 1; attempt <= SHARED_USER_CONFIG_WRITE_RETRIES; attempt++) {
|
|
179
|
+
validatedSharedConfigMode(path, label);
|
|
180
|
+
const snapshot = readJsonStrictSharedSnapshot(path, label);
|
|
181
|
+
validatedSharedConfigMode(path, label);
|
|
182
|
+
const next = update(snapshot.data);
|
|
183
|
+
if (!next.changed)
|
|
184
|
+
return false;
|
|
185
|
+
sharedConfigRaceHookForTest?.(path, attempt);
|
|
186
|
+
validatedSharedConfigMode(path, label);
|
|
187
|
+
const currentRaw = readRawIfExists(path);
|
|
188
|
+
validatedSharedConfigMode(path, label);
|
|
189
|
+
if (currentRaw !== snapshot.raw)
|
|
190
|
+
continue;
|
|
191
|
+
try {
|
|
192
|
+
commitSharedFile({
|
|
193
|
+
path,
|
|
194
|
+
expectedRaw: snapshot.raw ?? undefined,
|
|
195
|
+
nextRaw: `${JSON.stringify(next.data, null, 2)}\n`,
|
|
196
|
+
mode: sharedUserConfigWriteMode(path, label),
|
|
197
|
+
});
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (error instanceof SharedFileConflictError)
|
|
202
|
+
continue;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
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.`);
|
|
207
|
+
})();
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
operationFailed = true;
|
|
211
|
+
operationError = error;
|
|
242
212
|
}
|
|
243
|
-
|
|
244
|
-
|
|
213
|
+
let releaseError;
|
|
214
|
+
try {
|
|
215
|
+
const released = util.releaseLock(lockPath);
|
|
216
|
+
if (!released.released)
|
|
217
|
+
releaseError = new util.LockReleaseError(released.reason);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
releaseError = error;
|
|
245
221
|
}
|
|
246
|
-
|
|
222
|
+
if (operationFailed) {
|
|
223
|
+
if (operationError instanceof Error && releaseError !== undefined) {
|
|
224
|
+
operationError.lockReleaseError = releaseError;
|
|
225
|
+
}
|
|
226
|
+
throw operationError;
|
|
227
|
+
}
|
|
228
|
+
if (releaseError !== undefined)
|
|
229
|
+
throw releaseError;
|
|
230
|
+
return operationResult;
|
|
247
231
|
}
|
|
248
232
|
// ── pure merge (exported for tests) ──────────────────────────────────────────
|
|
249
233
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -275,7 +259,60 @@ function collectCommands(entry) {
|
|
|
275
259
|
out.push(h['command']);
|
|
276
260
|
return out;
|
|
277
261
|
}
|
|
278
|
-
const
|
|
262
|
+
const LEGACY_NODE_COMMAND = /^"?node(?:\.exe)?"?\s+/i;
|
|
263
|
+
const LEGACY_EVOLVER_SCRIPT_BASENAME = /(?:^|[\\/'"\s])(?:evolver-session-start|evolver-session-end|evolver-signal-detect|evolver-task-recall|evolver-daemon-start)\.js(?=$|[\\/'"\s])/i;
|
|
264
|
+
const LEGACY_V2_CLI_HOOK_COMMAND = /^"?evolver(?:\.cmd|\.exe)?"?\s+inject\s+(?:session-start|prompt-recall)(?:\s|$)/i;
|
|
265
|
+
function isKnownEvolverHookCommand(command) {
|
|
266
|
+
// V1 invoked only these copied script basenames through node; pre-marker V2 used the two anchored CLI verbs.
|
|
267
|
+
// Do not use a generic `evolver` substring: user script paths and messages commonly contain that project name.
|
|
268
|
+
const trimmed = command.trim();
|
|
269
|
+
return LEGACY_V2_CLI_HOOK_COMMAND.test(trimmed)
|
|
270
|
+
|| (LEGACY_NODE_COMMAND.test(trimmed) && LEGACY_EVOLVER_SCRIPT_BASENAME.test(trimmed));
|
|
271
|
+
}
|
|
272
|
+
/** Bind custom-command ownership to that exact command without adding undocumented hook-schema fields. */
|
|
273
|
+
export function evolverManagedHookStatus(command) {
|
|
274
|
+
if (isKnownEvolverHookCommand(command))
|
|
275
|
+
return EVOLVER_HOOK_STATUS;
|
|
276
|
+
const commandTag = createHash('sha256').update(command, 'utf8').digest('hex').slice(0, 16);
|
|
277
|
+
return `${EVOLVER_HOOK_STATUS} [evolver:${commandTag}]`;
|
|
278
|
+
}
|
|
279
|
+
const NO_MANAGED_HOOK_COMMANDS = new Set();
|
|
280
|
+
const isEvolverHandler = (handler, trustManagedStatus, managedCommands) => {
|
|
281
|
+
if (!isObj(handler) || typeof handler['command'] !== 'string')
|
|
282
|
+
return false;
|
|
283
|
+
const command = handler['command'];
|
|
284
|
+
return isKnownEvolverHookCommand(command)
|
|
285
|
+
|| (trustManagedStatus && (managedCommands.has(command)
|
|
286
|
+
|| handler['statusMessage'] === evolverManagedHookStatus(command)));
|
|
287
|
+
};
|
|
288
|
+
/** Remove only Evolver-owned handlers, retaining user handlers that share the same matcher group. */
|
|
289
|
+
export function stripEvolverHookEntries(entries, trustManagedStatus = false, managedCommands = NO_MANAGED_HOOK_COMMANDS) {
|
|
290
|
+
let changed = false;
|
|
291
|
+
const keptEntries = [];
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
if (!isObj(entry)) {
|
|
294
|
+
keptEntries.push(entry);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const handlers = entry['hooks'];
|
|
298
|
+
if (!Array.isArray(handlers)) {
|
|
299
|
+
if (isEvolverHandler(entry, trustManagedStatus, managedCommands))
|
|
300
|
+
changed = true;
|
|
301
|
+
else
|
|
302
|
+
keptEntries.push(entry);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const keptHandlers = handlers.filter((handler) => !isEvolverHandler(handler, trustManagedStatus, managedCommands));
|
|
306
|
+
if (keptHandlers.length === handlers.length) {
|
|
307
|
+
keptEntries.push(entry);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
changed = true;
|
|
311
|
+
if (keptHandlers.length > 0)
|
|
312
|
+
keptEntries.push({ ...entry, hooks: keptHandlers });
|
|
313
|
+
}
|
|
314
|
+
return { changed, entries: keptEntries };
|
|
315
|
+
}
|
|
279
316
|
/**
|
|
280
317
|
* deepMerge, but for `hooks.<event>` arrays keep the user's existing entries and only replace evolver-owned
|
|
281
318
|
* ones — so reinstalling refreshes evolver's hook without clobbering a user's own SessionStart/Stop hooks.
|
|
@@ -284,16 +321,41 @@ export function mergeHooksUnion(target, source) {
|
|
|
284
321
|
const result = deepMerge(target, source);
|
|
285
322
|
const tHooks = target['hooks'];
|
|
286
323
|
const sHooks = source['hooks'];
|
|
324
|
+
const trustManagedStatus = target[MANAGED_MARKER] === true;
|
|
287
325
|
if (isObj(tHooks) && isObj(sHooks)) {
|
|
288
|
-
const merged = {
|
|
326
|
+
const merged = {};
|
|
327
|
+
const managedCommands = new Set();
|
|
328
|
+
for (const value of Object.values(sHooks)) {
|
|
329
|
+
if (!Array.isArray(value))
|
|
330
|
+
continue;
|
|
331
|
+
for (const entry of value)
|
|
332
|
+
for (const command of collectCommands(entry))
|
|
333
|
+
managedCommands.add(command);
|
|
334
|
+
}
|
|
335
|
+
// First remove every stale Evolver-owned entry, including V1 events (Stop/PostToolUse) that V2 deliberately
|
|
336
|
+
// no longer installs. User entries and non-array hook metadata remain untouched.
|
|
337
|
+
for (const event of Object.keys(tHooks)) {
|
|
338
|
+
if (POLLUTION_KEYS.has(event))
|
|
339
|
+
continue;
|
|
340
|
+
const value = tHooks[event];
|
|
341
|
+
if (!Array.isArray(value)) {
|
|
342
|
+
merged[event] = value;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const kept = stripEvolverHookEntries(value, trustManagedStatus, managedCommands).entries;
|
|
346
|
+
if (kept.length > 0)
|
|
347
|
+
merged[event] = kept;
|
|
348
|
+
}
|
|
289
349
|
for (const event of Object.keys(sHooks)) {
|
|
290
350
|
if (POLLUTION_KEYS.has(event))
|
|
291
351
|
continue; // same guard for the hooks-union branch
|
|
292
|
-
const tArr = tHooks[event];
|
|
293
352
|
const sArr = sHooks[event];
|
|
294
|
-
if (Array.isArray(
|
|
295
|
-
merged[event]
|
|
353
|
+
if (Array.isArray(sArr)) {
|
|
354
|
+
const prior = Array.isArray(merged[event]) ? merged[event] : [];
|
|
355
|
+
merged[event] = [...prior, ...sArr];
|
|
296
356
|
}
|
|
357
|
+
else
|
|
358
|
+
merged[event] = sArr;
|
|
297
359
|
}
|
|
298
360
|
result['hooks'] = merged;
|
|
299
361
|
}
|
|
@@ -303,14 +365,16 @@ export function mergeHooksUnion(target, source) {
|
|
|
303
365
|
export function stripManaged(data) {
|
|
304
366
|
let changed = false;
|
|
305
367
|
const out = { ...data };
|
|
368
|
+
const trustManagedStatus = data[MANAGED_MARKER] === true;
|
|
306
369
|
const hooks = out['hooks'];
|
|
307
370
|
if (isObj(hooks)) {
|
|
308
371
|
const nextHooks = {};
|
|
309
372
|
for (const event of Object.keys(hooks)) {
|
|
310
373
|
const arr = hooks[event];
|
|
311
374
|
if (Array.isArray(arr)) {
|
|
312
|
-
const
|
|
313
|
-
|
|
375
|
+
const stripped = stripEvolverHookEntries(arr, trustManagedStatus);
|
|
376
|
+
const kept = stripped.entries;
|
|
377
|
+
if (stripped.changed)
|
|
314
378
|
changed = true;
|
|
315
379
|
if (kept.length > 0)
|
|
316
380
|
nextHooks[event] = kept;
|
|
@@ -343,8 +407,26 @@ export function stripManaged(data) {
|
|
|
343
407
|
}
|
|
344
408
|
return { changed, data: out };
|
|
345
409
|
}
|
|
346
|
-
function
|
|
347
|
-
return {
|
|
410
|
+
function runtimeHookPatch(sessionStartCommand, promptRecallCommand) {
|
|
411
|
+
return {
|
|
412
|
+
hooks: {
|
|
413
|
+
SessionStart: [{ hooks: [{
|
|
414
|
+
type: 'command', command: sessionStartCommand, statusMessage: evolverManagedHookStatus(sessionStartCommand),
|
|
415
|
+
}] }],
|
|
416
|
+
// Claude Code documents command-hook timeouts in seconds. A timeout discards output and lets the prompt
|
|
417
|
+
// proceed, so this remains fail-open even if the local CLI process stalls before its own deadline.
|
|
418
|
+
UserPromptSubmit: [{ hooks: [{
|
|
419
|
+
type: 'command', command: promptRecallCommand, timeout: 5,
|
|
420
|
+
statusMessage: evolverManagedHookStatus(promptRecallCommand),
|
|
421
|
+
}] }],
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function hasExactHookCommand(config, event, command) {
|
|
426
|
+
const hooks = config['hooks'];
|
|
427
|
+
if (!isObj(hooks) || !Array.isArray(hooks[event]))
|
|
428
|
+
return false;
|
|
429
|
+
return hooks[event].some((entry) => collectCommands(entry).includes(command));
|
|
348
430
|
}
|
|
349
431
|
/** True when a parsed config already carries evolver's MCP registration (mcpServers.evolver) — the same entry
|
|
350
432
|
* stripManaged removes on uninstall. The "already installed" short-circuit checks this in addition to the hook
|
|
@@ -416,6 +498,7 @@ export function installInjection(plan, opts) {
|
|
|
416
498
|
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)` };
|
|
417
499
|
}
|
|
418
500
|
const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
|
|
501
|
+
const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
|
|
419
502
|
const scope = opts.scope ?? 'project';
|
|
420
503
|
const { mcpConfigPath, mcpIsSharedUserConfig, claudeDir, settingsPath } = claudeCodeTargets(scope, opts.configRoot);
|
|
421
504
|
const mcpLabel = mcpIsSharedUserConfig ? '~/.claude.json' : '.mcp.json';
|
|
@@ -426,12 +509,8 @@ export function installInjection(plan, opts) {
|
|
|
426
509
|
assertNotSymlink(mcpConfigPath, mcpLabel);
|
|
427
510
|
assertNotSymlink(claudeDir, mcpIsSharedUserConfig ? '~/.claude' : '.claude');
|
|
428
511
|
assertNotSymlink(settingsPath, settingsLabel);
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
// the full-file atomic write below. Project-scoped evolver-owned files keep the lenient readJson fresh-start.
|
|
432
|
-
const readConfig = mcpIsSharedUserConfig
|
|
433
|
-
? (p, label) => readJsonStrictShared(p, label)
|
|
434
|
-
: (p, _label) => readJson(p);
|
|
512
|
+
// Validate both targets before either write so malformed input cannot cause a partial install.
|
|
513
|
+
const readConfig = (p, label) => readJsonStrictShared(p, label);
|
|
435
514
|
if (mcpIsSharedUserConfig) {
|
|
436
515
|
ensureSharedUserClaudeDir(claudeDir);
|
|
437
516
|
hardenSharedUserConfigFile(mcpConfigPath, mcpLabel);
|
|
@@ -443,7 +522,11 @@ export function installInjection(plan, opts) {
|
|
|
443
522
|
// settings marker alone missed user-scope upgrades: a legacy global install stamped ~/.claude/settings.json but
|
|
444
523
|
// registered the MCP in ~/.mcp.json (never ~/.claude.json), so a non-force reinstall returned alreadyInstalled
|
|
445
524
|
// and left #290 unfixed. The hook and the MCP live in different files for user scope, so check both.
|
|
446
|
-
if (!opts.force
|
|
525
|
+
if (!opts.force
|
|
526
|
+
&& existingSettings[MANAGED_MARKER] === true
|
|
527
|
+
&& hasEvolverMcpRegistration(existingMcp)
|
|
528
|
+
&& hasExactHookCommand(existingSettings, 'SessionStart', hookCommand)
|
|
529
|
+
&& hasExactHookCommand(existingSettings, 'UserPromptSubmit', promptRecallHookCommand)) {
|
|
447
530
|
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
|
|
448
531
|
}
|
|
449
532
|
// MCP server registration. project → <root>/.mcp.json (stamped _evolver_managed). user → ~/.claude.json's
|
|
@@ -456,9 +539,11 @@ export function installInjection(plan, opts) {
|
|
|
456
539
|
}));
|
|
457
540
|
}
|
|
458
541
|
else {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
542
|
+
writeSharedJsonWithRetry(mcpConfigPath, mcpLabel, (current) => {
|
|
543
|
+
const mcpMerged = deepMerge(current, plan.config);
|
|
544
|
+
mcpMerged[MANAGED_MARKER] = true;
|
|
545
|
+
return { changed: true, data: mcpMerged };
|
|
546
|
+
});
|
|
462
547
|
}
|
|
463
548
|
// .claude/settings.json ← SessionStart hook (hooks-union preserves the user's own hooks). For user scope this
|
|
464
549
|
// is ~/.claude/settings.json, which is already Claude Code's user-level hook config.
|
|
@@ -468,15 +553,17 @@ export function installInjection(plan, opts) {
|
|
|
468
553
|
mkdirSync(claudeDir, { recursive: true });
|
|
469
554
|
if (mcpIsSharedUserConfig) {
|
|
470
555
|
writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
|
|
471
|
-
const settingsMerged = mergeHooksUnion(current,
|
|
556
|
+
const settingsMerged = mergeHooksUnion(current, runtimeHookPatch(hookCommand, promptRecallHookCommand));
|
|
472
557
|
settingsMerged[MANAGED_MARKER] = true;
|
|
473
558
|
return { changed: true, data: settingsMerged };
|
|
474
559
|
});
|
|
475
560
|
}
|
|
476
561
|
else {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
562
|
+
writeSharedJsonWithRetry(settingsPath, settingsLabel, (current) => {
|
|
563
|
+
const settingsMerged = mergeHooksUnion(current, runtimeHookPatch(hookCommand, promptRecallHookCommand));
|
|
564
|
+
settingsMerged[MANAGED_MARKER] = true;
|
|
565
|
+
return { changed: true, data: settingsMerged };
|
|
566
|
+
});
|
|
480
567
|
}
|
|
481
568
|
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [mcpConfigPath, settingsPath] };
|
|
482
569
|
}
|
|
@@ -512,20 +599,15 @@ export function uninstallInjection(runtime, opts) {
|
|
|
512
599
|
];
|
|
513
600
|
for (const [path, label] of targets) {
|
|
514
601
|
assertNotSymlink(path, label);
|
|
602
|
+
if (existsSync(path))
|
|
603
|
+
readJsonStrictShared(path, label);
|
|
604
|
+
}
|
|
605
|
+
for (const [path, label] of targets) {
|
|
515
606
|
if (!existsSync(path))
|
|
516
607
|
continue;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
cleaned.push(path);
|
|
521
|
-
}
|
|
522
|
-
else {
|
|
523
|
-
const { changed, data } = stripManaged(readJson(path));
|
|
524
|
-
if (changed) {
|
|
525
|
-
writeJsonAtomic(path, data);
|
|
526
|
-
cleaned.push(path);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
608
|
+
const changed = writeSharedJsonWithRetry(path, label, (current) => stripManaged(current));
|
|
609
|
+
if (changed)
|
|
610
|
+
cleaned.push(path);
|
|
529
611
|
}
|
|
530
612
|
return { ok: true, runtime, mode: 'uninstall', files: cleaned };
|
|
531
613
|
}
|