prism-mcp-server 20.2.0 → 20.2.2
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/README.md +137 -15
- package/dist/cli.js +222 -34
- package/dist/config.js +14 -7
- package/dist/connect.js +854 -16
- package/dist/dashboard/server.js +9 -14
- package/dist/dashboard/settingsPolicy.js +41 -0
- package/dist/localFirstPolicy.js +20 -0
- package/dist/onboarding/wizard.js +3 -6
- package/dist/server.js +77 -65
- package/dist/session/sessionContext.js +5 -3
- package/dist/skillManifestSync.js +943 -0
- package/dist/storage/configStorage.js +110 -1
- package/dist/storage/index.js +15 -4
- package/dist/storage/inferMetricsLedger.js +89 -16
- package/dist/storage/panelMetricsSpool.js +324 -0
- package/dist/storage/sqlite.js +1 -1
- package/dist/storage/synalux.js +18 -1
- package/dist/tools/__tests__/ledgerHandlers.test.js +13 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/ledgerHandlers.js +468 -53
- package/dist/tools/prismInferHandler.js +171 -12
- package/dist/tools/sessionMemoryDefinitions.js +51 -10
- package/dist/tools/skillRouting.js +39 -7
- package/dist/tools/taskRouterHandler.js +184 -29
- package/dist/utils/entitlements.js +6 -2
- package/dist/utils/inferenceMetrics.js +22 -3
- package/dist/utils/modelPicker.js +3 -3
- package/dist/utils/synaluxJwt.js +8 -3
- package/package.json +6 -2
package/dist/connect.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as win32Path } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
7
|
+
import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
|
|
7
8
|
export const CONNECT_HOSTS = [
|
|
8
9
|
"claude-code",
|
|
9
10
|
"claude-desktop",
|
|
@@ -25,6 +26,49 @@ const HOST_ALIASES = {
|
|
|
25
26
|
};
|
|
26
27
|
const CODEX_MANAGED_START = "# >>> prism connect managed: prism-mcp";
|
|
27
28
|
const CODEX_MANAGED_END = "# <<< prism connect managed: prism-mcp";
|
|
29
|
+
const CLAUDE_STARTUP_MANAGED_START = "<!-- >>> prism connect managed: native startup -->";
|
|
30
|
+
const CLAUDE_STARTUP_MANAGED_END = "<!-- <<< prism connect managed: native startup -->";
|
|
31
|
+
const GEMINI_STARTUP_MANAGED_START = "<!-- >>> prism connect managed: native startup -->";
|
|
32
|
+
const GEMINI_STARTUP_MANAGED_END = "<!-- <<< prism connect managed: native startup -->";
|
|
33
|
+
const CODEX_STARTUP_MANAGED_START = "<!-- >>> prism connect managed: codex native startup -->";
|
|
34
|
+
const CODEX_STARTUP_MANAGED_END = "<!-- <<< prism connect managed: codex native startup -->";
|
|
35
|
+
const CODEX_STARTUP_BODY = [
|
|
36
|
+
"## Prism session startup",
|
|
37
|
+
"On the first user turn of every conversation, including a greeting, your first action must be",
|
|
38
|
+
"`session_bootstrap({})`, exactly once. Emit no preamble. Print the complete tool result verbatim as the",
|
|
39
|
+
"entire first-turn startup display, before any optional answer. Do not summarize, paraphrase, rename headings,",
|
|
40
|
+
"reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, stop",
|
|
41
|
+
"after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery to load that",
|
|
42
|
+
"exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection as",
|
|
43
|
+
"a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
|
|
44
|
+
"`Prism startup failure` and stop. Reuse the `conversation_id` returned in structuredContent for every",
|
|
45
|
+
"session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This hook-free",
|
|
46
|
+
"block is managed by `prism connect`; do not edit it manually.",
|
|
47
|
+
"",
|
|
48
|
+
...LOCAL_FIRST_POLICY_LINES,
|
|
49
|
+
];
|
|
50
|
+
const CONNECT_STORAGE_BACKENDS = ["auto", "local", "synalux", "supabase"];
|
|
51
|
+
const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
|
|
52
|
+
command: "npx",
|
|
53
|
+
args: ["-y", "prism-mcp-server"],
|
|
54
|
+
};
|
|
55
|
+
const INSTALLATION_RECEIPT_SCHEMA_VERSION = 1;
|
|
56
|
+
const INSTALLATION_RECEIPT_OWNER = "prism-connect";
|
|
57
|
+
const CLAUDE_FALLBACK_SUBAGENT_MODEL = "sonnet";
|
|
58
|
+
const CODEX_LOCAL_FIRST_POLICY = {
|
|
59
|
+
features: {
|
|
60
|
+
multi_agent: false,
|
|
61
|
+
},
|
|
62
|
+
agents: {
|
|
63
|
+
max_threads: 2,
|
|
64
|
+
max_depth: 1,
|
|
65
|
+
default_subagent_model: "gpt-5.6-terra",
|
|
66
|
+
default_subagent_reasoning_effort: "low",
|
|
67
|
+
job_max_runtime_seconds: 900,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const CODEX_POLICY_MARKER_PREFIX = "# >>> prism connect managed: local-first";
|
|
71
|
+
const CODEX_POLICY_MARKER_SUFFIX = "# <<< prism connect managed: local-first";
|
|
28
72
|
export function normalizeHostName(value) {
|
|
29
73
|
const normalized = value.trim().toLowerCase();
|
|
30
74
|
const host = HOST_ALIASES[normalized];
|
|
@@ -51,6 +95,19 @@ export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
|
|
|
51
95
|
}
|
|
52
96
|
return realpathSync(serverPath);
|
|
53
97
|
}
|
|
98
|
+
function resolveInstalledPackageVersion(moduleUrl = import.meta.url) {
|
|
99
|
+
const manifestPath = fileURLToPath(new URL("../package.json", moduleUrl));
|
|
100
|
+
try {
|
|
101
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
102
|
+
if (isJsonObject(manifest) && typeof manifest.version === "string" && manifest.version.trim()) {
|
|
103
|
+
return manifest.version;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new Error(`Cannot read Prism package version at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Prism package manifest has no valid "version" entry: ${manifestPath}`);
|
|
110
|
+
}
|
|
54
111
|
export function connectHosts(options = {}) {
|
|
55
112
|
if (options.all && options.hosts?.length) {
|
|
56
113
|
throw new Error("Use either --all or --host, not both.");
|
|
@@ -79,12 +136,775 @@ export function connectHosts(options = {}) {
|
|
|
79
136
|
}
|
|
80
137
|
const entry = buildMcpEntry(nodePath, serverPath, env);
|
|
81
138
|
const results = selected.map((definition) => registerHost(definition, entry, !!options.dryRun, !!options.refresh, options.beforeCommit));
|
|
139
|
+
let installationReceiptPath;
|
|
140
|
+
if (!options.dryRun && results.some((item) => item.status !== "error" && item.startupCompatible)) {
|
|
141
|
+
const pathApi = platform === "win32" ? win32Path : { dirname, join, isAbsolute, resolve };
|
|
142
|
+
const absoluteNodePath = pathApi.isAbsolute(nodePath) ? nodePath : pathApi.resolve(nodePath);
|
|
143
|
+
const absoluteServerPath = pathApi.isAbsolute(serverPath) ? serverPath : pathApi.resolve(serverPath);
|
|
144
|
+
const rawCliPath = options.cliPath ?? pathApi.join(pathApi.dirname(absoluteServerPath), "cli.js");
|
|
145
|
+
const absoluteCliPath = pathApi.isAbsolute(rawCliPath) ? rawCliPath : pathApi.resolve(rawCliPath);
|
|
146
|
+
installationReceiptPath = writeInstallationReceipt(homeDir, {
|
|
147
|
+
schema_version: INSTALLATION_RECEIPT_SCHEMA_VERSION,
|
|
148
|
+
owner: INSTALLATION_RECEIPT_OWNER,
|
|
149
|
+
node_path: absoluteNodePath,
|
|
150
|
+
cli_path: absoluteCliPath,
|
|
151
|
+
server_path: absoluteServerPath,
|
|
152
|
+
package_version: options.packageVersion ?? resolveInstalledPackageVersion(),
|
|
153
|
+
}, options.beforeReceiptCommit);
|
|
154
|
+
}
|
|
82
155
|
return {
|
|
83
156
|
results,
|
|
84
157
|
usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
|
|
85
158
|
serverPath,
|
|
159
|
+
installationReceiptPath,
|
|
86
160
|
};
|
|
87
161
|
}
|
|
162
|
+
/** Write the Prism-owned GUI discovery receipt without following symlinks. */
|
|
163
|
+
export function writeInstallationReceipt(homeDir, receipt, beforeCommit) {
|
|
164
|
+
if (!isAbsolute(resolve(homeDir)))
|
|
165
|
+
throw new Error("Prism installation receipt home must resolve absolutely");
|
|
166
|
+
for (const [key, value] of Object.entries(receipt)) {
|
|
167
|
+
if (key.endsWith("_path") && (typeof value !== "string" || (!isAbsolute(value) && !win32Path.isAbsolute(value)))) {
|
|
168
|
+
throw new Error(`Prism installation receipt ${key} must be absolute`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const directory = join(resolve(homeDir), ".prism-mcp");
|
|
172
|
+
const receiptPath = join(directory, "installation.json");
|
|
173
|
+
try {
|
|
174
|
+
const directoryInfo = lstatSync(directory);
|
|
175
|
+
if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) {
|
|
176
|
+
throw new Error("receipt directory must be a real directory");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (!isErrno(error, "ENOENT")) {
|
|
181
|
+
throw new Error(`Could not secure Prism installation receipt directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
182
|
+
}
|
|
183
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
184
|
+
const directoryInfo = lstatSync(directory);
|
|
185
|
+
if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) {
|
|
186
|
+
throw new Error("Could not secure Prism installation receipt directory");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let originalText;
|
|
190
|
+
try {
|
|
191
|
+
const receiptInfo = lstatSync(receiptPath);
|
|
192
|
+
if (receiptInfo.isSymbolicLink() || !receiptInfo.isFile()) {
|
|
193
|
+
throw new Error("receipt must be a regular Prism-owned file");
|
|
194
|
+
}
|
|
195
|
+
originalText = readFileSync(receiptPath, "utf8");
|
|
196
|
+
const current = JSON.parse(originalText);
|
|
197
|
+
if (!isJsonObject(current)
|
|
198
|
+
|| current.schema_version !== INSTALLATION_RECEIPT_SCHEMA_VERSION
|
|
199
|
+
|| current.owner !== INSTALLATION_RECEIPT_OWNER) {
|
|
200
|
+
throw new Error("existing receipt is not owned by prism-connect");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (!isErrno(error, "ENOENT")) {
|
|
205
|
+
throw new Error(`Could not inspect Prism installation receipt: ${error instanceof Error ? error.message : String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
writeTextAtomically(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, originalText, beforeCommit);
|
|
209
|
+
return receiptPath;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Remove the exact project-scoped registration written by Prism's former
|
|
213
|
+
* `npx -y prism-mcp-server` setup. Claude Code resolves the nearest ancestor
|
|
214
|
+
* `.mcp.json`, so a stale entry there can shadow the native user registration.
|
|
215
|
+
* Custom Prism commands, arguments, environment, and unrelated servers remain
|
|
216
|
+
* untouched.
|
|
217
|
+
*/
|
|
218
|
+
export function migrateLegacyClaudeProjectMcp(homeDir = homedir(), cwd = process.cwd(), dryRun = false, beforeCommit) {
|
|
219
|
+
let resolvedHome;
|
|
220
|
+
let resolvedCwd;
|
|
221
|
+
try {
|
|
222
|
+
resolvedHome = realpathSync(resolve(homeDir));
|
|
223
|
+
resolvedCwd = realpathSync(resolve(cwd));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw new Error(`Could not resolve Claude project MCP search path: ${error instanceof Error ? error.message : String(error)}`);
|
|
227
|
+
}
|
|
228
|
+
const fallbackPath = join(resolvedHome, ".mcp.json");
|
|
229
|
+
if (!isPathWithin(resolvedCwd, resolvedHome)) {
|
|
230
|
+
return { path: fallbackPath, status: "unchanged", removed: 0 };
|
|
231
|
+
}
|
|
232
|
+
let configPath;
|
|
233
|
+
for (let directory = resolvedCwd;; directory = dirname(directory)) {
|
|
234
|
+
const candidate = join(directory, ".mcp.json");
|
|
235
|
+
try {
|
|
236
|
+
lstatSync(candidate);
|
|
237
|
+
configPath = candidate;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (!isErrno(error, "ENOENT")) {
|
|
242
|
+
throw new Error(`Could not inspect Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (directory === resolvedHome)
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
if (!configPath)
|
|
249
|
+
return { path: fallbackPath, status: "unchanged", removed: 0 };
|
|
250
|
+
let writePath = configPath;
|
|
251
|
+
let symlinkPath;
|
|
252
|
+
let originalText;
|
|
253
|
+
try {
|
|
254
|
+
const pathInfo = lstatSync(configPath);
|
|
255
|
+
if (pathInfo.isSymbolicLink()) {
|
|
256
|
+
writePath = realpathSync(configPath);
|
|
257
|
+
symlinkPath = configPath;
|
|
258
|
+
}
|
|
259
|
+
if (!statSync(writePath).isFile())
|
|
260
|
+
throw new Error("config is not a regular file");
|
|
261
|
+
originalText = readFileSync(writePath, "utf8");
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
throw new Error(`Could not inspect Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
|
|
265
|
+
}
|
|
266
|
+
let config;
|
|
267
|
+
try {
|
|
268
|
+
const parsed = JSON.parse(originalText);
|
|
269
|
+
if (!isJsonObject(parsed))
|
|
270
|
+
throw new Error("top-level JSON value must be an object");
|
|
271
|
+
config = parsed;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
throw new Error(`Could not parse Claude project MCP config: ${error instanceof Error ? error.message : String(error)}`);
|
|
275
|
+
}
|
|
276
|
+
const currentServers = config.mcpServers;
|
|
277
|
+
if (currentServers === undefined) {
|
|
278
|
+
return { path: configPath, status: "unchanged", removed: 0 };
|
|
279
|
+
}
|
|
280
|
+
if (!isJsonObject(currentServers)) {
|
|
281
|
+
throw new Error('Could not inspect Claude project MCP config: "mcpServers" must be a JSON object');
|
|
282
|
+
}
|
|
283
|
+
if (!Object.prototype.hasOwnProperty.call(currentServers, "prism-mcp")
|
|
284
|
+
|| !isDeepStrictEqual(currentServers["prism-mcp"], LEGACY_CLAUDE_PROJECT_PRISM_ENTRY)) {
|
|
285
|
+
return { path: configPath, status: "unchanged", removed: 0 };
|
|
286
|
+
}
|
|
287
|
+
delete currentServers["prism-mcp"];
|
|
288
|
+
if (dryRun)
|
|
289
|
+
return { path: configPath, status: "would-remove", removed: 1 };
|
|
290
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
291
|
+
return { path: configPath, status: "removed", removed: 1 };
|
|
292
|
+
}
|
|
293
|
+
function legacyClaudeHookTuples(homeDir) {
|
|
294
|
+
const hooksDir = join(homeDir, ".claude", "hooks");
|
|
295
|
+
const syncSkills = `${join(homeDir, "prism", "scripts", "sync-skills.sh")} > /dev/null 2>&1`;
|
|
296
|
+
const loadMatcher = "session_load_context|mcp__prism-mcp__session_load_context";
|
|
297
|
+
const driftMatcher = "session_detect_drift|mcp__prism-mcp__session_detect_drift|session_save_ledger|mcp__prism-mcp__session_save_ledger";
|
|
298
|
+
const stop = "python3 -c \"import json; print(json.dumps({'continue': True, 'suppressOutput': True, 'systemMessage': 'MANDATORY END WORKFLOW: 1) Call mcp__prism-mcp__session_save_ledger with project and summary. 2) Call mcp__prism-mcp__session_save_handoff with expected_version set to the loaded version.'}))\"";
|
|
299
|
+
const tuple = (event, matcher, command) => JSON.stringify([event, matcher, "command", command]);
|
|
300
|
+
return new Set([
|
|
301
|
+
tuple("SessionStart", "*", `python3 ${join(hooksDir, "prism-startup", "init.py")}`),
|
|
302
|
+
tuple("SessionStart", "*", syncSkills),
|
|
303
|
+
tuple("UserPromptSubmit", "*", `python3 ${join(hooksDir, "prism-startup", "guard_on_submit.py")}`),
|
|
304
|
+
tuple("UserPromptSubmit", "*", join(hooksDir, "prism-startup", "maybe_sync_skills.sh")),
|
|
305
|
+
tuple("PostToolUse", loadMatcher, `python3 ${join(hooksDir, "prism-startup", "mark_loaded.py")}`),
|
|
306
|
+
tuple("PostToolUse", driftMatcher, `python3 ${join(hooksDir, "drift-detection", "reset_timer.py")}`),
|
|
307
|
+
tuple("PostToolUseFailure", loadMatcher, `python3 ${join(hooksDir, "prism-startup", "record_retry.py")}`),
|
|
308
|
+
tuple("SessionEnd", "*", `python3 ${join(hooksDir, "prism-startup", "cleanup.py")}`),
|
|
309
|
+
tuple("SessionEnd", "*", syncSkills),
|
|
310
|
+
tuple("Stop", "*", stop),
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Remove only the exact Claude lifecycle hooks installed by Prism's legacy
|
|
315
|
+
* bootstrap script. Native skills and server-side drift reminders now own the
|
|
316
|
+
* same behavior; unrelated and near-match user hooks must remain untouched.
|
|
317
|
+
*/
|
|
318
|
+
export function migrateLegacyClaudeHooks(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
319
|
+
const configPath = join(homeDir, ".claude", "settings.json");
|
|
320
|
+
let writePath = configPath;
|
|
321
|
+
let symlinkPath;
|
|
322
|
+
let originalText;
|
|
323
|
+
try {
|
|
324
|
+
const pathInfo = lstatSync(configPath);
|
|
325
|
+
if (pathInfo.isSymbolicLink()) {
|
|
326
|
+
writePath = realpathSync(configPath);
|
|
327
|
+
symlinkPath = configPath;
|
|
328
|
+
if (!statSync(writePath).isFile())
|
|
329
|
+
throw new Error("target is not a regular file");
|
|
330
|
+
}
|
|
331
|
+
originalText = readFileSync(writePath, "utf8");
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (isErrno(error, "ENOENT"))
|
|
335
|
+
return { path: configPath, status: "unchanged", removed: 0 };
|
|
336
|
+
throw new Error(`Could not inspect Claude hook settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
337
|
+
}
|
|
338
|
+
let config;
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(originalText);
|
|
341
|
+
if (!isJsonObject(parsed))
|
|
342
|
+
throw new Error("top-level JSON value must be an object");
|
|
343
|
+
config = parsed;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
throw new Error(`Could not parse Claude hook settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
347
|
+
}
|
|
348
|
+
if (!isJsonObject(config.hooks))
|
|
349
|
+
return { path: configPath, status: "unchanged", removed: 0 };
|
|
350
|
+
const legacyTuples = legacyClaudeHookTuples(homeDir);
|
|
351
|
+
const nextEvents = {};
|
|
352
|
+
let removed = 0;
|
|
353
|
+
for (const [event, groups] of Object.entries(config.hooks)) {
|
|
354
|
+
if (!Array.isArray(groups)) {
|
|
355
|
+
nextEvents[event] = groups;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const nextGroups = [];
|
|
359
|
+
for (const group of groups) {
|
|
360
|
+
if (!isJsonObject(group) || !Array.isArray(group.hooks)) {
|
|
361
|
+
nextGroups.push(group);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
let removedFromGroup = 0;
|
|
365
|
+
const nextHooks = group.hooks.filter((hook) => {
|
|
366
|
+
const owned = isJsonObject(hook)
|
|
367
|
+
&& hook.type === "command"
|
|
368
|
+
&& typeof hook.command === "string"
|
|
369
|
+
&& legacyTuples.has(JSON.stringify([event, group.matcher, hook.type, hook.command]));
|
|
370
|
+
if (owned)
|
|
371
|
+
removedFromGroup += 1;
|
|
372
|
+
return !owned;
|
|
373
|
+
});
|
|
374
|
+
removed += removedFromGroup;
|
|
375
|
+
if (removedFromGroup === 0)
|
|
376
|
+
nextGroups.push(group);
|
|
377
|
+
else if (nextHooks.length > 0)
|
|
378
|
+
nextGroups.push({ ...group, hooks: nextHooks });
|
|
379
|
+
}
|
|
380
|
+
if (nextGroups.length > 0)
|
|
381
|
+
nextEvents[event] = nextGroups;
|
|
382
|
+
}
|
|
383
|
+
if (removed === 0)
|
|
384
|
+
return { path: configPath, status: "unchanged", removed: 0 };
|
|
385
|
+
if (Object.keys(nextEvents).length > 0)
|
|
386
|
+
config.hooks = nextEvents;
|
|
387
|
+
else
|
|
388
|
+
delete config.hooks;
|
|
389
|
+
if (dryRun)
|
|
390
|
+
return { path: configPath, status: "would-remove", removed };
|
|
391
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
392
|
+
return { path: configPath, status: "removed", removed };
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Remove the legacy Prism-only startup sections from ~/CLAUDE.md. The rest of
|
|
396
|
+
* the developer's global instructions remain byte-for-byte unchanged. Native
|
|
397
|
+
* skill and MCP metadata now own first-turn bootstrap selection.
|
|
398
|
+
*/
|
|
399
|
+
export function migrateLegacyClaudeInstructions(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
400
|
+
const instructionPath = join(homeDir, "CLAUDE.md");
|
|
401
|
+
let writePath = instructionPath;
|
|
402
|
+
let symlinkPath;
|
|
403
|
+
let originalText;
|
|
404
|
+
let instructionEntryExists = false;
|
|
405
|
+
try {
|
|
406
|
+
const pathInfo = lstatSync(instructionPath);
|
|
407
|
+
instructionEntryExists = true;
|
|
408
|
+
if (pathInfo.isSymbolicLink()) {
|
|
409
|
+
writePath = realpathSync(instructionPath);
|
|
410
|
+
symlinkPath = instructionPath;
|
|
411
|
+
if (!statSync(writePath).isFile())
|
|
412
|
+
throw new Error("target is not a regular file");
|
|
413
|
+
}
|
|
414
|
+
originalText = readFileSync(writePath, "utf8");
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (!instructionEntryExists && isErrno(error, "ENOENT")) {
|
|
418
|
+
return { path: instructionPath, status: "unchanged", removed: 0 };
|
|
419
|
+
}
|
|
420
|
+
throw new Error(`Could not inspect Claude instructions: ${error instanceof Error ? error.message : String(error)}`);
|
|
421
|
+
}
|
|
422
|
+
const stepOne = findExactLineRanges(originalText, "## STEP 1: Auto-Load Prism Memory (MUST BE YOUR FIRST ACTION — NO EXCEPTIONS)");
|
|
423
|
+
const hardGates = findExactLineRanges(originalText, "## HARD BEHAVIORAL GATES — SUPERSEDE ALL OTHER INSTRUCTIONS");
|
|
424
|
+
if (stepOne.length !== 1 || hardGates.length !== 1 || hardGates[0].start <= stepOne[0].start) {
|
|
425
|
+
return { path: instructionPath, status: "unchanged", removed: 0 };
|
|
426
|
+
}
|
|
427
|
+
const legacyBlock = originalText.slice(stepOne[0].start, hardGates[0].start);
|
|
428
|
+
const signatures = [
|
|
429
|
+
'mcp__prism-mcp__session_load_context(project="prism-mcp")',
|
|
430
|
+
"## STEP 2: Display Startup Block (ONLY AFTER STEP 1 COMPLETES)",
|
|
431
|
+
"Use the `[📜 SKILL: ...]` blocks returned by session_load_context to build the display:",
|
|
432
|
+
];
|
|
433
|
+
if (!signatures.every((signature) => legacyBlock.includes(signature))) {
|
|
434
|
+
return { path: instructionPath, status: "unchanged", removed: 0 };
|
|
435
|
+
}
|
|
436
|
+
const nextText = originalText.slice(0, stepOne[0].start) + originalText.slice(hardGates[0].start);
|
|
437
|
+
if (dryRun)
|
|
438
|
+
return { path: instructionPath, status: "would-remove", removed: 2 };
|
|
439
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
440
|
+
return { path: instructionPath, status: "removed", removed: 2 };
|
|
441
|
+
}
|
|
442
|
+
/** Remove only Prism's marked startup block from the former ~/CLAUDE.md path. */
|
|
443
|
+
export function migrateLegacyClaudeManagedStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
444
|
+
const instructionPath = join(homeDir, "CLAUDE.md");
|
|
445
|
+
let writePath = instructionPath;
|
|
446
|
+
let symlinkPath;
|
|
447
|
+
let originalText;
|
|
448
|
+
let instructionEntryExists = false;
|
|
449
|
+
try {
|
|
450
|
+
const pathInfo = lstatSync(instructionPath);
|
|
451
|
+
instructionEntryExists = true;
|
|
452
|
+
if (pathInfo.isSymbolicLink()) {
|
|
453
|
+
writePath = realpathSync(instructionPath);
|
|
454
|
+
symlinkPath = instructionPath;
|
|
455
|
+
if (!statSync(writePath).isFile())
|
|
456
|
+
throw new Error("target is not a regular file");
|
|
457
|
+
}
|
|
458
|
+
originalText = readFileSync(writePath, "utf8");
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (!instructionEntryExists && isErrno(error, "ENOENT")) {
|
|
462
|
+
return { path: instructionPath, status: "unchanged", removed: 0 };
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`Could not inspect legacy Claude instructions: ${error instanceof Error ? error.message : String(error)}`);
|
|
465
|
+
}
|
|
466
|
+
const startRanges = findExactLineRanges(originalText, CLAUDE_STARTUP_MANAGED_START);
|
|
467
|
+
const endRanges = findExactLineRanges(originalText, CLAUDE_STARTUP_MANAGED_END);
|
|
468
|
+
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
469
|
+
throw new Error(`Legacy Claude instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
470
|
+
}
|
|
471
|
+
if (startRanges.length === 0)
|
|
472
|
+
return { path: instructionPath, status: "unchanged", removed: 0 };
|
|
473
|
+
if (endRanges[0].start <= startRanges[0].start) {
|
|
474
|
+
throw new Error(`Legacy Claude instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
|
|
475
|
+
}
|
|
476
|
+
const nextText = originalText.slice(0, startRanges[0].start) + originalText.slice(endRanges[0].end);
|
|
477
|
+
if (dryRun)
|
|
478
|
+
return { path: instructionPath, status: "would-remove", removed: 1 };
|
|
479
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
480
|
+
return { path: instructionPath, status: "removed", removed: 1 };
|
|
481
|
+
}
|
|
482
|
+
function serializeClaudeStartupBlock(newline) {
|
|
483
|
+
return [
|
|
484
|
+
CLAUDE_STARTUP_MANAGED_START,
|
|
485
|
+
"## Prism session startup",
|
|
486
|
+
"On the first user turn of every conversation, including a greeting, your first action must be to call",
|
|
487
|
+
"`mcp__prism-mcp__session_bootstrap` exactly once with an empty object (`{}`). Emit no preamble. Print the",
|
|
488
|
+
"complete tool result verbatim as the entire first-turn startup display, before any optional answer. Do not",
|
|
489
|
+
"summarize, paraphrase, rename headings, reformat, or omit any returned section. Preserve its order and line",
|
|
490
|
+
"content. For a greeting-only prompt, stop after the verbatim startup display. If `session_bootstrap` is",
|
|
491
|
+
"deferred, use native tool discovery/ToolSearch to load that",
|
|
492
|
+
"exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection",
|
|
493
|
+
"as a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
|
|
494
|
+
"`Prism startup failure` and stop. Reuse the `conversation_id` returned in structuredContent for every",
|
|
495
|
+
"session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This block is",
|
|
496
|
+
"managed by `prism connect`; do not edit it manually.",
|
|
497
|
+
"",
|
|
498
|
+
...LOCAL_FIRST_POLICY_LINES,
|
|
499
|
+
CLAUDE_STARTUP_MANAGED_END,
|
|
500
|
+
"",
|
|
501
|
+
].join(newline);
|
|
502
|
+
}
|
|
503
|
+
/** Install or refresh Claude Code's native, hook-free first-turn instruction. */
|
|
504
|
+
export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
505
|
+
const instructionPath = join(homeDir, ".claude", "CLAUDE.md");
|
|
506
|
+
let writePath = instructionPath;
|
|
507
|
+
let symlinkPath;
|
|
508
|
+
let originalText;
|
|
509
|
+
let instructionEntryExists = false;
|
|
510
|
+
try {
|
|
511
|
+
const pathInfo = lstatSync(instructionPath);
|
|
512
|
+
instructionEntryExists = true;
|
|
513
|
+
if (pathInfo.isSymbolicLink()) {
|
|
514
|
+
writePath = realpathSync(instructionPath);
|
|
515
|
+
symlinkPath = instructionPath;
|
|
516
|
+
if (!statSync(writePath).isFile())
|
|
517
|
+
throw new Error("target is not a regular file");
|
|
518
|
+
}
|
|
519
|
+
originalText = readFileSync(writePath, "utf8");
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
if (instructionEntryExists || !isErrno(error, "ENOENT")) {
|
|
523
|
+
throw new Error(`Could not inspect Claude instructions: ${error instanceof Error ? error.message : String(error)}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const currentText = originalText ?? "";
|
|
527
|
+
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
528
|
+
const startRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_START);
|
|
529
|
+
const endRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_END);
|
|
530
|
+
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
531
|
+
throw new Error(`Claude instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
532
|
+
}
|
|
533
|
+
const managedBlock = serializeClaudeStartupBlock(newline);
|
|
534
|
+
let nextText;
|
|
535
|
+
let action;
|
|
536
|
+
if (startRanges.length === 1) {
|
|
537
|
+
if (endRanges[0].start <= startRanges[0].start) {
|
|
538
|
+
throw new Error(`Claude instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
|
|
539
|
+
}
|
|
540
|
+
const managedEnd = endRanges[0].end;
|
|
541
|
+
if (currentText.slice(startRanges[0].start, managedEnd) === managedBlock) {
|
|
542
|
+
return { path: instructionPath, status: "unchanged" };
|
|
543
|
+
}
|
|
544
|
+
nextText = currentText.slice(0, startRanges[0].start) + managedBlock + currentText.slice(managedEnd);
|
|
545
|
+
action = "refresh";
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
const separator = currentText.length === 0
|
|
549
|
+
? ""
|
|
550
|
+
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
551
|
+
? newline
|
|
552
|
+
: `${newline}${newline}`;
|
|
553
|
+
nextText = currentText + separator + managedBlock;
|
|
554
|
+
action = "install";
|
|
555
|
+
}
|
|
556
|
+
if (dryRun) {
|
|
557
|
+
return { path: instructionPath, status: action === "install" ? "would-install" : "would-refresh" };
|
|
558
|
+
}
|
|
559
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
560
|
+
return { path: instructionPath, status: action === "install" ? "installed" : "refreshed" };
|
|
561
|
+
}
|
|
562
|
+
function serializeGeminiStartupBlock(newline) {
|
|
563
|
+
return [
|
|
564
|
+
GEMINI_STARTUP_MANAGED_START,
|
|
565
|
+
"## Prism session startup",
|
|
566
|
+
"On the first user turn of every conversation, including a greeting, your first action must be",
|
|
567
|
+
"`session_bootstrap({})`, exactly once. Emit no preamble. Print the complete tool result verbatim as the",
|
|
568
|
+
"entire first-turn startup display, before any optional answer. Do not summarize, paraphrase, rename headings,",
|
|
569
|
+
"reformat, or omit any returned section. Preserve its order and line content. For a greeting-only prompt, stop",
|
|
570
|
+
"after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery/ToolSearch",
|
|
571
|
+
"to load that exact tool, then invoke it.",
|
|
572
|
+
"Do not use shell commands, file reads, subagents, or unrelated tool inspection as a substitute. Do not call",
|
|
573
|
+
"`session_load_context`. If discovery or invocation fails, report `Prism startup failure` and stop. Reuse the",
|
|
574
|
+
"`conversation_id` returned in structuredContent for session_save_ledger, session_save_handoff, and",
|
|
575
|
+
"session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
|
|
576
|
+
"",
|
|
577
|
+
...LOCAL_FIRST_POLICY_LINES,
|
|
578
|
+
GEMINI_STARTUP_MANAGED_END,
|
|
579
|
+
"",
|
|
580
|
+
].join(newline);
|
|
581
|
+
}
|
|
582
|
+
/** The exact Gemini startup section provisioned before native bootstrap support. */
|
|
583
|
+
function legacyGeminiStartupBlock(newline) {
|
|
584
|
+
return [
|
|
585
|
+
"# Startup — MANDATORY",
|
|
586
|
+
"",
|
|
587
|
+
"Your first action in every conversation is loading Prism session context. Zero text before the tool call.",
|
|
588
|
+
"",
|
|
589
|
+
"**Dual-path detection:** Check your available toolset.",
|
|
590
|
+
"- **If `mcp_prism-mcp_session_load_context` exists:** Call it with `project: \"prism-mcp\"`, `level: \"deep\"`.",
|
|
591
|
+
"- **If no MCP tools are available (Antigravity):** Run `bash ~/.gemini/antigravity/scratch/prism_session_loader.sh prism-mcp` via `run_command`. This uses the `prism load` CLI under the hood, sharing the same storage layer (SQLite or Supabase) as the MCP tool.",
|
|
592
|
+
"",
|
|
593
|
+
"After success: echo agent identity, last summary, open TODOs, session version.",
|
|
594
|
+
"If any call fails: say \"Prism load failed — retrying\" and retry ONE more time.",
|
|
595
|
+
"",
|
|
596
|
+
"",
|
|
597
|
+
].join(newline);
|
|
598
|
+
}
|
|
599
|
+
/** Install or refresh Gemini CLI's native, hook-free first-turn instruction. */
|
|
600
|
+
export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
601
|
+
const instructionPath = join(homeDir, ".gemini", "GEMINI.md");
|
|
602
|
+
let writePath = instructionPath;
|
|
603
|
+
let symlinkPath;
|
|
604
|
+
let originalText;
|
|
605
|
+
let instructionEntryExists = false;
|
|
606
|
+
try {
|
|
607
|
+
const pathInfo = lstatSync(instructionPath);
|
|
608
|
+
instructionEntryExists = true;
|
|
609
|
+
if (pathInfo.isSymbolicLink()) {
|
|
610
|
+
writePath = realpathSync(instructionPath);
|
|
611
|
+
symlinkPath = instructionPath;
|
|
612
|
+
if (!statSync(writePath).isFile())
|
|
613
|
+
throw new Error("target is not a regular file");
|
|
614
|
+
}
|
|
615
|
+
originalText = readFileSync(writePath, "utf8");
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (instructionEntryExists || !isErrno(error, "ENOENT")) {
|
|
619
|
+
throw new Error(`Could not inspect Gemini instructions: ${error instanceof Error ? error.message : String(error)}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const currentText = originalText ?? "";
|
|
623
|
+
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
624
|
+
const startRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_START);
|
|
625
|
+
const endRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_END);
|
|
626
|
+
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
627
|
+
throw new Error(`Gemini instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
628
|
+
}
|
|
629
|
+
const managedBlock = serializeGeminiStartupBlock(newline);
|
|
630
|
+
let nextText;
|
|
631
|
+
let action;
|
|
632
|
+
if (startRanges.length === 1) {
|
|
633
|
+
if (endRanges[0].start <= startRanges[0].start) {
|
|
634
|
+
throw new Error(`Gemini instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
|
|
635
|
+
}
|
|
636
|
+
const managedEnd = endRanges[0].end;
|
|
637
|
+
if (currentText.slice(startRanges[0].start, managedEnd) === managedBlock) {
|
|
638
|
+
return { path: instructionPath, status: "unchanged" };
|
|
639
|
+
}
|
|
640
|
+
nextText = currentText.slice(0, startRanges[0].start) + managedBlock + currentText.slice(managedEnd);
|
|
641
|
+
action = "refresh";
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
const legacyBlock = legacyGeminiStartupBlock(newline);
|
|
645
|
+
if (currentText.startsWith(legacyBlock)) {
|
|
646
|
+
nextText = managedBlock + newline + currentText.slice(legacyBlock.length);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
const separator = currentText.length === 0
|
|
650
|
+
? ""
|
|
651
|
+
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
652
|
+
? newline
|
|
653
|
+
: `${newline}${newline}`;
|
|
654
|
+
nextText = currentText + separator + managedBlock;
|
|
655
|
+
}
|
|
656
|
+
action = "install";
|
|
657
|
+
}
|
|
658
|
+
if (dryRun) {
|
|
659
|
+
return { path: instructionPath, status: action === "install" ? "would-install" : "would-refresh" };
|
|
660
|
+
}
|
|
661
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
662
|
+
return { path: instructionPath, status: action === "install" ? "installed" : "refreshed" };
|
|
663
|
+
}
|
|
664
|
+
function serializeCodexStartupBlock(newline) {
|
|
665
|
+
return [
|
|
666
|
+
CODEX_STARTUP_MANAGED_START,
|
|
667
|
+
...CODEX_STARTUP_BODY,
|
|
668
|
+
CODEX_STARTUP_MANAGED_END,
|
|
669
|
+
"",
|
|
670
|
+
].join(newline);
|
|
671
|
+
}
|
|
672
|
+
/** Install or refresh Codex's official global, hook-free first-turn instruction. */
|
|
673
|
+
export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {}) {
|
|
674
|
+
const userHome = homeDir ?? homedir();
|
|
675
|
+
const configuredCodexHome = env.CODEX_HOME?.trim();
|
|
676
|
+
const codexHome = configuredCodexHome ? resolve(configuredCodexHome) : join(userHome, ".codex");
|
|
677
|
+
const instructionPath = join(codexHome, "AGENTS.md");
|
|
678
|
+
let writePath = instructionPath;
|
|
679
|
+
let symlinkPath;
|
|
680
|
+
let originalText;
|
|
681
|
+
let instructionEntryExists = false;
|
|
682
|
+
try {
|
|
683
|
+
const pathInfo = lstatSync(instructionPath);
|
|
684
|
+
instructionEntryExists = true;
|
|
685
|
+
if (pathInfo.isSymbolicLink()) {
|
|
686
|
+
writePath = realpathSync(instructionPath);
|
|
687
|
+
symlinkPath = instructionPath;
|
|
688
|
+
if (!statSync(writePath).isFile())
|
|
689
|
+
throw new Error("target is not a regular file");
|
|
690
|
+
}
|
|
691
|
+
originalText = readFileSync(writePath, "utf8");
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (instructionEntryExists || !isErrno(error, "ENOENT")) {
|
|
695
|
+
throw new Error(`Could not inspect Codex instructions: ${error instanceof Error ? error.message : String(error)}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const currentText = originalText ?? "";
|
|
699
|
+
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
700
|
+
const startRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_START);
|
|
701
|
+
const endRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_END);
|
|
702
|
+
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
703
|
+
throw new Error(`Codex instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
704
|
+
}
|
|
705
|
+
const managedBlock = serializeCodexStartupBlock(newline);
|
|
706
|
+
let nextText;
|
|
707
|
+
let action;
|
|
708
|
+
if (startRanges.length === 1) {
|
|
709
|
+
if (endRanges[0].start <= startRanges[0].start) {
|
|
710
|
+
throw new Error(`Codex instructions contain out-of-order Prism startup ownership markers: ${instructionPath}`);
|
|
711
|
+
}
|
|
712
|
+
const managedEnd = endRanges[0].end;
|
|
713
|
+
if (currentText.slice(startRanges[0].start, managedEnd) === managedBlock) {
|
|
714
|
+
return { path: instructionPath, status: "unchanged" };
|
|
715
|
+
}
|
|
716
|
+
nextText = currentText.slice(0, startRanges[0].start) + managedBlock + currentText.slice(managedEnd);
|
|
717
|
+
action = "refresh";
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
const separator = currentText.length === 0
|
|
721
|
+
? ""
|
|
722
|
+
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
723
|
+
? newline
|
|
724
|
+
: `${newline}${newline}`;
|
|
725
|
+
nextText = currentText + separator + managedBlock;
|
|
726
|
+
action = "install";
|
|
727
|
+
}
|
|
728
|
+
if (dryRun) {
|
|
729
|
+
return { path: instructionPath, status: action === "install" ? "would-install" : "would-refresh" };
|
|
730
|
+
}
|
|
731
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
732
|
+
return { path: instructionPath, status: action === "install" ? "installed" : "refreshed" };
|
|
733
|
+
}
|
|
734
|
+
function configureJsonAgentPolicy(configPath, label, mutate, dryRun, beforeCommit) {
|
|
735
|
+
let writePath = configPath;
|
|
736
|
+
let symlinkPath;
|
|
737
|
+
let originalText;
|
|
738
|
+
let config = {};
|
|
739
|
+
try {
|
|
740
|
+
const pathInfo = lstatSync(configPath);
|
|
741
|
+
if (pathInfo.isSymbolicLink()) {
|
|
742
|
+
writePath = realpathSync(configPath);
|
|
743
|
+
symlinkPath = configPath;
|
|
744
|
+
if (!statSync(writePath).isFile())
|
|
745
|
+
throw new Error("target is not a regular file");
|
|
746
|
+
}
|
|
747
|
+
originalText = readFileSync(writePath, "utf8");
|
|
748
|
+
const parsed = JSON.parse(originalText);
|
|
749
|
+
if (!isJsonObject(parsed))
|
|
750
|
+
throw new Error("top-level JSON value must be an object");
|
|
751
|
+
config = parsed;
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
if (!isErrno(error, "ENOENT")) {
|
|
755
|
+
throw new Error(`Could not inspect ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const changed = mutate(config);
|
|
759
|
+
if (!changed)
|
|
760
|
+
return { path: configPath, status: "unchanged" };
|
|
761
|
+
const action = originalText === undefined ? "install" : "refresh";
|
|
762
|
+
if (dryRun) {
|
|
763
|
+
return { path: configPath, status: action === "install" ? "would-install" : "would-refresh" };
|
|
764
|
+
}
|
|
765
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
766
|
+
return { path: configPath, status: action === "install" ? "installed" : "refreshed" };
|
|
767
|
+
}
|
|
768
|
+
/** Configure Claude Code's economy fallback model; policy text prevents routine fan-out. */
|
|
769
|
+
export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
770
|
+
const configPath = join(homeDir, ".claude", "settings.json");
|
|
771
|
+
return configureJsonAgentPolicy(configPath, "Claude agent settings", (config) => {
|
|
772
|
+
const currentEnv = config.env;
|
|
773
|
+
if (currentEnv !== undefined && !isJsonObject(currentEnv)) {
|
|
774
|
+
throw new Error('"env" must be a JSON object');
|
|
775
|
+
}
|
|
776
|
+
const env = (currentEnv ?? {});
|
|
777
|
+
if (env.CLAUDE_CODE_SUBAGENT_MODEL === CLAUDE_FALLBACK_SUBAGENT_MODEL)
|
|
778
|
+
return false;
|
|
779
|
+
env.CLAUDE_CODE_SUBAGENT_MODEL = CLAUDE_FALLBACK_SUBAGENT_MODEL;
|
|
780
|
+
config.env = env;
|
|
781
|
+
return true;
|
|
782
|
+
}, dryRun, beforeCommit);
|
|
783
|
+
}
|
|
784
|
+
/** Disable Gemini's native/remote subagents; Prism local workers remain available over MCP. */
|
|
785
|
+
export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
786
|
+
const configPath = join(homeDir, ".gemini", "settings.json");
|
|
787
|
+
return configureJsonAgentPolicy(configPath, "Gemini agent settings", (config) => {
|
|
788
|
+
const currentExperimental = config.experimental;
|
|
789
|
+
if (currentExperimental !== undefined && !isJsonObject(currentExperimental)) {
|
|
790
|
+
throw new Error('"experimental" must be a JSON object');
|
|
791
|
+
}
|
|
792
|
+
const experimental = (currentExperimental ?? {});
|
|
793
|
+
if (experimental.enableAgents === false)
|
|
794
|
+
return false;
|
|
795
|
+
experimental.enableAgents = false;
|
|
796
|
+
config.experimental = experimental;
|
|
797
|
+
return true;
|
|
798
|
+
}, dryRun, beforeCommit);
|
|
799
|
+
}
|
|
800
|
+
function tomlValue(value) {
|
|
801
|
+
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
802
|
+
}
|
|
803
|
+
function escapeRegExp(value) {
|
|
804
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
805
|
+
}
|
|
806
|
+
function applyTomlTablePolicy(source, table, desired) {
|
|
807
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
808
|
+
const hadFinalNewline = source.endsWith("\n");
|
|
809
|
+
const lines = source.length === 0 ? [] : source.split(/\r?\n/);
|
|
810
|
+
if (hadFinalNewline)
|
|
811
|
+
lines.pop();
|
|
812
|
+
const tablePattern = new RegExp(`^\\s*\\[${escapeRegExp(table)}\\]\\s*(?:#.*)?$`);
|
|
813
|
+
const tableIndexes = lines
|
|
814
|
+
.map((line, index) => tablePattern.test(line) ? index : -1)
|
|
815
|
+
.filter((index) => index >= 0);
|
|
816
|
+
if (tableIndexes.length > 1)
|
|
817
|
+
throw new Error(`duplicate [${table}] tables`);
|
|
818
|
+
let tableStart = tableIndexes[0];
|
|
819
|
+
if (tableStart === undefined) {
|
|
820
|
+
if (lines.length > 0 && lines[lines.length - 1] !== "")
|
|
821
|
+
lines.push("");
|
|
822
|
+
lines.push(`[${table}]`);
|
|
823
|
+
tableStart = lines.length - 1;
|
|
824
|
+
}
|
|
825
|
+
let tableEnd = lines.findIndex((line, index) => index > tableStart && /^\s*\[{1,2}[^\]]+\]{1,2}\s*(?:#.*)?$/.test(line));
|
|
826
|
+
if (tableEnd < 0)
|
|
827
|
+
tableEnd = lines.length;
|
|
828
|
+
const missing = [];
|
|
829
|
+
for (const [key, value] of Object.entries(desired)) {
|
|
830
|
+
const keyPattern = new RegExp(`^(\\s*${escapeRegExp(key)}\\s*=\\s*)(.*?)(\\s+#.*)?$`);
|
|
831
|
+
const matches = [];
|
|
832
|
+
for (let index = tableStart + 1; index < tableEnd; index++) {
|
|
833
|
+
if (keyPattern.test(lines[index]))
|
|
834
|
+
matches.push(index);
|
|
835
|
+
}
|
|
836
|
+
if (matches.length > 1)
|
|
837
|
+
throw new Error(`duplicate ${table}.${key} assignments`);
|
|
838
|
+
if (matches.length === 0) {
|
|
839
|
+
missing.push([key, value]);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
lines[matches[0]] = lines[matches[0]].replace(keyPattern, (_match, prefix, _oldValue, comment) => `${prefix}${tomlValue(value)}${comment ?? ""}`);
|
|
843
|
+
}
|
|
844
|
+
if (missing.length > 0) {
|
|
845
|
+
const marker = `${CODEX_POLICY_MARKER_PREFIX} ${table}`;
|
|
846
|
+
const markerEnd = `${CODEX_POLICY_MARKER_SUFFIX} ${table}`;
|
|
847
|
+
lines.splice(tableEnd, 0, marker, ...missing.map(([key, value]) => `${key} = ${tomlValue(value)}`), markerEnd);
|
|
848
|
+
}
|
|
849
|
+
return `${lines.join(newline)}${hadFinalNewline || lines.length > 0 ? newline : ""}`;
|
|
850
|
+
}
|
|
851
|
+
/** Hard-disable Codex fan-out while retaining a bounded economy fallback profile. */
|
|
852
|
+
export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {}) {
|
|
853
|
+
const userHome = homeDir ?? homedir();
|
|
854
|
+
const configuredCodexHome = env.CODEX_HOME?.trim();
|
|
855
|
+
const codexHome = configuredCodexHome ? resolve(configuredCodexHome) : join(userHome, ".codex");
|
|
856
|
+
const configPath = join(codexHome, "config.toml");
|
|
857
|
+
let writePath = configPath;
|
|
858
|
+
let symlinkPath;
|
|
859
|
+
let originalText;
|
|
860
|
+
try {
|
|
861
|
+
const pathInfo = lstatSync(configPath);
|
|
862
|
+
if (pathInfo.isSymbolicLink()) {
|
|
863
|
+
writePath = realpathSync(configPath);
|
|
864
|
+
symlinkPath = configPath;
|
|
865
|
+
if (!statSync(writePath).isFile())
|
|
866
|
+
throw new Error("target is not a regular file");
|
|
867
|
+
}
|
|
868
|
+
originalText = readFileSync(writePath, "utf8");
|
|
869
|
+
parseToml(originalText);
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
if (!isErrno(error, "ENOENT")) {
|
|
873
|
+
throw new Error(`Could not inspect Codex agent settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
let nextText = originalText ?? "";
|
|
877
|
+
nextText = applyTomlTablePolicy(nextText, "features", CODEX_LOCAL_FIRST_POLICY.features);
|
|
878
|
+
nextText = applyTomlTablePolicy(nextText, "agents", CODEX_LOCAL_FIRST_POLICY.agents);
|
|
879
|
+
let parsed;
|
|
880
|
+
try {
|
|
881
|
+
parsed = parseToml(nextText);
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
throw new Error(`Could not build valid Codex local-first policy: ${error instanceof Error ? error.message : String(error)}`);
|
|
885
|
+
}
|
|
886
|
+
const parsedFeatures = isJsonObject(parsed) && isJsonObject(parsed.features)
|
|
887
|
+
? parsed.features
|
|
888
|
+
: undefined;
|
|
889
|
+
const parsedAgents = isJsonObject(parsed) && isJsonObject(parsed.agents)
|
|
890
|
+
? parsed.agents
|
|
891
|
+
: undefined;
|
|
892
|
+
if (!parsedFeatures
|
|
893
|
+
|| !parsedAgents
|
|
894
|
+
|| !isDeepStrictEqual(parsedFeatures.multi_agent, false)
|
|
895
|
+
|| !Object.entries(CODEX_LOCAL_FIRST_POLICY.agents)
|
|
896
|
+
.every(([key, value]) => isDeepStrictEqual(parsedAgents[key], value))) {
|
|
897
|
+
throw new Error("Generated Codex local-first policy failed validation");
|
|
898
|
+
}
|
|
899
|
+
if (nextText === originalText)
|
|
900
|
+
return { path: configPath, status: "unchanged" };
|
|
901
|
+
const action = originalText === undefined ? "install" : "refresh";
|
|
902
|
+
if (dryRun) {
|
|
903
|
+
return { path: configPath, status: action === "install" ? "would-install" : "would-refresh" };
|
|
904
|
+
}
|
|
905
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
906
|
+
return { path: configPath, status: action === "install" ? "installed" : "refreshed" };
|
|
907
|
+
}
|
|
88
908
|
function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
|
|
89
909
|
const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
|
|
90
910
|
const cursorDetectionPaths = [join(homeDir, ".cursor")];
|
|
@@ -208,10 +1028,15 @@ function executableExists(name, platform, pathEnv) {
|
|
|
208
1028
|
return false;
|
|
209
1029
|
}
|
|
210
1030
|
function buildMcpEntry(nodePath, serverPath, env) {
|
|
1031
|
+
const storage = env.PRISM_STORAGE ?? "auto";
|
|
1032
|
+
if (!CONNECT_STORAGE_BACKENDS.includes(storage)) {
|
|
1033
|
+
throw new Error(`Invalid PRISM_STORAGE "${storage}". Choose one of: ${CONNECT_STORAGE_BACKENDS.join(", ")}`);
|
|
1034
|
+
}
|
|
211
1035
|
const serverEnv = {
|
|
212
1036
|
PRISM_INSTANCE: "prism-mcp",
|
|
1037
|
+
PRISM_AGENT_POLICY: LOCAL_FIRST_POLICY_ID,
|
|
213
1038
|
PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
|
|
214
|
-
PRISM_STORAGE:
|
|
1039
|
+
PRISM_STORAGE: storage,
|
|
215
1040
|
};
|
|
216
1041
|
if (env.PRISM_SYNALUX_API_KEY) {
|
|
217
1042
|
serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
|
|
@@ -277,34 +1102,37 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
|
277
1102
|
: undefined;
|
|
278
1103
|
if (existingKey) {
|
|
279
1104
|
const existingEntry = mcpServers[existingKey];
|
|
1105
|
+
const startupCompatible = existingKey === "prism-mcp"
|
|
1106
|
+
&& isManagedPrismEntry(existingEntry)
|
|
1107
|
+
&& isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
|
|
280
1108
|
if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
|
|
281
|
-
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
1109
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
|
|
282
1110
|
}
|
|
283
1111
|
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
284
1112
|
if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
|
|
285
|
-
return result(definition, "existing", "Prism-managed entry is already current");
|
|
1113
|
+
return result(definition, "existing", "Prism-managed entry is already current", true);
|
|
286
1114
|
}
|
|
287
1115
|
if (dryRun) {
|
|
288
|
-
return result(definition, "would-refresh");
|
|
1116
|
+
return result(definition, "would-refresh", undefined, true);
|
|
289
1117
|
}
|
|
290
1118
|
mcpServers[existingKey] = refreshedEntry;
|
|
291
1119
|
config.mcpServers = mcpServers;
|
|
292
1120
|
try {
|
|
293
1121
|
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
294
|
-
return result(definition, "refreshed");
|
|
1122
|
+
return result(definition, "refreshed", undefined, true);
|
|
295
1123
|
}
|
|
296
1124
|
catch (error) {
|
|
297
1125
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
298
1126
|
}
|
|
299
1127
|
}
|
|
300
1128
|
if (dryRun) {
|
|
301
|
-
return result(definition, "would-register");
|
|
1129
|
+
return result(definition, "would-register", undefined, true);
|
|
302
1130
|
}
|
|
303
1131
|
mcpServers["prism-mcp"] = entry;
|
|
304
1132
|
config.mcpServers = mcpServers;
|
|
305
1133
|
try {
|
|
306
1134
|
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
307
|
-
return result(definition, "registered");
|
|
1135
|
+
return result(definition, "registered", undefined, true);
|
|
308
1136
|
}
|
|
309
1137
|
catch (error) {
|
|
310
1138
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
@@ -367,18 +1195,22 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
367
1195
|
if (hasCanonical || hasLegacy) {
|
|
368
1196
|
const existingKey = hasCanonical ? "prism-mcp" : "prism";
|
|
369
1197
|
const existingEntry = mcpServers[existingKey];
|
|
1198
|
+
const startupCompatible = existingKey === "prism-mcp"
|
|
1199
|
+
&& managedBlock !== undefined
|
|
1200
|
+
&& isManagedPrismEntry(existingEntry)
|
|
1201
|
+
&& isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
|
|
370
1202
|
if (!refresh || existingKey !== "prism-mcp") {
|
|
371
|
-
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
1203
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
|
|
372
1204
|
}
|
|
373
1205
|
if (!managedBlock) {
|
|
374
|
-
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
1206
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched", false);
|
|
375
1207
|
}
|
|
376
1208
|
if (!isManagedPrismEntry(existingEntry)) {
|
|
377
1209
|
return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
|
|
378
1210
|
}
|
|
379
1211
|
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
380
1212
|
if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
|
|
381
|
-
return result(definition, "existing", "Prism-managed entry is already current");
|
|
1213
|
+
return result(definition, "existing", "Prism-managed entry is already current", true);
|
|
382
1214
|
}
|
|
383
1215
|
let nextText;
|
|
384
1216
|
try {
|
|
@@ -389,11 +1221,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
389
1221
|
return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
390
1222
|
}
|
|
391
1223
|
if (dryRun) {
|
|
392
|
-
return result(definition, "would-refresh");
|
|
1224
|
+
return result(definition, "would-refresh", undefined, true);
|
|
393
1225
|
}
|
|
394
1226
|
try {
|
|
395
1227
|
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
396
|
-
return result(definition, "refreshed");
|
|
1228
|
+
return result(definition, "refreshed", undefined, true);
|
|
397
1229
|
}
|
|
398
1230
|
catch (error) {
|
|
399
1231
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
@@ -418,11 +1250,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
418
1250
|
return result(definition, "error", `could not safely extend Codex config without rewriting existing TOML: ${error instanceof Error ? error.message : String(error)}. Convert an inline mcp_servers value to standard TOML tables, then retry`);
|
|
419
1251
|
}
|
|
420
1252
|
if (dryRun) {
|
|
421
|
-
return result(definition, "would-register");
|
|
1253
|
+
return result(definition, "would-register", undefined, true);
|
|
422
1254
|
}
|
|
423
1255
|
try {
|
|
424
1256
|
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
425
|
-
return result(definition, "registered");
|
|
1257
|
+
return result(definition, "registered", undefined, true);
|
|
426
1258
|
}
|
|
427
1259
|
catch (error) {
|
|
428
1260
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
@@ -521,18 +1353,24 @@ function writeTextAtomically(filePath, contents, expectedText, beforeCommit, sym
|
|
|
521
1353
|
unlinkSync(tempPath);
|
|
522
1354
|
}
|
|
523
1355
|
}
|
|
524
|
-
function result(definition, status, message) {
|
|
1356
|
+
function result(definition, status, message, startupCompatible = false) {
|
|
525
1357
|
return {
|
|
526
1358
|
host: definition.name,
|
|
527
1359
|
label: definition.label,
|
|
528
1360
|
path: definition.configPath,
|
|
529
1361
|
status,
|
|
1362
|
+
startupCompatible,
|
|
530
1363
|
message,
|
|
531
1364
|
};
|
|
532
1365
|
}
|
|
533
1366
|
function isJsonObject(value) {
|
|
534
1367
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
535
1368
|
}
|
|
1369
|
+
function isPathWithin(candidate, parent) {
|
|
1370
|
+
const relativePath = relative(parent, candidate);
|
|
1371
|
+
return relativePath === ""
|
|
1372
|
+
|| (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
1373
|
+
}
|
|
536
1374
|
function isManagedPrismEntry(value) {
|
|
537
1375
|
return isJsonObject(value)
|
|
538
1376
|
&& isJsonObject(value.env)
|