prism-mcp-server 20.2.1 → 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 +119 -14
- package/dist/cli.js +221 -33
- package/dist/config.js +12 -6
- package/dist/connect.js +848 -15
- 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 +4 -3
- package/dist/storage/inferMetricsLedger.js +72 -14
- package/dist/storage/panelMetricsSpool.js +324 -0
- 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/inferenceMetrics.js +22 -3
- package/dist/utils/modelPicker.js +3 -3
- package/dist/utils/synaluxJwt.js +2 -1
- package/package.json +3 -1
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,7 +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
|
+
];
|
|
28
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";
|
|
29
72
|
export function normalizeHostName(value) {
|
|
30
73
|
const normalized = value.trim().toLowerCase();
|
|
31
74
|
const host = HOST_ALIASES[normalized];
|
|
@@ -52,6 +95,19 @@ export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
|
|
|
52
95
|
}
|
|
53
96
|
return realpathSync(serverPath);
|
|
54
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
|
+
}
|
|
55
111
|
export function connectHosts(options = {}) {
|
|
56
112
|
if (options.all && options.hosts?.length) {
|
|
57
113
|
throw new Error("Use either --all or --host, not both.");
|
|
@@ -80,12 +136,775 @@ export function connectHosts(options = {}) {
|
|
|
80
136
|
}
|
|
81
137
|
const entry = buildMcpEntry(nodePath, serverPath, env);
|
|
82
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
|
+
}
|
|
83
155
|
return {
|
|
84
156
|
results,
|
|
85
157
|
usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
|
|
86
158
|
serverPath,
|
|
159
|
+
installationReceiptPath,
|
|
87
160
|
};
|
|
88
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
|
+
}
|
|
89
908
|
function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
|
|
90
909
|
const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
|
|
91
910
|
const cursorDetectionPaths = [join(homeDir, ".cursor")];
|
|
@@ -215,6 +1034,7 @@ function buildMcpEntry(nodePath, serverPath, env) {
|
|
|
215
1034
|
}
|
|
216
1035
|
const serverEnv = {
|
|
217
1036
|
PRISM_INSTANCE: "prism-mcp",
|
|
1037
|
+
PRISM_AGENT_POLICY: LOCAL_FIRST_POLICY_ID,
|
|
218
1038
|
PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
|
|
219
1039
|
PRISM_STORAGE: storage,
|
|
220
1040
|
};
|
|
@@ -282,34 +1102,37 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
|
282
1102
|
: undefined;
|
|
283
1103
|
if (existingKey) {
|
|
284
1104
|
const existingEntry = mcpServers[existingKey];
|
|
1105
|
+
const startupCompatible = existingKey === "prism-mcp"
|
|
1106
|
+
&& isManagedPrismEntry(existingEntry)
|
|
1107
|
+
&& isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
|
|
285
1108
|
if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
|
|
286
|
-
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);
|
|
287
1110
|
}
|
|
288
1111
|
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
289
1112
|
if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
|
|
290
|
-
return result(definition, "existing", "Prism-managed entry is already current");
|
|
1113
|
+
return result(definition, "existing", "Prism-managed entry is already current", true);
|
|
291
1114
|
}
|
|
292
1115
|
if (dryRun) {
|
|
293
|
-
return result(definition, "would-refresh");
|
|
1116
|
+
return result(definition, "would-refresh", undefined, true);
|
|
294
1117
|
}
|
|
295
1118
|
mcpServers[existingKey] = refreshedEntry;
|
|
296
1119
|
config.mcpServers = mcpServers;
|
|
297
1120
|
try {
|
|
298
1121
|
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
299
|
-
return result(definition, "refreshed");
|
|
1122
|
+
return result(definition, "refreshed", undefined, true);
|
|
300
1123
|
}
|
|
301
1124
|
catch (error) {
|
|
302
1125
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
303
1126
|
}
|
|
304
1127
|
}
|
|
305
1128
|
if (dryRun) {
|
|
306
|
-
return result(definition, "would-register");
|
|
1129
|
+
return result(definition, "would-register", undefined, true);
|
|
307
1130
|
}
|
|
308
1131
|
mcpServers["prism-mcp"] = entry;
|
|
309
1132
|
config.mcpServers = mcpServers;
|
|
310
1133
|
try {
|
|
311
1134
|
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
312
|
-
return result(definition, "registered");
|
|
1135
|
+
return result(definition, "registered", undefined, true);
|
|
313
1136
|
}
|
|
314
1137
|
catch (error) {
|
|
315
1138
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
@@ -372,18 +1195,22 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
372
1195
|
if (hasCanonical || hasLegacy) {
|
|
373
1196
|
const existingKey = hasCanonical ? "prism-mcp" : "prism";
|
|
374
1197
|
const existingEntry = mcpServers[existingKey];
|
|
1198
|
+
const startupCompatible = existingKey === "prism-mcp"
|
|
1199
|
+
&& managedBlock !== undefined
|
|
1200
|
+
&& isManagedPrismEntry(existingEntry)
|
|
1201
|
+
&& isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
|
|
375
1202
|
if (!refresh || existingKey !== "prism-mcp") {
|
|
376
|
-
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);
|
|
377
1204
|
}
|
|
378
1205
|
if (!managedBlock) {
|
|
379
|
-
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);
|
|
380
1207
|
}
|
|
381
1208
|
if (!isManagedPrismEntry(existingEntry)) {
|
|
382
1209
|
return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
|
|
383
1210
|
}
|
|
384
1211
|
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
385
1212
|
if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
|
|
386
|
-
return result(definition, "existing", "Prism-managed entry is already current");
|
|
1213
|
+
return result(definition, "existing", "Prism-managed entry is already current", true);
|
|
387
1214
|
}
|
|
388
1215
|
let nextText;
|
|
389
1216
|
try {
|
|
@@ -394,11 +1221,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
394
1221
|
return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
395
1222
|
}
|
|
396
1223
|
if (dryRun) {
|
|
397
|
-
return result(definition, "would-refresh");
|
|
1224
|
+
return result(definition, "would-refresh", undefined, true);
|
|
398
1225
|
}
|
|
399
1226
|
try {
|
|
400
1227
|
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
401
|
-
return result(definition, "refreshed");
|
|
1228
|
+
return result(definition, "refreshed", undefined, true);
|
|
402
1229
|
}
|
|
403
1230
|
catch (error) {
|
|
404
1231
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
@@ -423,11 +1250,11 @@ function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit)
|
|
|
423
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`);
|
|
424
1251
|
}
|
|
425
1252
|
if (dryRun) {
|
|
426
|
-
return result(definition, "would-register");
|
|
1253
|
+
return result(definition, "would-register", undefined, true);
|
|
427
1254
|
}
|
|
428
1255
|
try {
|
|
429
1256
|
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
430
|
-
return result(definition, "registered");
|
|
1257
|
+
return result(definition, "registered", undefined, true);
|
|
431
1258
|
}
|
|
432
1259
|
catch (error) {
|
|
433
1260
|
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
@@ -526,18 +1353,24 @@ function writeTextAtomically(filePath, contents, expectedText, beforeCommit, sym
|
|
|
526
1353
|
unlinkSync(tempPath);
|
|
527
1354
|
}
|
|
528
1355
|
}
|
|
529
|
-
function result(definition, status, message) {
|
|
1356
|
+
function result(definition, status, message, startupCompatible = false) {
|
|
530
1357
|
return {
|
|
531
1358
|
host: definition.name,
|
|
532
1359
|
label: definition.label,
|
|
533
1360
|
path: definition.configPath,
|
|
534
1361
|
status,
|
|
1362
|
+
startupCompatible,
|
|
535
1363
|
message,
|
|
536
1364
|
};
|
|
537
1365
|
}
|
|
538
1366
|
function isJsonObject(value) {
|
|
539
1367
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
540
1368
|
}
|
|
1369
|
+
function isPathWithin(candidate, parent) {
|
|
1370
|
+
const relativePath = relative(parent, candidate);
|
|
1371
|
+
return relativePath === ""
|
|
1372
|
+
|| (!isAbsolute(relativePath) && relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
|
|
1373
|
+
}
|
|
541
1374
|
function isManagedPrismEntry(value) {
|
|
542
1375
|
return isJsonObject(value)
|
|
543
1376
|
&& isJsonObject(value.env)
|