@evomap/evolver-mcp 2.0.0-beta.2 → 2.0.0-beta.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/antigravityInstaller.d.ts +1 -1
- package/dist/antigravityInstaller.js +17 -29
- package/dist/codexInstaller.d.ts +8 -3
- package/dist/codexInstaller.js +217 -53
- package/dist/cursorRulesInstaller.d.ts +1 -1
- package/dist/cursorRulesInstaller.js +66 -17
- package/dist/envFile.d.ts +2 -1
- package/dist/envFile.js +6 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/injection.d.ts +2 -1
- package/dist/injection.js +10 -7
- package/dist/installer.d.ts +52 -24
- package/dist/installer.js +268 -157
- package/dist/installerShared.d.ts +103 -0
- package/dist/installerShared.js +99 -0
- package/dist/jsonMcpInstaller.d.ts +79 -0
- package/dist/jsonMcpInstaller.js +857 -0
- package/dist/kiroInstaller.d.ts +10 -0
- package/dist/kiroInstaller.js +146 -0
- package/dist/opencodeInstaller.d.ts +18 -0
- package/dist/opencodeInstaller.js +531 -0
- package/dist/primer.js +8 -5
- package/dist/productBridge.d.ts +37 -0
- package/dist/productBridge.js +250 -0
- package/dist/productBridgeShim.d.ts +52 -0
- package/dist/productBridgeShim.js +338 -0
- package/dist/proxyClient.d.ts +27 -0
- package/dist/proxyClient.js +135 -26
- package/dist/sharedFileCommit.d.ts +20 -0
- package/dist/sharedFileCommit.js +256 -0
- package/dist/stdio.js +10 -4
- package/dist/tools.js +71 -7
- package/package.json +13 -2
|
@@ -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,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,
|
|
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';
|
|
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
|
|
49
|
+
function readTomlSnapshot(path) {
|
|
50
|
+
let raw;
|
|
47
51
|
try {
|
|
48
|
-
|
|
49
|
-
return {};
|
|
50
|
-
const raw = readFileSync(path, 'utf8').trim();
|
|
51
|
-
return raw ? parseToml(raw) : {};
|
|
52
|
+
raw = readFileSync(path, 'utf8');
|
|
52
53
|
}
|
|
53
|
-
catch {
|
|
54
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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: [{
|
|
163
|
+
return { matcher: SESSION_START_MATCHER, hooks: [{
|
|
164
|
+
type: 'command', command, statusMessage: evolverManagedHookStatus(command),
|
|
165
|
+
}] };
|
|
74
166
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
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[
|
|
262
|
+
hooks[event] = kept;
|
|
122
263
|
else
|
|
123
|
-
delete hooks[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
300
|
+
const userPromptSubmitHook = codexUserPromptSubmitHook(promptRecallHookCommand);
|
|
156
301
|
mkdirSync(codexDir, { recursive: true });
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
163
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
return {
|
|
169
|
-
|
|
170
|
-
|
|
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 './
|
|
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/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 './
|
|
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 './
|
|
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/injection.d.ts
CHANGED
|
@@ -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).
|