@evomap/evolver-mcp 2.0.0-beta.17 → 2.0.0-beta.19
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/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.js +17 -7
- package/dist/sharedFileCommit.d.ts +20 -0
- package/dist/sharedFileCommit.js +256 -0
- package/dist/stdio.js +3 -0
- package/package.json +5 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InjectionPlan, McpServerCmd, RuntimeId } from './injection.js';
|
|
2
|
-
import { type InstallOptions, type InstallResult, type UninstallOptions } from './
|
|
2
|
+
import { type InstallOptions, type InstallResult, type UninstallOptions } from './installerShared.js';
|
|
3
3
|
export declare const ANTIGRAVITY_NAMESPACES: readonly ["antigravity", "antigravity-ide"];
|
|
4
4
|
export interface AntigravityConfigTarget {
|
|
5
5
|
namespace: (typeof ANTIGRAVITY_NAMESPACES)[number];
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { lstatSync, mkdirSync, readFileSync, statSync, } from 'node:fs';
|
|
3
2
|
import { homedir as osHomedir } from 'node:os';
|
|
4
3
|
import { dirname, join } from 'node:path';
|
|
5
4
|
import { util } from '@evomap/evolver-core';
|
|
6
|
-
import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './
|
|
5
|
+
import { EmptySharedConfigError, SymlinkRefusedError, UnparseableConfigError, } from './installerShared.js';
|
|
6
|
+
import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
|
|
7
7
|
const ENV_FILE_KEY = 'EVOLVER_ENV_FILE';
|
|
8
8
|
const CONFIG_FILE = 'mcp_config.json';
|
|
9
9
|
const CONFIG_WRITE_RETRIES = 5;
|
|
@@ -125,30 +125,6 @@ function existingFileMode(path) {
|
|
|
125
125
|
throw error;
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
function writeJsonAtomic(path, data) {
|
|
129
|
-
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
130
|
-
const mode = existingFileMode(path) ?? NEW_CONFIG_MODE;
|
|
131
|
-
const restoreMode = process.platform === 'win32' && existsSync(path) ? existingFileMode(path) : undefined;
|
|
132
|
-
try {
|
|
133
|
-
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode });
|
|
134
|
-
chmodSync(tmp, mode);
|
|
135
|
-
if (process.platform === 'win32' && existsSync(path))
|
|
136
|
-
chmodSync(path, mode | 0o200);
|
|
137
|
-
renameSync(tmp, path);
|
|
138
|
-
chmodSync(path, mode);
|
|
139
|
-
}
|
|
140
|
-
catch (error) {
|
|
141
|
-
rmSync(tmp, { force: true });
|
|
142
|
-
if (restoreMode !== undefined) {
|
|
143
|
-
try {
|
|
144
|
-
if (existsSync(path))
|
|
145
|
-
chmodSync(path, restoreMode);
|
|
146
|
-
}
|
|
147
|
-
catch { /* preserve the original error */ }
|
|
148
|
-
}
|
|
149
|
-
throw error;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
128
|
function updateConfigWithRetry(path, update) {
|
|
153
129
|
const lockPath = `${path}.evolver.lock`;
|
|
154
130
|
util.acquireLock(lockPath);
|
|
@@ -160,8 +136,20 @@ function updateConfigWithRetry(path, update) {
|
|
|
160
136
|
return false;
|
|
161
137
|
if (readRawIfExists(path) !== snapshot.raw)
|
|
162
138
|
continue;
|
|
163
|
-
|
|
164
|
-
|
|
139
|
+
try {
|
|
140
|
+
commitSharedFile({
|
|
141
|
+
path,
|
|
142
|
+
expectedRaw: snapshot.raw ?? undefined,
|
|
143
|
+
nextRaw: `${JSON.stringify(next.data, null, 2)}\n`,
|
|
144
|
+
mode: existingFileMode(path) ?? NEW_CONFIG_MODE,
|
|
145
|
+
});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof SharedFileConflictError)
|
|
150
|
+
continue;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
165
153
|
}
|
|
166
154
|
}
|
|
167
155
|
finally {
|
package/dist/codexInstaller.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { RuntimeId, InjectionPlan } from './injection.js';
|
|
2
|
-
import { type InstallResult, type InstallOptions } from './
|
|
2
|
+
import { type InstallResult, type InstallOptions } from './installerShared.js';
|
|
3
3
|
/** The MCP server id evolver registers under [mcp_servers.evolver] / removes on uninstall. */
|
|
4
4
|
export declare const CODEX_MCP_SERVER_ID = "evolver";
|
|
5
|
+
type CodexConfigRaceHook = (path: string, attempt: number) => void;
|
|
6
|
+
export declare function _setCodexConfigRaceHookForTest(hook?: CodexConfigRaceHook): void;
|
|
5
7
|
/** The [mcp_servers.evolver] table from a plan's launch command. env omitted when empty (no `[..env]` table). */
|
|
6
8
|
export declare function codexMcpServerEntry(server: {
|
|
7
9
|
command: string;
|
|
@@ -10,13 +12,15 @@ export declare function codexMcpServerEntry(server: {
|
|
|
10
12
|
}): Record<string, unknown>;
|
|
11
13
|
/** A single [[hooks.SessionStart]] entry that runs `command` at session start. Shape matches codex's hooks schema. */
|
|
12
14
|
export declare function codexSessionStartHook(command: string): Record<string, unknown>;
|
|
15
|
+
/** UserPromptSubmit has no matcher in current Codex; five seconds keeps the prompt path fail-open and bounded. */
|
|
16
|
+
export declare function codexUserPromptSubmitHook(command: string): Record<string, unknown>;
|
|
13
17
|
/**
|
|
14
18
|
* Merge evolver's codex config into the user's existing parsed TOML. Idempotent + non-destructive:
|
|
15
19
|
* - [mcp_servers] : set our `evolver` server, keep every other server the user registered.
|
|
16
20
|
* - [[hooks.SessionStart]] : drop any prior evolver-owned entry, keep all user entries, append ours fresh.
|
|
17
21
|
* The user's unrelated tables (model, approval_policy, other hook events, …) pass through untouched.
|
|
18
22
|
*/
|
|
19
|
-
export declare function mergeCodexConfig(existing: Record<string, unknown>, mcpServer: Record<string, unknown>, sessionStartHook: Record<string, unknown>): Record<string, unknown>;
|
|
23
|
+
export declare function mergeCodexConfig(existing: Record<string, unknown>, mcpServer: Record<string, unknown>, sessionStartHook: Record<string, unknown>, userPromptSubmitHook?: Record<string, unknown>): Record<string, unknown>;
|
|
20
24
|
/** Strip evolver's MCP server + SessionStart hook from parsed codex TOML (uninstall). Returns [changed, data]. */
|
|
21
25
|
export declare function stripCodexManaged(data: Record<string, unknown>): {
|
|
22
26
|
changed: boolean;
|
|
@@ -31,4 +35,5 @@ export declare function installCodex(plan: InjectionPlan, opts: InstallOptions):
|
|
|
31
35
|
/** Remove evolver's MCP registration + SessionStart hook from a codex project config (leaves user content intact). */
|
|
32
36
|
export declare function uninstallCodex(runtime: RuntimeId, opts: {
|
|
33
37
|
configRoot: string;
|
|
34
|
-
}): InstallResult;
|
|
38
|
+
}): InstallResult;
|
|
39
|
+
export {};
|
package/dist/codexInstaller.js
CHANGED
|
@@ -18,16 +18,18 @@
|
|
|
18
18
|
// adapter-owned path, marker-managed so reinstall/uninstall only touch evolver's own entries, and a
|
|
19
19
|
// hooks-UNION merge that preserves the user's existing hooks. TOML round-trips through smol-toml (spec
|
|
20
20
|
// parser/serializer) so a user's hand-written config is preserved rather than clobbered.
|
|
21
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync,
|
|
21
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
24
|
-
import {
|
|
24
|
+
import { util } from '@evomap/evolver-core';
|
|
25
|
+
import { commitSharedFile, SharedFileConflictError } from './sharedFileCommit.js';
|
|
26
|
+
import { SymlinkRefusedError, DEFAULT_HOOK_COMMAND, DEFAULT_PROMPT_RECALL_HOOK_COMMAND, evolverManagedHookStatus, stripEvolverHookEntries, } from './installerShared.js';
|
|
25
27
|
/** The MCP server id evolver registers under [mcp_servers.evolver] / removes on uninstall. */
|
|
26
28
|
export const CODEX_MCP_SERVER_ID = 'evolver';
|
|
27
|
-
/** A hook entry is evolver-owned if any command mentions this — used to replace-not-duplicate on reinstall. */
|
|
28
|
-
const EVOLVER_HOOK_TAG = 'evolver';
|
|
29
29
|
/** SessionStart matcher: codex fires `startup` on a fresh thread and `resume` on a resumed one — cover both. */
|
|
30
30
|
const SESSION_START_MATCHER = 'startup|resume';
|
|
31
|
+
const CONFIG_WRITE_RETRIES = 5;
|
|
32
|
+
const CODEX_CONFIG_MODE = 0o600;
|
|
31
33
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
32
34
|
// ── fs hardening (mirrors installer.ts) ──────────────────────────────────────
|
|
33
35
|
function assertNotSymlink(path, label) {
|
|
@@ -43,21 +45,108 @@ function assertNotSymlink(path, label) {
|
|
|
43
45
|
if (st.isSymbolicLink())
|
|
44
46
|
throw new SymlinkRefusedError(label, path);
|
|
45
47
|
}
|
|
46
|
-
function
|
|
48
|
+
function readTomlSnapshot(path) {
|
|
49
|
+
let raw;
|
|
47
50
|
try {
|
|
48
|
-
|
|
49
|
-
return {};
|
|
50
|
-
const raw = readFileSync(path, 'utf8').trim();
|
|
51
|
-
return raw ? parseToml(raw) : {};
|
|
51
|
+
raw = readFileSync(path, 'utf8');
|
|
52
52
|
}
|
|
53
|
-
catch {
|
|
54
|
-
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code === 'ENOENT')
|
|
55
|
+
return { data: {}, raw: null, mode: CODEX_CONFIG_MODE };
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (!raw.trim()) {
|
|
59
|
+
throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the existing file is empty.`);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return { data: parseToml(raw), raw, mode: (statSync(path).mode & 0o777) & 0o700 };
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the existing file is not valid TOML.`, { cause: error });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readRawIfExists(path) {
|
|
69
|
+
try {
|
|
70
|
+
return readFileSync(path, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code === 'ENOENT')
|
|
74
|
+
return null;
|
|
75
|
+
throw error;
|
|
55
76
|
}
|
|
56
77
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
let codexConfigRaceHookForTest;
|
|
79
|
+
export function _setCodexConfigRaceHookForTest(hook) {
|
|
80
|
+
codexConfigRaceHookForTest = hook;
|
|
81
|
+
}
|
|
82
|
+
function writeTomlWithRetry(path, update, assertSafe) {
|
|
83
|
+
const lockPath = `${path}.evolver.lock`;
|
|
84
|
+
assertSafe();
|
|
85
|
+
util.acquireLock(lockPath);
|
|
86
|
+
let operationResult = false;
|
|
87
|
+
let operationFailed = false;
|
|
88
|
+
let operationError;
|
|
89
|
+
try {
|
|
90
|
+
operationResult = (() => {
|
|
91
|
+
for (let attempt = 1; attempt <= CONFIG_WRITE_RETRIES; attempt += 1) {
|
|
92
|
+
assertSafe();
|
|
93
|
+
const snapshot = readTomlSnapshot(path);
|
|
94
|
+
const next = update(snapshot.data);
|
|
95
|
+
if (!next.changed)
|
|
96
|
+
return false;
|
|
97
|
+
codexConfigRaceHookForTest?.(path, attempt);
|
|
98
|
+
assertSafe();
|
|
99
|
+
if (readRawIfExists(path) !== snapshot.raw)
|
|
100
|
+
continue;
|
|
101
|
+
assertSafe();
|
|
102
|
+
try {
|
|
103
|
+
commitSharedFile({
|
|
104
|
+
path,
|
|
105
|
+
expectedRaw: snapshot.raw ?? undefined,
|
|
106
|
+
nextRaw: next.remove ? undefined : `${stringifyToml(next.data)}\n`,
|
|
107
|
+
mode: snapshot.mode,
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof SharedFileConflictError)
|
|
113
|
+
continue;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the file changed repeatedly while evolver was merging it.`);
|
|
118
|
+
})();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
operationFailed = true;
|
|
122
|
+
operationError = error;
|
|
123
|
+
}
|
|
124
|
+
let releaseError;
|
|
125
|
+
try {
|
|
126
|
+
const released = util.releaseLock(lockPath);
|
|
127
|
+
if (!released.released)
|
|
128
|
+
releaseError = new util.LockReleaseError(released.reason);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
releaseError = error;
|
|
132
|
+
}
|
|
133
|
+
if (operationFailed) {
|
|
134
|
+
if (operationError instanceof Error && releaseError !== undefined) {
|
|
135
|
+
operationError.lockReleaseError = releaseError;
|
|
136
|
+
}
|
|
137
|
+
throw operationError;
|
|
138
|
+
}
|
|
139
|
+
if (releaseError !== undefined)
|
|
140
|
+
throw releaseError;
|
|
141
|
+
return operationResult;
|
|
142
|
+
}
|
|
143
|
+
function codexPathGuard(configRoot, codexDir, configPath) {
|
|
144
|
+
return () => {
|
|
145
|
+
assertNotSymlink(configRoot, 'config root');
|
|
146
|
+
assertNotSymlink(codexDir, '.codex');
|
|
147
|
+
assertNotSymlink(configPath, '.codex/config.toml');
|
|
148
|
+
assertNotSymlink(`${configPath}.evolver.lock`, '.codex/config.toml lock');
|
|
149
|
+
};
|
|
61
150
|
}
|
|
62
151
|
// ── pure merge (exported for tests) ──────────────────────────────────────────
|
|
63
152
|
/** The [mcp_servers.evolver] table from a plan's launch command. env omitted when empty (no `[..env]` table). */
|
|
@@ -70,30 +159,66 @@ export function codexMcpServerEntry(server) {
|
|
|
70
159
|
}
|
|
71
160
|
/** A single [[hooks.SessionStart]] entry that runs `command` at session start. Shape matches codex's hooks schema. */
|
|
72
161
|
export function codexSessionStartHook(command) {
|
|
73
|
-
return { matcher: SESSION_START_MATCHER, hooks: [{
|
|
162
|
+
return { matcher: SESSION_START_MATCHER, hooks: [{
|
|
163
|
+
type: 'command', command, statusMessage: evolverManagedHookStatus(command),
|
|
164
|
+
}] };
|
|
74
165
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
166
|
+
/** UserPromptSubmit has no matcher in current Codex; five seconds keeps the prompt path fail-open and bounded. */
|
|
167
|
+
export function codexUserPromptSubmitHook(command) {
|
|
168
|
+
return { hooks: [{
|
|
169
|
+
type: 'command', command, timeout: 5, statusMessage: evolverManagedHookStatus(command),
|
|
170
|
+
}] };
|
|
171
|
+
}
|
|
172
|
+
function hookHasExactCommand(entry, command) {
|
|
173
|
+
if (!isObj(entry) || !Array.isArray(entry['hooks']))
|
|
80
174
|
return false;
|
|
81
|
-
return
|
|
82
|
-
}
|
|
175
|
+
return entry['hooks'].some((handler) => isObj(handler) && handler['command'] === command);
|
|
176
|
+
}
|
|
177
|
+
function hookCommands(entries) {
|
|
178
|
+
const commands = new Set();
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (!isObj(entry) || !Array.isArray(entry['hooks']))
|
|
181
|
+
continue;
|
|
182
|
+
for (const handler of entry['hooks']) {
|
|
183
|
+
if (isObj(handler) && typeof handler['command'] === 'string')
|
|
184
|
+
commands.add(handler['command']);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return commands;
|
|
188
|
+
}
|
|
83
189
|
/**
|
|
84
190
|
* Merge evolver's codex config into the user's existing parsed TOML. Idempotent + non-destructive:
|
|
85
191
|
* - [mcp_servers] : set our `evolver` server, keep every other server the user registered.
|
|
86
192
|
* - [[hooks.SessionStart]] : drop any prior evolver-owned entry, keep all user entries, append ours fresh.
|
|
87
193
|
* The user's unrelated tables (model, approval_policy, other hook events, …) pass through untouched.
|
|
88
194
|
*/
|
|
89
|
-
export function mergeCodexConfig(existing, mcpServer, sessionStartHook) {
|
|
195
|
+
export function mergeCodexConfig(existing, mcpServer, sessionStartHook, userPromptSubmitHook) {
|
|
90
196
|
const out = { ...existing };
|
|
91
197
|
const mcpServers = isObj(out['mcp_servers']) ? { ...out['mcp_servers'] } : {};
|
|
198
|
+
const trustManagedStatus = CODEX_MCP_SERVER_ID in mcpServers;
|
|
199
|
+
const managedCommands = hookCommands(userPromptSubmitHook ? [sessionStartHook, userPromptSubmitHook] : [sessionStartHook]);
|
|
92
200
|
mcpServers[CODEX_MCP_SERVER_ID] = mcpServer;
|
|
93
201
|
out['mcp_servers'] = mcpServers;
|
|
94
202
|
const hooks = isObj(out['hooks']) ? { ...out['hooks'] } : {};
|
|
203
|
+
for (const event of Object.keys(hooks)) {
|
|
204
|
+
const value = hooks[event];
|
|
205
|
+
if (!Array.isArray(value))
|
|
206
|
+
continue;
|
|
207
|
+
const kept = stripEvolverHookEntries(value, trustManagedStatus, managedCommands).entries;
|
|
208
|
+
if (kept.length > 0)
|
|
209
|
+
hooks[event] = kept;
|
|
210
|
+
else
|
|
211
|
+
delete hooks[event];
|
|
212
|
+
}
|
|
95
213
|
const prior = Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : [];
|
|
96
|
-
hooks['SessionStart'] = [...prior
|
|
214
|
+
hooks['SessionStart'] = [...prior, sessionStartHook];
|
|
215
|
+
if (userPromptSubmitHook) {
|
|
216
|
+
const priorPrompt = Array.isArray(hooks['UserPromptSubmit']) ? hooks['UserPromptSubmit'] : [];
|
|
217
|
+
hooks['UserPromptSubmit'] = [
|
|
218
|
+
...priorPrompt,
|
|
219
|
+
userPromptSubmitHook,
|
|
220
|
+
];
|
|
221
|
+
}
|
|
97
222
|
out['hooks'] = hooks;
|
|
98
223
|
return out;
|
|
99
224
|
}
|
|
@@ -101,6 +226,8 @@ export function mergeCodexConfig(existing, mcpServer, sessionStartHook) {
|
|
|
101
226
|
export function stripCodexManaged(data) {
|
|
102
227
|
let changed = false;
|
|
103
228
|
const out = { ...data };
|
|
229
|
+
const trustManagedStatus = isObj(out['mcp_servers'])
|
|
230
|
+
&& CODEX_MCP_SERVER_ID in out['mcp_servers'];
|
|
104
231
|
if (isObj(out['mcp_servers']) && CODEX_MCP_SERVER_ID in out['mcp_servers']) {
|
|
105
232
|
const next = { ...out['mcp_servers'] };
|
|
106
233
|
delete next[CODEX_MCP_SERVER_ID];
|
|
@@ -112,15 +239,18 @@ export function stripCodexManaged(data) {
|
|
|
112
239
|
}
|
|
113
240
|
if (isObj(out['hooks'])) {
|
|
114
241
|
const hooks = { ...out['hooks'] };
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
242
|
+
for (const event of Object.keys(hooks)) {
|
|
243
|
+
const value = hooks[event];
|
|
244
|
+
if (!Array.isArray(value))
|
|
245
|
+
continue;
|
|
246
|
+
const stripped = stripEvolverHookEntries(value, trustManagedStatus);
|
|
247
|
+
const kept = stripped.entries;
|
|
248
|
+
if (stripped.changed)
|
|
119
249
|
changed = true;
|
|
120
250
|
if (kept.length > 0)
|
|
121
|
-
hooks[
|
|
251
|
+
hooks[event] = kept;
|
|
122
252
|
else
|
|
123
|
-
delete hooks[
|
|
253
|
+
delete hooks[event];
|
|
124
254
|
}
|
|
125
255
|
if (Object.keys(hooks).length > 0)
|
|
126
256
|
out['hooks'] = hooks;
|
|
@@ -130,8 +260,16 @@ export function stripCodexManaged(data) {
|
|
|
130
260
|
return { changed, data: out };
|
|
131
261
|
}
|
|
132
262
|
/** True if evolver's MCP server is already registered in this parsed config (structural install marker). */
|
|
133
|
-
function codexAlreadyInstalled(cfg) {
|
|
134
|
-
|
|
263
|
+
function codexAlreadyInstalled(cfg, sessionStartCommand, promptRecallCommand) {
|
|
264
|
+
if (!isObj(cfg['mcp_servers']) || !(CODEX_MCP_SERVER_ID in cfg['mcp_servers']))
|
|
265
|
+
return false;
|
|
266
|
+
const hooks = cfg['hooks'];
|
|
267
|
+
if (!isObj(hooks))
|
|
268
|
+
return false;
|
|
269
|
+
const sessionStart = Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : [];
|
|
270
|
+
const promptSubmit = Array.isArray(hooks['UserPromptSubmit']) ? hooks['UserPromptSubmit'] : [];
|
|
271
|
+
return sessionStart.some((entry) => hookHasExactCommand(entry, sessionStartCommand))
|
|
272
|
+
&& promptSubmit.some((entry) => hookHasExactCommand(entry, promptRecallCommand));
|
|
135
273
|
}
|
|
136
274
|
// ── install / uninstall ───────────────────────────────────────────────────────
|
|
137
275
|
/**
|
|
@@ -141,31 +279,46 @@ function codexAlreadyInstalled(cfg) {
|
|
|
141
279
|
*/
|
|
142
280
|
export function installCodex(plan, opts) {
|
|
143
281
|
const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
|
|
282
|
+
const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
|
|
144
283
|
const codexDir = join(opts.configRoot, '.codex');
|
|
145
284
|
const configPath = join(codexDir, 'config.toml');
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
assertNotSymlink(configPath, '.codex/config.toml');
|
|
149
|
-
const existing = readToml(configPath);
|
|
150
|
-
if (!opts.force && codexAlreadyInstalled(existing)) {
|
|
151
|
-
return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [], alreadyInstalled: true };
|
|
152
|
-
}
|
|
285
|
+
const assertSafe = codexPathGuard(opts.configRoot, codexDir, configPath);
|
|
286
|
+
assertSafe();
|
|
153
287
|
const mcpServer = codexMcpServerEntry(opts.server);
|
|
154
288
|
const sessionStartHook = codexSessionStartHook(hookCommand);
|
|
155
|
-
const
|
|
289
|
+
const userPromptSubmitHook = codexUserPromptSubmitHook(promptRecallHookCommand);
|
|
156
290
|
mkdirSync(codexDir, { recursive: true });
|
|
157
|
-
|
|
158
|
-
|
|
291
|
+
const changed = writeTomlWithRetry(configPath, (current) => {
|
|
292
|
+
if (!opts.force && codexAlreadyInstalled(current, hookCommand, promptRecallHookCommand)) {
|
|
293
|
+
return { changed: false, data: current };
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
changed: true,
|
|
297
|
+
data: mergeCodexConfig(current, mcpServer, sessionStartHook, userPromptSubmitHook),
|
|
298
|
+
};
|
|
299
|
+
}, assertSafe);
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
runtime: plan.runtime,
|
|
303
|
+
mode: plan.mode,
|
|
304
|
+
files: changed ? [configPath] : [],
|
|
305
|
+
...(!changed ? { alreadyInstalled: true } : {}),
|
|
306
|
+
};
|
|
159
307
|
}
|
|
160
308
|
/** Remove evolver's MCP registration + SessionStart hook from a codex project config (leaves user content intact). */
|
|
161
309
|
export function uninstallCodex(runtime, opts) {
|
|
162
|
-
const
|
|
163
|
-
|
|
310
|
+
const codexDir = join(opts.configRoot, '.codex');
|
|
311
|
+
const configPath = join(codexDir, 'config.toml');
|
|
312
|
+
const assertSafe = codexPathGuard(opts.configRoot, codexDir, configPath);
|
|
313
|
+
assertSafe();
|
|
164
314
|
if (!existsSync(configPath))
|
|
165
315
|
return { ok: true, runtime, mode: 'uninstall', files: [] };
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
return {
|
|
169
|
-
|
|
170
|
-
|
|
316
|
+
const changed = writeTomlWithRetry(configPath, (current) => {
|
|
317
|
+
const stripped = stripCodexManaged(current);
|
|
318
|
+
return {
|
|
319
|
+
...stripped,
|
|
320
|
+
remove: stripped.changed && Object.keys(stripped.data).length === 0,
|
|
321
|
+
};
|
|
322
|
+
}, assertSafe);
|
|
323
|
+
return { ok: true, runtime, mode: 'uninstall', files: changed ? [configPath] : [] };
|
|
171
324
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type InstallResult } from './
|
|
1
|
+
import { type InstallResult } from './installerShared.js';
|
|
2
2
|
/** Project-relative location cursor loads project rules from. */
|
|
3
3
|
export declare const CURSOR_RULES_DIR: string;
|
|
4
4
|
/** The single evolver-owned rules file. Other `.cursor/rules/*.mdc` (user-authored) are never touched. */
|
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
// with an unchanged gene set is a byte-for-byte no-op) and we never clobber rules a user hand-wrote in the same
|
|
16
16
|
// file. Hardened exactly like the other installers: atomic writes (tmp+rename) and refusal to follow a symlink
|
|
17
17
|
// at any adapter-owned path (a hostile workspace could redirect writes/unlinks outside the project).
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
18
19
|
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { join } from 'node:path';
|
|
20
|
-
import { SymlinkRefusedError } from './
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
import { SymlinkRefusedError } from './installerShared.js';
|
|
21
22
|
/** Project-relative location cursor loads project rules from. */
|
|
22
23
|
export const CURSOR_RULES_DIR = join('.cursor', 'rules');
|
|
23
24
|
/** The single evolver-owned rules file. Other `.cursor/rules/*.mdc` (user-authored) are never touched. */
|
|
@@ -47,10 +48,60 @@ function assertNotSymlink(path, label) {
|
|
|
47
48
|
if (st.isSymbolicLink())
|
|
48
49
|
throw new SymlinkRefusedError(label, path);
|
|
49
50
|
}
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
function cursorRulePaths(configRoot) {
|
|
52
|
+
return {
|
|
53
|
+
cursorDir: join(configRoot, '.cursor'),
|
|
54
|
+
rulesDir: join(configRoot, CURSOR_RULES_DIR),
|
|
55
|
+
filePath: cursorRulesPath(configRoot),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function assertCursorRulePathsNotSymlink(configRoot, paths) {
|
|
59
|
+
assertNotSymlink(configRoot, 'config root');
|
|
60
|
+
assertNotSymlink(paths.cursorDir, '.cursor');
|
|
61
|
+
assertNotSymlink(paths.rulesDir, '.cursor/rules');
|
|
62
|
+
assertNotSymlink(paths.filePath, '.cursor/rules/evolver.mdc');
|
|
63
|
+
}
|
|
64
|
+
function cleanupAtomicTemp(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
const stat = lstatSync(filePath);
|
|
67
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
|
68
|
+
return new Error(`Refusing to clean non-file atomic temp path: ${filePath}`);
|
|
69
|
+
}
|
|
70
|
+
unlinkSync(filePath);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return error.code === 'ENOENT' ? undefined : error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function writeTextAtomic(filePath, text, beforeCommit) {
|
|
78
|
+
const tmp = join(dirname(filePath), `${basename(filePath)}.${randomUUID()}.tmp`);
|
|
79
|
+
let writeCompleted = false;
|
|
80
|
+
let renameCompleted = false;
|
|
81
|
+
let operationFailed = false;
|
|
82
|
+
let operationError;
|
|
83
|
+
let cleanupError;
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(tmp, text, { encoding: 'utf8', flag: 'wx' });
|
|
86
|
+
writeCompleted = true;
|
|
87
|
+
beforeCommit?.();
|
|
88
|
+
assertNotSymlink(filePath, '.cursor/rules/evolver.mdc');
|
|
89
|
+
renameSync(tmp, filePath);
|
|
90
|
+
renameCompleted = true;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
operationFailed = true;
|
|
94
|
+
operationError = error;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
const collidedBeforeWrite = !writeCompleted && operationError?.code === 'EEXIST';
|
|
98
|
+
if (!renameCompleted && !collidedBeforeWrite)
|
|
99
|
+
cleanupError = cleanupAtomicTemp(tmp);
|
|
100
|
+
}
|
|
101
|
+
if (operationFailed)
|
|
102
|
+
throw operationError;
|
|
103
|
+
if (cleanupError !== undefined)
|
|
104
|
+
throw cleanupError;
|
|
54
105
|
}
|
|
55
106
|
// ── pure rendering (exported for tests) ───────────────────────────────────────
|
|
56
107
|
/** One compact line for a gene in the rules body. Deterministic; defensively caps the hint length. */
|
|
@@ -135,14 +186,10 @@ export function cursorRulesPath(configRoot) {
|
|
|
135
186
|
* Symlink-hardened on every adapter-owned path; other `.cursor/rules/*.mdc` files are never read or written.
|
|
136
187
|
*/
|
|
137
188
|
export function installCursorRules(opts) {
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const filePath = cursorRulesPath(opts.configRoot);
|
|
189
|
+
const paths = cursorRulePaths(opts.configRoot);
|
|
190
|
+
const { rulesDir, filePath } = paths;
|
|
141
191
|
const maxGenes = opts.maxGenes ?? DEFAULT_CURSOR_MAX_GENES;
|
|
142
|
-
|
|
143
|
-
assertNotSymlink(cursorDir, '.cursor');
|
|
144
|
-
assertNotSymlink(rulesDir, '.cursor/rules');
|
|
145
|
-
assertNotSymlink(filePath, '.cursor/rules/evolver.mdc');
|
|
192
|
+
assertCursorRulePathsNotSymlink(opts.configRoot, paths);
|
|
146
193
|
const block = renderManagedBlock(opts.genes, maxGenes);
|
|
147
194
|
const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
|
148
195
|
const next = existing ? spliceManagedBlock(existing, block) : renderCursorRulesFile(opts.genes, maxGenes);
|
|
@@ -151,7 +198,7 @@ export function installCursorRules(opts) {
|
|
|
151
198
|
return { ok: true, runtime: 'cursor', mode: 'cursor-rules', files: [], rewritten: false };
|
|
152
199
|
}
|
|
153
200
|
mkdirSync(rulesDir, { recursive: true });
|
|
154
|
-
writeTextAtomic(filePath, next);
|
|
201
|
+
writeTextAtomic(filePath, next, () => assertCursorRulePathsNotSymlink(opts.configRoot, paths));
|
|
155
202
|
return { ok: true, runtime: 'cursor', mode: 'cursor-rules', files: [filePath], rewritten: true };
|
|
156
203
|
}
|
|
157
204
|
/**
|
|
@@ -159,19 +206,21 @@ export function installCursorRules(opts) {
|
|
|
159
206
|
* file; otherwise strip just the managed block and keep the user's content. Other rules files are untouched.
|
|
160
207
|
*/
|
|
161
208
|
export function uninstallCursorRules(opts) {
|
|
162
|
-
const
|
|
163
|
-
|
|
209
|
+
const paths = cursorRulePaths(opts.configRoot);
|
|
210
|
+
const { filePath } = paths;
|
|
211
|
+
assertCursorRulePathsNotSymlink(opts.configRoot, paths);
|
|
164
212
|
if (!existsSync(filePath))
|
|
165
213
|
return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [] };
|
|
166
214
|
const text = readFileSync(filePath, 'utf8');
|
|
167
215
|
if (isEvolverOnly(text)) {
|
|
216
|
+
assertCursorRulePathsNotSymlink(opts.configRoot, paths);
|
|
168
217
|
unlinkSync(filePath);
|
|
169
218
|
return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [filePath] };
|
|
170
219
|
}
|
|
171
220
|
const { changed, text: stripped } = stripManagedBlock(text);
|
|
172
221
|
if (!changed)
|
|
173
222
|
return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [] };
|
|
174
|
-
writeTextAtomic(filePath, stripped.endsWith('\n') ? stripped : `${stripped}\n
|
|
223
|
+
writeTextAtomic(filePath, stripped.endsWith('\n') ? stripped : `${stripped}\n`, () => assertCursorRulePathsNotSymlink(opts.configRoot, paths));
|
|
175
224
|
return { ok: true, runtime: 'cursor', mode: 'uninstall', files: [filePath] };
|
|
176
225
|
}
|
|
177
226
|
/** Convenience for the daemon rewrite trigger: rewrite the rules file to reflect the current top genes. Returns
|
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;
|