@sphyr/cli 2.0.0-beta.1

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.
@@ -0,0 +1,411 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Sphyr
3
+
4
+ /**
5
+ * IDE client definitions for sphyr init.
6
+ *
7
+ * Provides:
8
+ * IDE_CLIENTS — ordered array of 6 supported IDE clients
9
+ * detectInstalledClients() — returns Set of installed client IDs
10
+ * isSphyrEntry(...) — returns true if an mcpServers key/entry is sphyr-owned (D-04)
11
+ * mergeClientConfig(...) — read-merge-write a client's JSON config file
12
+ * buildServerEntry(...) — constructs the MCP server entry JSON
13
+ * readMcpServersForIde(...) — reads existing mcpServers for an IDE client
14
+ * replaceWithGuardEntry(...) — replaces wrapped entries with a single sphyr-guard entry
15
+ */
16
+
17
+ import { createRequire } from 'node:module';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+
22
+ // D-17: Read the CLI version from package.json at module load time.
23
+ // Using createRequire because this file is ESM ("type": "module" in package.json),
24
+ // so require() is not available natively. createRequire gives us a CJS-compatible
25
+ // require() bound to this file's URL, making the relative path resolution correct
26
+ // regardless of the process cwd. The CLI_VERSION is read once and pinned into
27
+ // every generated npx command to prevent supply-chain attacks via future npm
28
+ // releases of @sphyr/sdk that the user has not vetted.
29
+ const _require = createRequire(import.meta.url);
30
+ const CLI_VERSION = _require('../../package.json').version;
31
+
32
+ // D-07: Read the SDK version from @sphyr/sdk/package.json at module load time.
33
+ // This is distinct from CLI_VERSION (packages/cli is 1.1.0-beta.1; @sphyr/sdk is
34
+ // 2.0.0-beta.1 — different packages). SDK_VERSION is used in buildServerEntry to
35
+ // pin the sphyr-guard binary to the exact SDK version installed in this workspace,
36
+ // satisfying the D-17 supply-chain pinning requirement for the new sphyr-guard entry.
37
+ export const SDK_VERSION = _require('@sphyr/sdk/package.json').version;
38
+
39
+ const HOME = os.homedir();
40
+ const PLATFORM = process.platform;
41
+
42
+ // Helper: resolve %APPDATA% on Windows, fall back to HOME-relative on others
43
+ function appdata(...segments) {
44
+ if (PLATFORM === 'win32') {
45
+ const base = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
46
+ return path.join(base, ...segments);
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * IDE_CLIENTS — 6 entries in priority order:
53
+ * Claude Desktop, Cursor, VS Code (Cline/Roo), Antigravity, Zed, Windsurf
54
+ *
55
+ * Each entry:
56
+ * id — string identifier
57
+ * name — display name
58
+ * configPath() — returns absolute path or null (e.g. Claude Desktop on Linux)
59
+ * topLevelKey — JSON key for the MCP servers block
60
+ * entryExtra — optional extra fields merged into the server entry
61
+ */
62
+ export const IDE_CLIENTS = [
63
+ {
64
+ id: 'claude-desktop',
65
+ name: 'Claude Desktop',
66
+ configPath() {
67
+ if (PLATFORM === 'darwin') {
68
+ return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
69
+ }
70
+ if (PLATFORM === 'win32') {
71
+ return appdata('Claude', 'claude_desktop_config.json');
72
+ }
73
+ // Linux: not supported
74
+ return null;
75
+ },
76
+ topLevelKey: 'mcpServers',
77
+ entryExtra: undefined,
78
+ },
79
+ {
80
+ id: 'cursor',
81
+ name: 'Cursor',
82
+ configPath() {
83
+ return path.join(HOME, '.cursor', 'mcp.json');
84
+ },
85
+ topLevelKey: 'mcpServers',
86
+ entryExtra: undefined,
87
+ },
88
+ {
89
+ id: 'vscode',
90
+ name: 'VS Code (Cline/Roo)',
91
+ configPath() {
92
+ if (PLATFORM === 'win32') {
93
+ return appdata('Code', 'User', 'mcp.json');
94
+ }
95
+ return path.join(HOME, '.vscode', 'mcp.json');
96
+ },
97
+ topLevelKey: 'servers',
98
+ entryExtra: { type: 'stdio' },
99
+ },
100
+ {
101
+ id: 'antigravity',
102
+ name: 'Antigravity',
103
+ configPath() {
104
+ return path.join(HOME, '.gemini', 'antigravity', 'mcp_config.json');
105
+ },
106
+ topLevelKey: 'mcpServers',
107
+ entryExtra: undefined,
108
+ },
109
+ {
110
+ id: 'zed',
111
+ name: 'Zed',
112
+ configPath() {
113
+ if (PLATFORM === 'win32') {
114
+ return appdata('Zed', 'settings.json');
115
+ }
116
+ return path.join(HOME, '.config', 'zed', 'settings.json');
117
+ },
118
+ topLevelKey: 'context_servers',
119
+ entryExtra: undefined,
120
+ },
121
+ {
122
+ id: 'windsurf',
123
+ name: 'Windsurf',
124
+ configPath() {
125
+ return path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json');
126
+ },
127
+ topLevelKey: 'mcpServers',
128
+ entryExtra: undefined,
129
+ },
130
+ ];
131
+
132
+ /**
133
+ * Returns a Set of client IDs whose configPath() is non-null AND whose config
134
+ * file (or its parent directory) exists on the filesystem.
135
+ */
136
+ export function detectInstalledClients() {
137
+ const installed = new Set();
138
+ for (const client of IDE_CLIENTS) {
139
+ const cfgPath = client.configPath();
140
+ if (!cfgPath) continue;
141
+ // Check for the file itself, or the parent directory (e.g. app is installed
142
+ // but config hasn't been created yet)
143
+ if (fs.existsSync(cfgPath) || fs.existsSync(path.dirname(cfgPath))) {
144
+ installed.add(client.id);
145
+ }
146
+ }
147
+ return installed;
148
+ }
149
+
150
+ /**
151
+ * Read-merge-write a client's JSON config file.
152
+ *
153
+ * @param {string} configPath — absolute path to the config file
154
+ * @param {string} topLevelKey — JSON key that holds the MCP servers map
155
+ * @param {string} serverEntryKey — the key to set within the servers map
156
+ * @param {object} serverEntry — the MCP server entry object to write
157
+ */
158
+ export function mergeClientConfig(configPath, topLevelKey, serverEntryKey, serverEntry) {
159
+ let existing = {};
160
+
161
+ if (fs.existsSync(configPath)) {
162
+ try {
163
+ existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
164
+ if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
165
+ throw new Error(
166
+ `Config file at ${configPath} must contain a JSON object at the top level. ` +
167
+ `Found ${Array.isArray(existing) ? 'array' : typeof existing}. ` +
168
+ `Fix the file manually, then re-run sphyr init.`,
169
+ );
170
+ }
171
+ } catch (parseErr) {
172
+ throw new Error(
173
+ `Config file at ${configPath} contains invalid JSON and cannot be merged safely.\n` +
174
+ `Fix the file manually, then re-run sphyr init.\n` +
175
+ `Parse error: ${parseErr.message}`,
176
+ );
177
+ }
178
+ } else {
179
+ // Ensure parent directory exists
180
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
181
+ }
182
+
183
+ if (!existing[topLevelKey] || typeof existing[topLevelKey] !== 'object') {
184
+ existing[topLevelKey] = {};
185
+ }
186
+
187
+ existing[topLevelKey][serverEntryKey] = serverEntry;
188
+
189
+ fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
190
+ fs.chmodSync(configPath, 0o600);
191
+ }
192
+
193
+ /**
194
+ * Returns true if an mcpServers entry should be excluded from SPHYR_DOWNSTREAM
195
+ * (D-04 belt-and-suspenders circular-wrapping prevention).
196
+ *
197
+ * Exclusion rules:
198
+ * 1. Key prefix check: key.toLowerCase().startsWith('sphyr')
199
+ * 2. Command/args content check: 'sphyr-guard' or 'sphyr-proxy' appears in
200
+ * entry.command or entry.args (catches sphyr binaries keyed under non-sphyr names)
201
+ *
202
+ * Exported so init.js can use it for wrappedKeys computation and D-04 logging
203
+ * without duplicating the rule (kept in sync automatically).
204
+ *
205
+ * @param {string} key — the mcpServers entry key
206
+ * @param {object} entry — the mcpServers entry value
207
+ * @returns {boolean}
208
+ */
209
+ export function isSphyrEntry(key, entry) {
210
+ if (key.toLowerCase().startsWith('sphyr')) return true;
211
+ const cmd = entry.command || '';
212
+ const argsStr = (entry.args || []).join(' ');
213
+ return cmd.includes('sphyr-guard') || cmd.includes('sphyr-proxy') || cmd.includes('sphyr-mcp') ||
214
+ argsStr.includes('sphyr-guard') || argsStr.includes('sphyr-proxy') || argsStr.includes('sphyr-mcp');
215
+ }
216
+
217
+ /**
218
+ * Reads existing mcpServers entries from an IDE client's config, applying D-04
219
+ * exclusion, D-08 mapping, and D-01 merge semantics with dual legacy source support.
220
+ *
221
+ * Returns warnings in the result object — callers (init.js) are responsible for
222
+ * surfacing them via p.log.warn(). This file has no @clack/prompts dependency.
223
+ *
224
+ * @param {object} client — an entry from IDE_CLIENTS (must have configPath() and topLevelKey)
225
+ * @returns {{ all: object[], existingWrapped: object[], newIndividual: object[], warnings: string[] }}
226
+ */
227
+ // eslint-disable-next-line max-lines-per-function -- sequential read-merge phases with D-01/D-04/D-08 logic; splitting would obscure the merge invariant
228
+ export function readMcpServersForIde(client) {
229
+ const empty = { all: [], existingWrapped: [], newIndividual: [], warnings: [] };
230
+
231
+ const configPath = client.configPath();
232
+ if (!configPath || !fs.existsSync(configPath)) {
233
+ return empty;
234
+ }
235
+
236
+ let config;
237
+ try {
238
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
239
+ } catch (parseErr) {
240
+ // Non-fatal during preview phase (D-03) — return empty with a warning
241
+ return {
242
+ ...empty,
243
+ warnings: [`Config file at ${configPath} could not be parsed: ${parseErr.message}`],
244
+ };
245
+ }
246
+
247
+ // Use client.topLevelKey (NOT hardcoded 'mcpServers') — Pitfall 6:
248
+ // Zed uses 'context_servers', VS Code uses 'servers'.
249
+ const servers = config[client.topLevelKey] || {};
250
+ const warnings = [];
251
+
252
+ // Read previously-wrapped servers from BOTH legacy sources (D-01 re-run correctness).
253
+ // 'sphyr' is the current canonical entry; it wins on name collision with any legacy
254
+ // 'sphyr-guard' entry (the pre-rename entry written by older versions of this CLI).
255
+ let canonicalWrapped = [];
256
+ let legacyGuardWrapped = [];
257
+
258
+ const canonicalEntry = servers['sphyr'];
259
+ const legacyGuardEntry = servers['sphyr-guard'];
260
+
261
+ if (canonicalEntry?.env?.SPHYR_DOWNSTREAM) {
262
+ try {
263
+ canonicalWrapped = JSON.parse(canonicalEntry.env.SPHYR_DOWNSTREAM);
264
+ if (!Array.isArray(canonicalWrapped)) canonicalWrapped = [];
265
+ } catch {
266
+ warnings.push(
267
+ `Existing SPHYR_DOWNSTREAM in 'sphyr' could not be parsed — ` +
268
+ `previously-wrapped servers from that entry will be lost. ` +
269
+ `Check the entry manually.`,
270
+ );
271
+ canonicalWrapped = [];
272
+ }
273
+ }
274
+
275
+ if (legacyGuardEntry?.env?.SPHYR_DOWNSTREAM) {
276
+ try {
277
+ legacyGuardWrapped = JSON.parse(legacyGuardEntry.env.SPHYR_DOWNSTREAM);
278
+ if (!Array.isArray(legacyGuardWrapped)) legacyGuardWrapped = [];
279
+ } catch {
280
+ warnings.push(
281
+ `Existing SPHYR_DOWNSTREAM in 'sphyr-guard' could not be parsed — ` +
282
+ `previously-wrapped servers from that entry will be lost. ` +
283
+ `Check the entry manually.`,
284
+ );
285
+ legacyGuardWrapped = [];
286
+ }
287
+ }
288
+
289
+ // Merge both sources. 'sphyr' wins on name collision (it is the current canonical
290
+ // entry written by this version of the CLI; 'sphyr-guard' is a legacy key from
291
+ // older installs that used @sphyr/agent-guard-cli).
292
+ const existingWrappedMap = new Map(legacyGuardWrapped.map(e => [e.name, e]));
293
+ for (const e of canonicalWrapped) existingWrappedMap.set(e.name, e);
294
+ const existingWrapped = [...existingWrappedMap.values()];
295
+
296
+ // Build newIndividual: iterate mcpServers, exclude sphyr entries (D-04),
297
+ // map to DownstreamServerConfig shape (D-08 — unknown fields dropped silently).
298
+ const newIndividual = Object.entries(servers)
299
+ .filter(([key, entry]) => !isSphyrEntry(key, entry))
300
+ .map(([key, entry]) => ({
301
+ name: key,
302
+ command: entry.command,
303
+ args: entry.args || [],
304
+ ...(entry.env ? { env: entry.env } : {}),
305
+ }));
306
+
307
+ // Merge with D-01 semantics: existing first, new individual wins on name collision.
308
+ const merged = new Map(existingWrapped.map(e => [e.name, e]));
309
+ for (const e of newIndividual) merged.set(e.name, e);
310
+
311
+ return {
312
+ all: [...merged.values()],
313
+ existingWrapped,
314
+ newIndividual,
315
+ warnings,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Constructs the MCP server entry for a given client.
321
+ * Generates a sphyr-guard entry with SPHYR_DOWNSTREAM embedded in env.
322
+ *
323
+ * The sphyr-guard binary fail-fasts at startup if required env vars are missing,
324
+ * so callers must always supply all required env vars.
325
+ *
326
+ * @param {object} client — an entry from IDE_CLIENTS
327
+ * @param {string} credential — the Sphyr single credential (`sphyr_v1_<keyId>.<signingSecret>`)
328
+ * @param {string} downstreamJson — JSON.stringify'd DownstreamServerConfig[] array
329
+ * @returns {object} the server entry object
330
+ */
331
+ export function buildServerEntry(client, credential, downstreamJson) {
332
+ // D-07: Pin SDK_VERSION (not CLI_VERSION) in the npx command — the sphyr-mcp
333
+ // binary lives in @sphyr/sdk, not @sphyr/cli. Using SDK_VERSION ensures the
334
+ // generated config pins the correct package at the correct version.
335
+ // D-06: args use 'sphyr-mcp'; SPHYR_DOWNSTREAM added to env.
336
+ const entry = {
337
+ command: 'npx',
338
+ args: ['-y', '-p', `@sphyr/sdk@${SDK_VERSION}`, 'sphyr-mcp'],
339
+ env: {
340
+ SPHYR_CREDENTIAL: credential,
341
+ SPHYR_DOWNSTREAM: downstreamJson,
342
+ },
343
+ };
344
+ if (client.entryExtra) Object.assign(entry, client.entryExtra);
345
+ return entry;
346
+ }
347
+
348
+ /**
349
+ * Replaces existing wrapped + sphyr-prefixed entries in an IDE config with a
350
+ * single sphyr-guard entry (D-05 write operation).
351
+ *
352
+ * Steps:
353
+ * 1. Read existing config (replicates mergeClientConfig read-guard exactly)
354
+ * 2. Delete all keys in wrappedKeys from config[topLevelKey]
355
+ * 3. Delete any remaining keys whose lowercased prefix starts with 'sphyr' (upgrade path)
356
+ * 4. Write config[topLevelKey]['sphyr-guard'] = guardEntry
357
+ * 5. Write file with 0o600 permissions (belt-and-suspenders: mode in writeFileSync + chmodSync)
358
+ *
359
+ * @param {string} configPath — absolute path to the config file
360
+ * @param {string} topLevelKey — JSON key that holds the MCP servers map
361
+ * @param {object} guardEntry — the sphyr-guard entry to write
362
+ * @param {string[]} wrappedKeys — keys to remove from servers (the previously-individual entries)
363
+ */
364
+ // eslint-disable-next-line max-lines-per-function -- sequential read-guard then delete-write phases; splitting obscures the file-integrity invariant (same rationale as mergeClientConfig)
365
+ export function replaceWithGuardEntry(configPath, topLevelKey, guardEntry, wrappedKeys) {
366
+ let existing = {};
367
+
368
+ if (fs.existsSync(configPath)) {
369
+ try {
370
+ existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
371
+ if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
372
+ throw new Error(
373
+ `Config file at ${configPath} must contain a JSON object at the top level. ` +
374
+ `Found ${Array.isArray(existing) ? 'array' : typeof existing}. ` +
375
+ `Fix the file manually, then re-run sphyr init.`,
376
+ );
377
+ }
378
+ } catch (parseErr) {
379
+ throw new Error(
380
+ `Config file at ${configPath} contains invalid JSON and cannot be merged safely.\n` +
381
+ `Fix the file manually, then re-run sphyr init.\n` +
382
+ `Parse error: ${parseErr.message}`,
383
+ );
384
+ }
385
+ } else {
386
+ // Ensure parent directory exists
387
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
388
+ }
389
+
390
+ if (!existing[topLevelKey] || typeof existing[topLevelKey] !== 'object') {
391
+ existing[topLevelKey] = {};
392
+ }
393
+ const servers = existing[topLevelKey];
394
+
395
+ // Remove the individual wrapped entries (the ones now encoded in SPHYR_DOWNSTREAM)
396
+ for (const key of wrappedKeys) delete servers[key];
397
+
398
+ // Remove any remaining sphyr-prefixed entries (upgrade path D-05):
399
+ // clears old 'sphyr', 'sphyr-proxy', 'sphyr-guard' regardless of wrappedKeys.
400
+ for (const key of Object.keys(servers)) {
401
+ if (key.toLowerCase().startsWith('sphyr')) delete servers[key];
402
+ }
403
+
404
+ // Write the single new 'sphyr' entry (key is hardcoded — ROADMAP SC-2, Pitfall 2)
405
+ servers['sphyr'] = guardEntry;
406
+
407
+ // Belt-and-suspenders: set mode in writeFileSync AND follow up with chmodSync
408
+ // (same pattern as mergeClientConfig — both calls required per security requirement)
409
+ fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
410
+ fs.chmodSync(configPath, 0o600);
411
+ }
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Sphyr Agent Guard Contributors
3
+ //
4
+ // packages/cli/src/lib/url-guard.js
5
+ //
6
+ // REVIEW.md W-10 (2026-06-09): SPHYR_API_URL / SPHYR_MCP_URL flow into the
7
+ // device flow and are persisted into ~/.sphyr/config.json, where the SDK reads
8
+ // them for credentialed traffic indefinitely. A poisoned env var at login time
9
+ // would silently redirect credentials to an attacker host. guard.js has always
10
+ // enforced HTTPS on its own env var; this helper ports the same rule (plus the
11
+ // SDK's local-host exception, matching sdk/ts/guard.ts) to the two commands
12
+ // that actually handle the credential.
13
+
14
+ const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
15
+
16
+ /** Strips userinfo so a URL can be printed in errors without leaking embedded credentials. */
17
+ export function safeUrl(raw) {
18
+ try {
19
+ const u = new URL(raw);
20
+ u.username = '';
21
+ u.password = '';
22
+ return u.toString();
23
+ } catch {
24
+ return '<invalid url>';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Validates an endpoint URL sourced from an env var. Exits the process on a
30
+ * non-HTTPS non-local URL; prints a loud notice when a non-default endpoint is
31
+ * in effect so an unexpected override is visible at the moment credentials flow.
32
+ *
33
+ * @param {string} value the URL in effect (env override or default)
34
+ * @param {string} envName the env var name, for messages
35
+ * @param {string} defaultValue the built-in default endpoint
36
+ * @returns {string} value, unchanged, for assignment convenience
37
+ */
38
+ export function requireTrustedEndpoint(value, envName, defaultValue) {
39
+ let parsed;
40
+ try {
41
+ parsed = new URL(value);
42
+ } catch {
43
+ process.stderr.write(`[sphyr] Fatal: ${envName} is not a valid URL: ${safeUrl(value)}\n`);
44
+ process.exit(1);
45
+ }
46
+ if (parsed.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(parsed.hostname)) {
47
+ process.stderr.write(
48
+ `[sphyr] Fatal: ${envName} must use https:// for non-local hosts (got: ${safeUrl(value)}). ` +
49
+ `Credentials will not be sent over an insecure or unexpected transport.\n`,
50
+ );
51
+ process.exit(1);
52
+ }
53
+ if (value !== defaultValue) {
54
+ process.stderr.write(
55
+ `[sphyr] NOTICE: ${envName} is overridden — credentials will flow to ${safeUrl(value)} (default: ${defaultValue}).\n`,
56
+ );
57
+ }
58
+ return value;
59
+ }