@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.
@@ -1,5 +1,5 @@
1
1
  import type { InjectionPlan, McpServerCmd, RuntimeId } from './injection.js';
2
- import { type InstallOptions, type InstallResult, type UninstallOptions } from './installer.js';
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 { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
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 './installer.js';
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
- writeJsonAtomic(path, next.data);
164
- return true;
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 {
@@ -1,7 +1,9 @@
1
1
  import type { RuntimeId, InjectionPlan } from './injection.js';
2
- import { type InstallResult, type InstallOptions } from './installer.js';
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 {};
@@ -18,16 +18,19 @@
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, renameSync, writeFileSync } from 'node:fs';
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 { SymlinkRefusedError, DEFAULT_HOOK_COMMAND } from './installer.js';
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';
27
+ import { withCodexProductBridge, isOwnedProductBridge, restoreProductBridgeEntry, PRODUCT_BRIDGE_SERVER_ID } from './productBridge.js';
25
28
  /** The MCP server id evolver registers under [mcp_servers.evolver] / removes on uninstall. */
26
29
  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
30
  /** SessionStart matcher: codex fires `startup` on a fresh thread and `resume` on a resumed one — cover both. */
30
31
  const SESSION_START_MATCHER = 'startup|resume';
32
+ const CONFIG_WRITE_RETRIES = 5;
33
+ const CODEX_CONFIG_MODE = 0o600;
31
34
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
32
35
  // ── fs hardening (mirrors installer.ts) ──────────────────────────────────────
33
36
  function assertNotSymlink(path, label) {
@@ -43,21 +46,108 @@ function assertNotSymlink(path, label) {
43
46
  if (st.isSymbolicLink())
44
47
  throw new SymlinkRefusedError(label, path);
45
48
  }
46
- function readToml(path) {
49
+ function readTomlSnapshot(path) {
50
+ let raw;
47
51
  try {
48
- if (!existsSync(path))
49
- return {};
50
- const raw = readFileSync(path, 'utf8').trim();
51
- return raw ? parseToml(raw) : {};
52
+ raw = readFileSync(path, 'utf8');
52
53
  }
53
- catch {
54
- return {}; // unparseable → start fresh (merge re-adds evolver entries; a broken file isn't silently kept)
54
+ catch (error) {
55
+ if (error.code === 'ENOENT')
56
+ return { data: {}, raw: null, mode: CODEX_CONFIG_MODE };
57
+ throw error;
55
58
  }
59
+ if (!raw.trim()) {
60
+ throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the existing file is empty.`);
61
+ }
62
+ try {
63
+ return { data: parseToml(raw), raw, mode: (statSync(path).mode & 0o777) & 0o700 };
64
+ }
65
+ catch (error) {
66
+ throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the existing file is not valid TOML.`, { cause: error });
67
+ }
68
+ }
69
+ function readRawIfExists(path) {
70
+ try {
71
+ return readFileSync(path, 'utf8');
72
+ }
73
+ catch (error) {
74
+ if (error.code === 'ENOENT')
75
+ return null;
76
+ throw error;
77
+ }
78
+ }
79
+ let codexConfigRaceHookForTest;
80
+ export function _setCodexConfigRaceHookForTest(hook) {
81
+ codexConfigRaceHookForTest = hook;
82
+ }
83
+ function writeTomlWithRetry(path, update, assertSafe) {
84
+ const lockPath = `${path}.evolver.lock`;
85
+ assertSafe();
86
+ util.acquireLock(lockPath);
87
+ let operationResult = false;
88
+ let operationFailed = false;
89
+ let operationError;
90
+ try {
91
+ operationResult = (() => {
92
+ for (let attempt = 1; attempt <= CONFIG_WRITE_RETRIES; attempt += 1) {
93
+ assertSafe();
94
+ const snapshot = readTomlSnapshot(path);
95
+ const next = update(snapshot.data);
96
+ if (!next.changed)
97
+ return false;
98
+ codexConfigRaceHookForTest?.(path, attempt);
99
+ assertSafe();
100
+ if (readRawIfExists(path) !== snapshot.raw)
101
+ continue;
102
+ assertSafe();
103
+ try {
104
+ commitSharedFile({
105
+ path,
106
+ expectedRaw: snapshot.raw ?? undefined,
107
+ nextRaw: next.remove ? undefined : `${stringifyToml(next.data)}\n`,
108
+ mode: snapshot.mode,
109
+ });
110
+ return true;
111
+ }
112
+ catch (error) {
113
+ if (error instanceof SharedFileConflictError)
114
+ continue;
115
+ throw error;
116
+ }
117
+ }
118
+ throw new Error(`[setup-hooks] refusing to overwrite .codex/config.toml (${path}): the file changed repeatedly while evolver was merging it.`);
119
+ })();
120
+ }
121
+ catch (error) {
122
+ operationFailed = true;
123
+ operationError = error;
124
+ }
125
+ let releaseError;
126
+ try {
127
+ const released = util.releaseLock(lockPath);
128
+ if (!released.released)
129
+ releaseError = new util.LockReleaseError(released.reason);
130
+ }
131
+ catch (error) {
132
+ releaseError = error;
133
+ }
134
+ if (operationFailed) {
135
+ if (operationError instanceof Error && releaseError !== undefined) {
136
+ operationError.lockReleaseError = releaseError;
137
+ }
138
+ throw operationError;
139
+ }
140
+ if (releaseError !== undefined)
141
+ throw releaseError;
142
+ return operationResult;
56
143
  }
57
- function writeTomlAtomic(path, data) {
58
- const tmp = `${path}.tmp`;
59
- writeFileSync(tmp, `${stringifyToml(data)}\n`, 'utf8');
60
- renameSync(tmp, path);
144
+ function codexPathGuard(configRoot, codexDir, configPath) {
145
+ return () => {
146
+ assertNotSymlink(configRoot, 'config root');
147
+ assertNotSymlink(codexDir, '.codex');
148
+ assertNotSymlink(configPath, '.codex/config.toml');
149
+ assertNotSymlink(`${configPath}.evolver.lock`, '.codex/config.toml lock');
150
+ };
61
151
  }
62
152
  // ── pure merge (exported for tests) ──────────────────────────────────────────
63
153
  /** The [mcp_servers.evolver] table from a plan's launch command. env omitted when empty (no `[..env]` table). */
@@ -70,30 +160,66 @@ export function codexMcpServerEntry(server) {
70
160
  }
71
161
  /** A single [[hooks.SessionStart]] entry that runs `command` at session start. Shape matches codex's hooks schema. */
72
162
  export function codexSessionStartHook(command) {
73
- return { matcher: SESSION_START_MATCHER, hooks: [{ type: 'command', command }] };
163
+ return { matcher: SESSION_START_MATCHER, hooks: [{
164
+ type: 'command', command, statusMessage: evolverManagedHookStatus(command),
165
+ }] };
74
166
  }
75
- const hookIsEvolverOwned = (entry) => {
76
- if (!isObj(entry))
77
- return false;
78
- const inner = entry['hooks'];
79
- if (!Array.isArray(inner))
167
+ /** UserPromptSubmit has no matcher in current Codex; five seconds keeps the prompt path fail-open and bounded. */
168
+ export function codexUserPromptSubmitHook(command) {
169
+ return { hooks: [{
170
+ type: 'command', command, timeout: 5, statusMessage: evolverManagedHookStatus(command),
171
+ }] };
172
+ }
173
+ function hookHasExactCommand(entry, command) {
174
+ if (!isObj(entry) || !Array.isArray(entry['hooks']))
80
175
  return false;
81
- return inner.some((h) => isObj(h) && typeof h['command'] === 'string' && h['command'].includes(EVOLVER_HOOK_TAG));
82
- };
176
+ return entry['hooks'].some((handler) => isObj(handler) && handler['command'] === command);
177
+ }
178
+ function hookCommands(entries) {
179
+ const commands = new Set();
180
+ for (const entry of entries) {
181
+ if (!isObj(entry) || !Array.isArray(entry['hooks']))
182
+ continue;
183
+ for (const handler of entry['hooks']) {
184
+ if (isObj(handler) && typeof handler['command'] === 'string')
185
+ commands.add(handler['command']);
186
+ }
187
+ }
188
+ return commands;
189
+ }
83
190
  /**
84
191
  * Merge evolver's codex config into the user's existing parsed TOML. Idempotent + non-destructive:
85
192
  * - [mcp_servers] : set our `evolver` server, keep every other server the user registered.
86
193
  * - [[hooks.SessionStart]] : drop any prior evolver-owned entry, keep all user entries, append ours fresh.
87
194
  * The user's unrelated tables (model, approval_policy, other hook events, …) pass through untouched.
88
195
  */
89
- export function mergeCodexConfig(existing, mcpServer, sessionStartHook) {
196
+ export function mergeCodexConfig(existing, mcpServer, sessionStartHook, userPromptSubmitHook) {
90
197
  const out = { ...existing };
91
198
  const mcpServers = isObj(out['mcp_servers']) ? { ...out['mcp_servers'] } : {};
199
+ const trustManagedStatus = CODEX_MCP_SERVER_ID in mcpServers;
200
+ const managedCommands = hookCommands(userPromptSubmitHook ? [sessionStartHook, userPromptSubmitHook] : [sessionStartHook]);
92
201
  mcpServers[CODEX_MCP_SERVER_ID] = mcpServer;
93
202
  out['mcp_servers'] = mcpServers;
94
203
  const hooks = isObj(out['hooks']) ? { ...out['hooks'] } : {};
204
+ for (const event of Object.keys(hooks)) {
205
+ const value = hooks[event];
206
+ if (!Array.isArray(value))
207
+ continue;
208
+ const kept = stripEvolverHookEntries(value, trustManagedStatus, managedCommands).entries;
209
+ if (kept.length > 0)
210
+ hooks[event] = kept;
211
+ else
212
+ delete hooks[event];
213
+ }
95
214
  const prior = Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : [];
96
- hooks['SessionStart'] = [...prior.filter((e) => !hookIsEvolverOwned(e)), sessionStartHook];
215
+ hooks['SessionStart'] = [...prior, sessionStartHook];
216
+ if (userPromptSubmitHook) {
217
+ const priorPrompt = Array.isArray(hooks['UserPromptSubmit']) ? hooks['UserPromptSubmit'] : [];
218
+ hooks['UserPromptSubmit'] = [
219
+ ...priorPrompt,
220
+ userPromptSubmitHook,
221
+ ];
222
+ }
97
223
  out['hooks'] = hooks;
98
224
  return out;
99
225
  }
@@ -101,10 +227,22 @@ export function mergeCodexConfig(existing, mcpServer, sessionStartHook) {
101
227
  export function stripCodexManaged(data) {
102
228
  let changed = false;
103
229
  const out = { ...data };
104
- if (isObj(out['mcp_servers']) && CODEX_MCP_SERVER_ID in out['mcp_servers']) {
230
+ const trustManagedStatus = isObj(out['mcp_servers'])
231
+ && CODEX_MCP_SERVER_ID in out['mcp_servers'];
232
+ if (isObj(out['mcp_servers'])) {
105
233
  const next = { ...out['mcp_servers'] };
106
- delete next[CODEX_MCP_SERVER_ID];
107
- changed = true;
234
+ if (CODEX_MCP_SERVER_ID in next) {
235
+ delete next[CODEX_MCP_SERVER_ID];
236
+ changed = true;
237
+ }
238
+ if (PRODUCT_BRIDGE_SERVER_ID in next && isOwnedProductBridge(next[PRODUCT_BRIDGE_SERVER_ID])) {
239
+ const restored = restoreProductBridgeEntry(next[PRODUCT_BRIDGE_SERVER_ID]);
240
+ if (restored.restored)
241
+ next[PRODUCT_BRIDGE_SERVER_ID] = restored.entry;
242
+ else
243
+ delete next[PRODUCT_BRIDGE_SERVER_ID];
244
+ changed = true;
245
+ }
108
246
  if (Object.keys(next).length > 0)
109
247
  out['mcp_servers'] = next;
110
248
  else
@@ -112,15 +250,18 @@ export function stripCodexManaged(data) {
112
250
  }
113
251
  if (isObj(out['hooks'])) {
114
252
  const hooks = { ...out['hooks'] };
115
- if (Array.isArray(hooks['SessionStart'])) {
116
- const arr = hooks['SessionStart'];
117
- const kept = arr.filter((e) => !hookIsEvolverOwned(e));
118
- if (kept.length !== arr.length)
253
+ for (const event of Object.keys(hooks)) {
254
+ const value = hooks[event];
255
+ if (!Array.isArray(value))
256
+ continue;
257
+ const stripped = stripEvolverHookEntries(value, trustManagedStatus);
258
+ const kept = stripped.entries;
259
+ if (stripped.changed)
119
260
  changed = true;
120
261
  if (kept.length > 0)
121
- hooks['SessionStart'] = kept;
262
+ hooks[event] = kept;
122
263
  else
123
- delete hooks['SessionStart'];
264
+ delete hooks[event];
124
265
  }
125
266
  if (Object.keys(hooks).length > 0)
126
267
  out['hooks'] = hooks;
@@ -130,8 +271,16 @@ export function stripCodexManaged(data) {
130
271
  return { changed, data: out };
131
272
  }
132
273
  /** True if evolver's MCP server is already registered in this parsed config (structural install marker). */
133
- function codexAlreadyInstalled(cfg) {
134
- return isObj(cfg['mcp_servers']) && CODEX_MCP_SERVER_ID in cfg['mcp_servers'];
274
+ function codexAlreadyInstalled(cfg, sessionStartCommand, promptRecallCommand) {
275
+ if (!isObj(cfg['mcp_servers']) || !(CODEX_MCP_SERVER_ID in cfg['mcp_servers']))
276
+ return false;
277
+ const hooks = cfg['hooks'];
278
+ if (!isObj(hooks))
279
+ return false;
280
+ const sessionStart = Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : [];
281
+ const promptSubmit = Array.isArray(hooks['UserPromptSubmit']) ? hooks['UserPromptSubmit'] : [];
282
+ return sessionStart.some((entry) => hookHasExactCommand(entry, sessionStartCommand))
283
+ && promptSubmit.some((entry) => hookHasExactCommand(entry, promptRecallCommand));
135
284
  }
136
285
  // ── install / uninstall ───────────────────────────────────────────────────────
137
286
  /**
@@ -141,31 +290,46 @@ function codexAlreadyInstalled(cfg) {
141
290
  */
142
291
  export function installCodex(plan, opts) {
143
292
  const hookCommand = opts.hookCommand ?? DEFAULT_HOOK_COMMAND;
293
+ const promptRecallHookCommand = opts.promptRecallHookCommand ?? DEFAULT_PROMPT_RECALL_HOOK_COMMAND;
144
294
  const codexDir = join(opts.configRoot, '.codex');
145
295
  const configPath = join(codexDir, 'config.toml');
146
- assertNotSymlink(opts.configRoot, 'config root');
147
- assertNotSymlink(codexDir, '.codex');
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
- }
296
+ const assertSafe = codexPathGuard(opts.configRoot, codexDir, configPath);
297
+ assertSafe();
153
298
  const mcpServer = codexMcpServerEntry(opts.server);
154
299
  const sessionStartHook = codexSessionStartHook(hookCommand);
155
- const merged = mergeCodexConfig(existing, mcpServer, sessionStartHook);
300
+ const userPromptSubmitHook = codexUserPromptSubmitHook(promptRecallHookCommand);
156
301
  mkdirSync(codexDir, { recursive: true });
157
- writeTomlAtomic(configPath, merged);
158
- return { ok: true, runtime: plan.runtime, mode: plan.mode, files: [configPath] };
302
+ const changed = writeTomlWithRetry(configPath, (current) => {
303
+ if (!opts.force && codexAlreadyInstalled(current, hookCommand, promptRecallHookCommand)) {
304
+ return withCodexProductBridge(current, false);
305
+ }
306
+ return {
307
+ changed: true,
308
+ data: withCodexProductBridge(mergeCodexConfig(current, mcpServer, sessionStartHook, userPromptSubmitHook), opts.force === true).data,
309
+ };
310
+ }, assertSafe);
311
+ return {
312
+ ok: true,
313
+ runtime: plan.runtime,
314
+ mode: plan.mode,
315
+ files: changed ? [configPath] : [],
316
+ ...(!changed ? { alreadyInstalled: true } : {}),
317
+ };
159
318
  }
160
319
  /** Remove evolver's MCP registration + SessionStart hook from a codex project config (leaves user content intact). */
161
320
  export function uninstallCodex(runtime, opts) {
162
- const configPath = join(opts.configRoot, '.codex', 'config.toml');
163
- assertNotSymlink(configPath, '.codex/config.toml');
321
+ const codexDir = join(opts.configRoot, '.codex');
322
+ const configPath = join(codexDir, 'config.toml');
323
+ const assertSafe = codexPathGuard(opts.configRoot, codexDir, configPath);
324
+ assertSafe();
164
325
  if (!existsSync(configPath))
165
326
  return { ok: true, runtime, mode: 'uninstall', files: [] };
166
- const { changed, data } = stripCodexManaged(readToml(configPath));
167
- if (!changed)
168
- return { ok: true, runtime, mode: 'uninstall', files: [] };
169
- writeTomlAtomic(configPath, data);
170
- return { ok: true, runtime, mode: 'uninstall', files: [configPath] };
327
+ const changed = writeTomlWithRetry(configPath, (current) => {
328
+ const stripped = stripCodexManaged(current);
329
+ return {
330
+ ...stripped,
331
+ remove: stripped.changed && Object.keys(stripped.data).length === 0,
332
+ };
333
+ }, assertSafe);
334
+ return { ok: true, runtime, mode: 'uninstall', files: changed ? [configPath] : [] };
171
335
  }
@@ -1,4 +1,4 @@
1
- import { type InstallResult } from './installer.js';
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 './installer.js';
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 writeTextAtomic(path, text) {
51
- const tmp = `${path}.tmp`;
52
- writeFileSync(tmp, text, 'utf8');
53
- renameSync(tmp, path);
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 rulesDir = join(opts.configRoot, CURSOR_RULES_DIR);
139
- const cursorDir = join(opts.configRoot, '.cursor');
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
- assertNotSymlink(opts.configRoot, 'config root');
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 filePath = cursorRulesPath(opts.configRoot);
163
- assertNotSymlink(filePath, '.cursor/rules/evolver.mdc');
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/envFile.d.ts CHANGED
@@ -7,4 +7,5 @@ export interface EnvFileLoadResult {
7
7
  export declare function expandHomePath(path: string): string;
8
8
  export declare function parseEnvFile(raw: string): Record<string, string>;
9
9
  export declare function loadEnvFile(path: string, env?: Record<string, string | undefined>): EnvFileLoadResult;
10
- export declare function loadEnvFileFromEnv(env?: Record<string, string | undefined>): EnvFileLoadResult;
10
+ export declare function loadEnvFileFromEnv(env?: Record<string, string | undefined>): EnvFileLoadResult;
11
+ export declare function loadEnvFileFromEnvOrThrow(env?: Record<string, string | undefined>): EnvFileLoadResult;
package/dist/envFile.js CHANGED
@@ -48,6 +48,12 @@ export function loadEnvFileFromEnv(env = process.env) {
48
48
  return { loaded: false, keys: [] };
49
49
  return loadEnvFile(path, env);
50
50
  }
51
+ export function loadEnvFileFromEnvOrThrow(env = process.env) {
52
+ const result = loadEnvFileFromEnv(env);
53
+ if (result.error)
54
+ throw new Error('failed to load EVOLVER_ENV_FILE');
55
+ return result;
56
+ }
51
57
  function unquoteEnvValue(value) {
52
58
  if (value.length >= 2) {
53
59
  const first = value[0];
package/dist/index.d.ts CHANGED
@@ -10,4 +10,8 @@ export * from './serviceGuidance.js';
10
10
  export * from './codexInstaller.js';
11
11
  export * from './cursorRulesInstaller.js';
12
12
  export * from './antigravityInstaller.js';
13
- export * from './envFile.js';
13
+ export * from './jsonMcpInstaller.js';
14
+ export * from './opencodeInstaller.js';
15
+ export * from './kiroInstaller.js';
16
+ export * from './envFile.js';
17
+ export * from './productBridge.js';
package/dist/index.js CHANGED
@@ -10,4 +10,8 @@ export * from './serviceGuidance.js';
10
10
  export * from './codexInstaller.js';
11
11
  export * from './cursorRulesInstaller.js';
12
12
  export * from './antigravityInstaller.js';
13
- export * from './envFile.js';
13
+ export * from './jsonMcpInstaller.js';
14
+ export * from './opencodeInstaller.js';
15
+ export * from './kiroInstaller.js';
16
+ export * from './envFile.js';
17
+ export * from './productBridge.js';
@@ -4,7 +4,8 @@ export type InjectionMode = 'mcp-hooks' | 'mcp-plugin' | 'mcp-config' | 'cursor-
4
4
  * Setup support contract (#217). A bootstrapper that delegates runtime onboarding to evolver v2 needs a
5
5
  * DETERMINISTIC answer for every runtime it might ask about — not just the ones v2 can write config for.
6
6
  * The three outcomes are the whole contract:
7
- * - `installed` v2 can write the runtime config/hooks and verify it (claude-code, codex, cursor, antigravity).
7
+ * - `installed` v2 can write the runtime config/hooks and verify it (claude-code, codex, cursor, antigravity,
8
+ * opencode, kiro).
8
9
  * - `manual` v2 cannot mutate this runtime's config, but the path is real: it prints precise MCP/HTTP
9
10
  * wiring the operator does by hand (opencode, openclaw, mcp-generic, http-agent, server).
10
11
  * - `unsupported` v2 refuses with a clear reason (an unrecognized runtime id).