prism-mcp-server 20.0.8 → 20.2.0
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 +49 -12
- package/dist/boundaries/boundaries.js +2 -2
- package/dist/cli.js +62 -0
- package/dist/connect.js +560 -0
- package/dist/onboarding/wizard.js +6 -29
- package/dist/tools/__tests__/layer1Integration.test.js +97 -5
- package/dist/tools/prismInferHandler.js +128 -12
- package/dist/utils/entitlements.js +38 -10
- package/dist/utils/inferenceMetrics.js +19 -1
- package/dist/utils/layer1.js +57 -10
- package/dist/utils/modelPicker.js +12 -3
- package/package.json +3 -1
- package/dist/utils/groundingVerifier.js +0 -203
package/dist/connect.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
7
|
+
export const CONNECT_HOSTS = [
|
|
8
|
+
"claude-code",
|
|
9
|
+
"claude-desktop",
|
|
10
|
+
"cursor",
|
|
11
|
+
"gemini",
|
|
12
|
+
"codex",
|
|
13
|
+
];
|
|
14
|
+
const HOST_ALIASES = {
|
|
15
|
+
"claude-code": "claude-code",
|
|
16
|
+
claude: "claude-code",
|
|
17
|
+
code: "claude-code",
|
|
18
|
+
"claude-desktop": "claude-desktop",
|
|
19
|
+
desktop: "claude-desktop",
|
|
20
|
+
cursor: "cursor",
|
|
21
|
+
gemini: "gemini",
|
|
22
|
+
"gemini-cli": "gemini",
|
|
23
|
+
codex: "codex",
|
|
24
|
+
"codex-cli": "codex",
|
|
25
|
+
};
|
|
26
|
+
const CODEX_MANAGED_START = "# >>> prism connect managed: prism-mcp";
|
|
27
|
+
const CODEX_MANAGED_END = "# <<< prism connect managed: prism-mcp";
|
|
28
|
+
export function normalizeHostName(value) {
|
|
29
|
+
const normalized = value.trim().toLowerCase();
|
|
30
|
+
const host = HOST_ALIASES[normalized];
|
|
31
|
+
if (!host) {
|
|
32
|
+
throw new Error(`Unsupported host "${value}". Choose one of: ${CONNECT_HOSTS.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return host;
|
|
35
|
+
}
|
|
36
|
+
export function resolveInstalledServerPath(moduleUrl = import.meta.url) {
|
|
37
|
+
const manifestPath = fileURLToPath(new URL("../package.json", moduleUrl));
|
|
38
|
+
let manifest;
|
|
39
|
+
try {
|
|
40
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new Error(`Cannot read Prism package manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
if (!isJsonObject(manifest) || typeof manifest.main !== "string" || !manifest.main.trim()) {
|
|
46
|
+
throw new Error(`Prism package manifest has no valid "main" entry: ${manifestPath}`);
|
|
47
|
+
}
|
|
48
|
+
const serverPath = resolve(dirname(manifestPath), manifest.main);
|
|
49
|
+
if (!existsSync(serverPath)) {
|
|
50
|
+
throw new Error(`Prism server entrypoint was not found: ${serverPath}. Run npm run build first.`);
|
|
51
|
+
}
|
|
52
|
+
return realpathSync(serverPath);
|
|
53
|
+
}
|
|
54
|
+
export function connectHosts(options = {}) {
|
|
55
|
+
if (options.all && options.hosts?.length) {
|
|
56
|
+
throw new Error("Use either --all or --host, not both.");
|
|
57
|
+
}
|
|
58
|
+
const homeDir = options.homeDir ?? homedir();
|
|
59
|
+
const platform = options.platform ?? process.platform;
|
|
60
|
+
const env = options.env ?? process.env;
|
|
61
|
+
const serverPath = options.serverPath ?? resolveInstalledServerPath();
|
|
62
|
+
const nodePath = options.nodePath ?? process.execPath;
|
|
63
|
+
const definitions = getHostDefinitions(homeDir, platform, env, options.homeDir === undefined);
|
|
64
|
+
const pathEnv = options.pathEnv ?? env.PATH ?? "";
|
|
65
|
+
let selected;
|
|
66
|
+
if (options.hosts?.length) {
|
|
67
|
+
const requested = new Set(options.hosts);
|
|
68
|
+
selected = definitions.filter((definition) => requested.has(definition.name));
|
|
69
|
+
const unavailable = options.hosts.filter((host) => !selected.some((definition) => definition.name === host));
|
|
70
|
+
if (unavailable.length > 0) {
|
|
71
|
+
throw new Error(`${unavailable.join(", ")} is not supported on ${platform}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (options.all) {
|
|
75
|
+
selected = definitions;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
selected = definitions.filter((definition) => isHostDetected(definition, platform, pathEnv));
|
|
79
|
+
}
|
|
80
|
+
const entry = buildMcpEntry(nodePath, serverPath, env);
|
|
81
|
+
const results = selected.map((definition) => registerHost(definition, entry, !!options.dryRun, !!options.refresh, options.beforeCommit));
|
|
82
|
+
return {
|
|
83
|
+
results,
|
|
84
|
+
usedApiKey: typeof env.PRISM_SYNALUX_API_KEY === "string" && env.PRISM_SYNALUX_API_KEY.length > 0,
|
|
85
|
+
serverPath,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
|
|
89
|
+
const claudeDesktop = claudeDesktopPath(homeDir, platform, env);
|
|
90
|
+
const cursorDetectionPaths = [join(homeDir, ".cursor")];
|
|
91
|
+
const desktopDetectionPaths = claudeDesktop ? [dirname(claudeDesktop)] : [];
|
|
92
|
+
const configuredCodexHome = includeSystemPaths && env.CODEX_HOME?.trim()
|
|
93
|
+
? resolve(env.CODEX_HOME.trim())
|
|
94
|
+
: undefined;
|
|
95
|
+
const codexHome = configuredCodexHome ?? join(homeDir, ".codex");
|
|
96
|
+
const codexDetectionPaths = [codexHome];
|
|
97
|
+
let codexConfigurationError;
|
|
98
|
+
if (configuredCodexHome) {
|
|
99
|
+
try {
|
|
100
|
+
if (!statSync(configuredCodexHome).isDirectory()) {
|
|
101
|
+
codexConfigurationError = `CODEX_HOME must be an existing directory: ${configuredCodexHome}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
codexConfigurationError = `CODEX_HOME must be an existing directory: ${configuredCodexHome} (${error instanceof Error ? error.message : String(error)})`;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (platform === "darwin") {
|
|
109
|
+
desktopDetectionPaths.push(join(homeDir, "Applications", "Claude.app"));
|
|
110
|
+
cursorDetectionPaths.push(join(homeDir, "Applications", "Cursor.app"));
|
|
111
|
+
codexDetectionPaths.push(join(homeDir, "Applications", "ChatGPT.app"));
|
|
112
|
+
if (includeSystemPaths) {
|
|
113
|
+
desktopDetectionPaths.push("/Applications/Claude.app");
|
|
114
|
+
cursorDetectionPaths.push("/Applications/Cursor.app");
|
|
115
|
+
codexDetectionPaths.push("/Applications/ChatGPT.app");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (platform === "win32") {
|
|
119
|
+
const localAppData = env.LOCALAPPDATA || join(homeDir, "AppData", "Local");
|
|
120
|
+
desktopDetectionPaths.push(join(localAppData, "Programs", "Claude", "Claude.exe"), join(localAppData, "AnthropicClaude", "Claude.exe"));
|
|
121
|
+
cursorDetectionPaths.push(join(localAppData, "Programs", "cursor", "Cursor.exe"), join(localAppData, "Cursor", "Cursor.exe"));
|
|
122
|
+
codexDetectionPaths.push(join(localAppData, "Programs", "OpenAI", "Codex", "bin", "codex.exe"), join(localAppData, "Microsoft", "WindowsApps", "ChatGPT.exe"));
|
|
123
|
+
}
|
|
124
|
+
const definitions = [
|
|
125
|
+
{
|
|
126
|
+
name: "claude-code",
|
|
127
|
+
label: "Claude Code",
|
|
128
|
+
format: "json",
|
|
129
|
+
configPath: join(homeDir, ".claude.json"),
|
|
130
|
+
detectionPaths: [join(homeDir, ".claude.json"), join(homeDir, ".claude")],
|
|
131
|
+
executables: ["claude"],
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
if (claudeDesktop) {
|
|
135
|
+
definitions.push({
|
|
136
|
+
name: "claude-desktop",
|
|
137
|
+
label: "Claude Desktop",
|
|
138
|
+
format: "json",
|
|
139
|
+
configPath: claudeDesktop,
|
|
140
|
+
detectionPaths: desktopDetectionPaths,
|
|
141
|
+
executables: ["claude-desktop"],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
definitions.push({
|
|
145
|
+
name: "cursor",
|
|
146
|
+
label: "Cursor",
|
|
147
|
+
format: "json",
|
|
148
|
+
configPath: join(homeDir, ".cursor", "mcp.json"),
|
|
149
|
+
detectionPaths: cursorDetectionPaths,
|
|
150
|
+
executables: ["cursor"],
|
|
151
|
+
}, {
|
|
152
|
+
name: "gemini",
|
|
153
|
+
label: "Gemini CLI",
|
|
154
|
+
format: "json",
|
|
155
|
+
configPath: join(homeDir, ".gemini", "settings.json"),
|
|
156
|
+
detectionPaths: [join(homeDir, ".gemini")],
|
|
157
|
+
executables: ["gemini"],
|
|
158
|
+
}, {
|
|
159
|
+
name: "codex",
|
|
160
|
+
label: "Codex",
|
|
161
|
+
format: "codex-toml",
|
|
162
|
+
configPath: join(codexHome, "config.toml"),
|
|
163
|
+
detectionPaths: codexDetectionPaths,
|
|
164
|
+
executables: ["codex"],
|
|
165
|
+
configurationError: codexConfigurationError,
|
|
166
|
+
});
|
|
167
|
+
return definitions;
|
|
168
|
+
}
|
|
169
|
+
function claudeDesktopPath(homeDir, platform, env) {
|
|
170
|
+
if (platform === "darwin") {
|
|
171
|
+
return join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
172
|
+
}
|
|
173
|
+
if (platform === "win32") {
|
|
174
|
+
const appData = env.APPDATA || join(homeDir, "AppData", "Roaming");
|
|
175
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
176
|
+
}
|
|
177
|
+
if (platform === "linux") {
|
|
178
|
+
const configHome = env.XDG_CONFIG_HOME || join(homeDir, ".config");
|
|
179
|
+
return join(configHome, "Claude", "claude_desktop_config.json");
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
function isHostDetected(definition, platform, pathEnv) {
|
|
184
|
+
return definition.configurationError !== undefined
|
|
185
|
+
|| definition.detectionPaths.some(existsSync)
|
|
186
|
+
|| definition.executables.some((executable) => executableExists(executable, platform, pathEnv));
|
|
187
|
+
}
|
|
188
|
+
function executableExists(name, platform, pathEnv) {
|
|
189
|
+
if (!pathEnv)
|
|
190
|
+
return false;
|
|
191
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
192
|
+
const extensions = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
193
|
+
for (const directory of pathEnv.split(separator).filter(Boolean)) {
|
|
194
|
+
for (const extension of extensions) {
|
|
195
|
+
const candidate = join(directory, `${name}${extension}`);
|
|
196
|
+
try {
|
|
197
|
+
if (!statSync(candidate).isFile())
|
|
198
|
+
continue;
|
|
199
|
+
if (platform !== "win32")
|
|
200
|
+
accessSync(candidate, constants.X_OK);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Keep searching PATH entries when a candidate is absent or not executable.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
function buildMcpEntry(nodePath, serverPath, env) {
|
|
211
|
+
const serverEnv = {
|
|
212
|
+
PRISM_INSTANCE: "prism-mcp",
|
|
213
|
+
PRISM_SYNALUX_BASE_URL: env.PRISM_SYNALUX_BASE_URL || "https://synalux.ai",
|
|
214
|
+
PRISM_STORAGE: "auto",
|
|
215
|
+
};
|
|
216
|
+
if (env.PRISM_SYNALUX_API_KEY) {
|
|
217
|
+
serverEnv.PRISM_SYNALUX_API_KEY = env.PRISM_SYNALUX_API_KEY;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
command: nodePath,
|
|
221
|
+
args: [serverPath],
|
|
222
|
+
env: serverEnv,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function registerHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
226
|
+
if (definition.configurationError) {
|
|
227
|
+
return result(definition, "error", definition.configurationError);
|
|
228
|
+
}
|
|
229
|
+
if (definition.format === "codex-toml") {
|
|
230
|
+
return registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit);
|
|
231
|
+
}
|
|
232
|
+
return registerJsonHost(definition, entry, dryRun, refresh, beforeCommit);
|
|
233
|
+
}
|
|
234
|
+
function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
235
|
+
const { configPath } = definition;
|
|
236
|
+
let config = {};
|
|
237
|
+
let originalText;
|
|
238
|
+
let writePath = configPath;
|
|
239
|
+
let symlinkPath;
|
|
240
|
+
try {
|
|
241
|
+
const pathInfo = lstatSync(configPath);
|
|
242
|
+
if (pathInfo.isSymbolicLink()) {
|
|
243
|
+
try {
|
|
244
|
+
writePath = realpathSync(configPath);
|
|
245
|
+
symlinkPath = configPath;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
originalText = readFileSync(writePath, "utf8");
|
|
253
|
+
const parsed = JSON.parse(originalText);
|
|
254
|
+
if (!isJsonObject(parsed)) {
|
|
255
|
+
throw new Error("top-level JSON value must be an object");
|
|
256
|
+
}
|
|
257
|
+
config = parsed;
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (!isErrno(error, "ENOENT")) {
|
|
265
|
+
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const currentServers = config.mcpServers;
|
|
269
|
+
if (currentServers !== undefined && !isJsonObject(currentServers)) {
|
|
270
|
+
return result(definition, "error", '"mcpServers" must be a JSON object');
|
|
271
|
+
}
|
|
272
|
+
const mcpServers = (currentServers ?? {});
|
|
273
|
+
const existingKey = Object.prototype.hasOwnProperty.call(mcpServers, "prism-mcp")
|
|
274
|
+
? "prism-mcp"
|
|
275
|
+
: Object.prototype.hasOwnProperty.call(mcpServers, "prism")
|
|
276
|
+
? "prism"
|
|
277
|
+
: undefined;
|
|
278
|
+
if (existingKey) {
|
|
279
|
+
const existingEntry = mcpServers[existingKey];
|
|
280
|
+
if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
|
|
281
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
282
|
+
}
|
|
283
|
+
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
284
|
+
if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
|
|
285
|
+
return result(definition, "existing", "Prism-managed entry is already current");
|
|
286
|
+
}
|
|
287
|
+
if (dryRun) {
|
|
288
|
+
return result(definition, "would-refresh");
|
|
289
|
+
}
|
|
290
|
+
mcpServers[existingKey] = refreshedEntry;
|
|
291
|
+
config.mcpServers = mcpServers;
|
|
292
|
+
try {
|
|
293
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
294
|
+
return result(definition, "refreshed");
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (dryRun) {
|
|
301
|
+
return result(definition, "would-register");
|
|
302
|
+
}
|
|
303
|
+
mcpServers["prism-mcp"] = entry;
|
|
304
|
+
config.mcpServers = mcpServers;
|
|
305
|
+
try {
|
|
306
|
+
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
307
|
+
return result(definition, "registered");
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
314
|
+
const { configPath } = definition;
|
|
315
|
+
let config = {};
|
|
316
|
+
let originalText;
|
|
317
|
+
let writePath = configPath;
|
|
318
|
+
let symlinkPath;
|
|
319
|
+
try {
|
|
320
|
+
const pathInfo = lstatSync(configPath);
|
|
321
|
+
if (pathInfo.isSymbolicLink()) {
|
|
322
|
+
try {
|
|
323
|
+
writePath = realpathSync(configPath);
|
|
324
|
+
symlinkPath = configPath;
|
|
325
|
+
if (!statSync(writePath).isFile()) {
|
|
326
|
+
throw new Error("config symlink target is not a regular file");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
return result(definition, "error", `config symlink target is unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
originalText = readFileSync(writePath, "utf8");
|
|
335
|
+
const parsed = parseToml(originalText);
|
|
336
|
+
if (!isJsonObject(parsed)) {
|
|
337
|
+
throw new Error("top-level TOML value must be a table");
|
|
338
|
+
}
|
|
339
|
+
config = parsed;
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
return result(definition, "error", `could not parse config: ${error instanceof Error ? error.message : String(error)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (!isErrno(error, "ENOENT")) {
|
|
347
|
+
return result(definition, "error", `could not inspect config: ${error instanceof Error ? error.message : String(error)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
let managedBlock;
|
|
351
|
+
try {
|
|
352
|
+
managedBlock = locateCodexManagedBlock(originalText ?? "");
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
356
|
+
}
|
|
357
|
+
const currentServers = config.mcp_servers;
|
|
358
|
+
if (currentServers !== undefined && !isJsonObject(currentServers)) {
|
|
359
|
+
return result(definition, "error", '"mcp_servers" must be a TOML table');
|
|
360
|
+
}
|
|
361
|
+
const mcpServers = (currentServers ?? {});
|
|
362
|
+
const hasCanonical = Object.prototype.hasOwnProperty.call(mcpServers, "prism-mcp");
|
|
363
|
+
const hasLegacy = Object.prototype.hasOwnProperty.call(mcpServers, "prism");
|
|
364
|
+
if (hasCanonical && hasLegacy) {
|
|
365
|
+
return result(definition, "error", "Codex config contains both prism and prism-mcp entries; resolve the duplicate before retrying");
|
|
366
|
+
}
|
|
367
|
+
if (hasCanonical || hasLegacy) {
|
|
368
|
+
const existingKey = hasCanonical ? "prism-mcp" : "prism";
|
|
369
|
+
const existingEntry = mcpServers[existingKey];
|
|
370
|
+
if (!refresh || existingKey !== "prism-mcp") {
|
|
371
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
372
|
+
}
|
|
373
|
+
if (!managedBlock) {
|
|
374
|
+
return result(definition, "existing", "Prism is already registered; existing entry left untouched");
|
|
375
|
+
}
|
|
376
|
+
if (!isManagedPrismEntry(existingEntry)) {
|
|
377
|
+
return result(definition, "error", "Prism-managed Codex block has an invalid ownership marker");
|
|
378
|
+
}
|
|
379
|
+
const refreshedEntry = refreshManagedEntry(existingEntry, entry);
|
|
380
|
+
if (isDeepStrictEqual(refreshedEntry, existingEntry)) {
|
|
381
|
+
return result(definition, "existing", "Prism-managed entry is already current");
|
|
382
|
+
}
|
|
383
|
+
let nextText;
|
|
384
|
+
try {
|
|
385
|
+
nextText = `${originalText.slice(0, managedBlock.start)}${serializeCodexManagedBlock(refreshedEntry, originalText)}${originalText.slice(managedBlock.end)}`;
|
|
386
|
+
validateCodexCandidate(nextText);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return result(definition, "error", `could not build valid Codex config: ${error instanceof Error ? error.message : String(error)}`);
|
|
390
|
+
}
|
|
391
|
+
if (dryRun) {
|
|
392
|
+
return result(definition, "would-refresh");
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
396
|
+
return result(definition, "refreshed");
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (managedBlock) {
|
|
403
|
+
return result(definition, "error", "Codex config contains a Prism-managed marker without its MCP entry");
|
|
404
|
+
}
|
|
405
|
+
let nextText;
|
|
406
|
+
try {
|
|
407
|
+
const currentText = originalText ?? "";
|
|
408
|
+
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
409
|
+
const separator = currentText.length === 0
|
|
410
|
+
? ""
|
|
411
|
+
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
412
|
+
? newline
|
|
413
|
+
: `${newline}${newline}`;
|
|
414
|
+
nextText = `${currentText}${separator}${serializeCodexManagedBlock(entry, currentText)}`;
|
|
415
|
+
validateCodexCandidate(nextText);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
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
|
+
}
|
|
420
|
+
if (dryRun) {
|
|
421
|
+
return result(definition, "would-register");
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
writeTextAtomically(writePath, nextText, originalText, beforeCommit, symlinkPath);
|
|
425
|
+
return result(definition, "registered");
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
return result(definition, "error", error instanceof Error ? error.message : String(error));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function serializeCodexManagedBlock(entry, existingText) {
|
|
432
|
+
const newline = existingText.includes("\r\n") ? "\r\n" : "\n";
|
|
433
|
+
const serialized = stringifyToml({ mcp_servers: { "prism-mcp": entry } }).trimEnd();
|
|
434
|
+
return `${CODEX_MANAGED_START}\n${serialized}\n${CODEX_MANAGED_END}\n`.replaceAll("\n", newline);
|
|
435
|
+
}
|
|
436
|
+
function validateCodexCandidate(text) {
|
|
437
|
+
const parsed = parseToml(text);
|
|
438
|
+
if (!isJsonObject(parsed) || !isJsonObject(parsed.mcp_servers)) {
|
|
439
|
+
throw new Error("generated mcp_servers table is unavailable");
|
|
440
|
+
}
|
|
441
|
+
const entry = parsed.mcp_servers["prism-mcp"];
|
|
442
|
+
if (!isManagedPrismEntry(entry)) {
|
|
443
|
+
throw new Error("generated prism-mcp entry failed ownership validation");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function locateCodexManagedBlock(text) {
|
|
447
|
+
const starts = findExactLineRanges(text, CODEX_MANAGED_START);
|
|
448
|
+
const ends = findExactLineRanges(text, CODEX_MANAGED_END);
|
|
449
|
+
if (starts.length === 0 && ends.length === 0)
|
|
450
|
+
return undefined;
|
|
451
|
+
if (starts.length !== 1 || ends.length !== 1 || starts[0].start >= ends[0].start) {
|
|
452
|
+
throw new Error("Codex config has malformed or duplicate Prism-managed markers; no changes made");
|
|
453
|
+
}
|
|
454
|
+
return { start: starts[0].start, end: ends[0].end };
|
|
455
|
+
}
|
|
456
|
+
function findExactLineRanges(text, expected) {
|
|
457
|
+
const ranges = [];
|
|
458
|
+
let start = 0;
|
|
459
|
+
while (start <= text.length) {
|
|
460
|
+
const newline = text.indexOf("\n", start);
|
|
461
|
+
const lineEnd = newline === -1 ? text.length : newline;
|
|
462
|
+
const contentEnd = lineEnd > start && text[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
|
|
463
|
+
if (text.slice(start, contentEnd) === expected) {
|
|
464
|
+
ranges.push({ start, end: newline === -1 ? text.length : newline + 1 });
|
|
465
|
+
}
|
|
466
|
+
if (newline === -1)
|
|
467
|
+
break;
|
|
468
|
+
start = newline + 1;
|
|
469
|
+
}
|
|
470
|
+
return ranges;
|
|
471
|
+
}
|
|
472
|
+
function writeTextAtomically(filePath, contents, expectedText, beforeCommit, symlinkPath) {
|
|
473
|
+
const directory = dirname(filePath);
|
|
474
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
475
|
+
const mode = existsSync(filePath) ? statSync(filePath).mode & 0o777 : 0o600;
|
|
476
|
+
const tempPath = join(directory, `.${basename(filePath)}.prism-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
477
|
+
try {
|
|
478
|
+
writeFileSync(tempPath, contents, {
|
|
479
|
+
encoding: "utf8",
|
|
480
|
+
flag: "wx",
|
|
481
|
+
mode,
|
|
482
|
+
});
|
|
483
|
+
beforeCommit?.(filePath);
|
|
484
|
+
if (symlinkPath) {
|
|
485
|
+
let currentTarget;
|
|
486
|
+
try {
|
|
487
|
+
currentTarget = realpathSync(symlinkPath);
|
|
488
|
+
if (currentTarget !== filePath || !statSync(currentTarget).isFile()) {
|
|
489
|
+
throw new Error("target changed");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
throw new Error(`Config symlink changed while Prism was preparing the update; retry: ${error instanceof Error ? error.message : String(error)}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (expectedText === undefined) {
|
|
497
|
+
if (existsSync(filePath)) {
|
|
498
|
+
throw new Error(`Config changed while Prism was preparing the update; retry: ${filePath}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
let currentText;
|
|
503
|
+
try {
|
|
504
|
+
currentText = readFileSync(filePath, "utf8");
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
throw new Error(`Config changed while Prism was preparing the update; retry: ${error instanceof Error ? error.message : String(error)}`);
|
|
508
|
+
}
|
|
509
|
+
if (currentText !== expectedText) {
|
|
510
|
+
throw new Error(`Config changed while Prism was preparing the update; retry: ${filePath}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// This compare-before-rename catches observable host edits and the rename
|
|
514
|
+
// itself is atomic. No portable filesystem API can make the comparison and
|
|
515
|
+
// replacement one operation against an uncooperative host, which is why the
|
|
516
|
+
// CLI also tells users to close target hosts before writing.
|
|
517
|
+
renameSync(tempPath, filePath);
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
if (existsSync(tempPath))
|
|
521
|
+
unlinkSync(tempPath);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function result(definition, status, message) {
|
|
525
|
+
return {
|
|
526
|
+
host: definition.name,
|
|
527
|
+
label: definition.label,
|
|
528
|
+
path: definition.configPath,
|
|
529
|
+
status,
|
|
530
|
+
message,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function isJsonObject(value) {
|
|
534
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
535
|
+
}
|
|
536
|
+
function isManagedPrismEntry(value) {
|
|
537
|
+
return isJsonObject(value)
|
|
538
|
+
&& isJsonObject(value.env)
|
|
539
|
+
&& value.env.PRISM_INSTANCE === "prism-mcp";
|
|
540
|
+
}
|
|
541
|
+
function refreshManagedEntry(existing, desired) {
|
|
542
|
+
const existingEnv = isJsonObject(existing.env) ? existing.env : {};
|
|
543
|
+
const desiredEnv = isJsonObject(desired.env) ? desired.env : {};
|
|
544
|
+
const mergedEnv = {
|
|
545
|
+
...existingEnv,
|
|
546
|
+
...desiredEnv,
|
|
547
|
+
};
|
|
548
|
+
if (!Object.prototype.hasOwnProperty.call(desiredEnv, "PRISM_SYNALUX_API_KEY")) {
|
|
549
|
+
delete mergedEnv.PRISM_SYNALUX_API_KEY;
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
...existing,
|
|
553
|
+
command: desired.command,
|
|
554
|
+
args: desired.args,
|
|
555
|
+
env: mergedEnv,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function isErrno(error, code) {
|
|
559
|
+
return error instanceof Error && error.code === code;
|
|
560
|
+
}
|
|
@@ -94,41 +94,18 @@ session_save_ledger({
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
function getIDEConfigContent(client = "claude_desktop") {
|
|
97
|
-
const
|
|
98
|
-
claude_desktop: `{
|
|
99
|
-
"mcpServers": {
|
|
100
|
-
"prism-mcp": {
|
|
101
|
-
"command": "npx",
|
|
102
|
-
"args": ["-y", "prism-mcp@latest"],
|
|
103
|
-
"env": {
|
|
104
|
-
"BRAVE_API_KEY": "your-brave-key"
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}`,
|
|
109
|
-
cursor: `{
|
|
110
|
-
"mcpServers": {
|
|
111
|
-
"prism-mcp": {
|
|
112
|
-
"command": "npx",
|
|
113
|
-
"args": ["-y", "prism-mcp@latest"],
|
|
114
|
-
"env": {
|
|
115
|
-
"BRAVE_API_KEY": "your-brave-key"
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}`,
|
|
120
|
-
};
|
|
97
|
+
const host = client === "cursor" ? "cursor" : "claude-desktop";
|
|
121
98
|
return {
|
|
122
99
|
step: "ide_config",
|
|
123
100
|
title: "⚙️ Configure Your IDE",
|
|
124
|
-
description: "
|
|
101
|
+
description: "Register Prism as an MCP server in your AI coding tool.",
|
|
125
102
|
instructions: [
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
103
|
+
"Run the command below in a terminal",
|
|
104
|
+
"Prism adds only a missing registration and never overwrites custom entries",
|
|
105
|
+
"Use --dry-run first if you want to preview the target file",
|
|
129
106
|
"Restart your IDE to pick up the new MCP server",
|
|
130
107
|
],
|
|
131
|
-
codeSnippet:
|
|
108
|
+
codeSnippet: `prism connect --host ${host}`,
|
|
132
109
|
nextStep: "first_save",
|
|
133
110
|
progress: 43,
|
|
134
111
|
};
|