dxai-cli 1.0.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/src/detect.js ADDED
@@ -0,0 +1,587 @@
1
+ import { execSync, execFileSync } from 'child_process';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import { successMsg, warnMsg } from './branding.js';
6
+ import { isSafeBinaryName } from './registry/validate.js';
7
+
8
+ // ── OS Detection ──
9
+ export function detectOS() {
10
+ const platform = os.platform();
11
+ const map = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
12
+ return {
13
+ platform,
14
+ name: map[platform] || platform,
15
+ arch: os.arch(),
16
+ home: os.homedir(),
17
+ isWindows: platform === 'win32',
18
+ isMac: platform === 'darwin',
19
+ isLinux: platform === 'linux',
20
+ };
21
+ }
22
+
23
+ // ── Command existence check ──
24
+ export function commandExists(cmd) {
25
+ // `cmd` is interpolated into a shell string below. Agent detect commands are
26
+ // hardcoded, but automation-tool detect commands come from the (untrusted,
27
+ // network-refreshed) registry — so even though the payload is validated at
28
+ // the fetch boundary, refuse anything but a bare binary name here too.
29
+ if (!isSafeBinaryName(cmd)) return false;
30
+ try {
31
+ const check = os.platform() === 'win32'
32
+ ? `where ${cmd} 2>nul`
33
+ : `command -v ${cmd} 2>/dev/null`;
34
+ execSync(check, { stdio: 'pipe' });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ const DEFAULT_APPLICATIONS_DIR = '/Applications';
42
+
43
+ function appExists(appName, applicationsDir = DEFAULT_APPLICATIONS_DIR) {
44
+ if (!appName || os.platform() !== 'darwin') return false;
45
+ return fs.existsSync(path.join(applicationsDir, appName));
46
+ }
47
+
48
+ // Version of a macOS app bundle, from Info.plist. Some agents (the Antigravity
49
+ // desktop app) ship no shell command at all, so this is the only version signal.
50
+ function appVersion(appName, applicationsDir = DEFAULT_APPLICATIONS_DIR) {
51
+ try {
52
+ const plist = fs.readFileSync(path.join(applicationsDir, appName, 'Contents', 'Info.plist'), 'utf-8');
53
+ const match = plist.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/);
54
+ return match ? match[1].trim() : null;
55
+ } catch {
56
+ return null; // not a bundle we can read — version simply stays unknown
57
+ }
58
+ }
59
+
60
+ // First existing path from an agent's `detectPaths` — install locations that
61
+ // are documented but commonly missing from PATH (e.g. ~/.local/bin). These are
62
+ // hardcoded absolute paths, never registry data.
63
+ function findInstalledPath(def, home) {
64
+ const candidates = typeof def.detectPaths === 'function' ? def.detectPaths(home) : [];
65
+ return candidates.find((p) => p && fs.existsSync(p)) || null;
66
+ }
67
+
68
+ function toList(value) {
69
+ if (Array.isArray(value)) return value;
70
+ return value ? [value] : [];
71
+ }
72
+
73
+ function parseVersion(out) {
74
+ const match = out.match(/(\d+\.\d+[.\d]*)/);
75
+ return match ? match[1] : out.slice(0, 30);
76
+ }
77
+
78
+ function getVersion(cmd, flag = '--version') {
79
+ if (!isSafeBinaryName(cmd)) return null;
80
+ try {
81
+ return parseVersion(execSync(`${cmd} ${flag}`, { stdio: 'pipe', timeout: 10000 }).toString().trim());
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // Version of a binary found by absolute path (argv form — no shell involved).
88
+ function getVersionAtPath(binPath, flag = '--version') {
89
+ try {
90
+ return parseVersion(execFileSync(binPath, [flag], { stdio: 'pipe', timeout: 10000 }).toString().trim());
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ // ── Prerequisites ──
97
+ export function checkPrerequisites() {
98
+ const pyCmd = commandExists('python3') ? 'python3' : (commandExists('python') ? 'python' : null);
99
+ return {
100
+ node: { installed: commandExists('node'), version: getVersion('node') },
101
+ git: { installed: commandExists('git'), version: getVersion('git') },
102
+ python: { installed: !!pyCmd, command: pyCmd, version: pyCmd ? getVersion(pyCmd) : null },
103
+ };
104
+ }
105
+
106
+ // ── Agent Detection ──
107
+ //
108
+ // AGENT_DEFINITIONS is the single source of truth for every tool dxai supports.
109
+ // Each entry is verified against the vendor's current documentation and carries
110
+ // `docs` (the pages it was checked against) and `verifiedAt` (when). The weekly
111
+ // agent-health workflow (scripts/agent-health.mjs) flags entries whose
112
+ // `verifiedAt` is older than the review window, whose Homebrew cask / winget
113
+ // package has vanished or been deprecated, or whose docs URLs have moved — so
114
+ // vendor churn surfaces as an issue instead of a silent detection gap.
115
+ //
116
+ // Detection fields: `detectCommand` (one or more binary names probed on PATH),
117
+ // `detectApp` (one or more macOS bundles under /Applications), and `detectPaths`
118
+ // (absolute install locations to check when the binary is not on PATH). Any hit
119
+ // counts as installed; `configDir` existing is reported separately as
120
+ // `configExists`.
121
+ //
122
+ // `mcpDialect` describes how an agent expresses an MCP server, so a server can
123
+ // declare its `transport` once and have its per-agent config blocks derived
124
+ // (see renderAgentConfig + src/registry/mcp-servers.js#deriveConfigs). Adding a
125
+ // new agent here is enough to make every transport-based server support it.
126
+ // - { kind: 'json', urlKey, typed?, envRef }
127
+ // JSON config. HTTP servers use `urlKey`; stdio uses command/args/env.
128
+ // `typed` = { http: 'http', stdio: 'stdio' } adds a `type` field where the
129
+ // agent's schema requires one. `envRef` is how the agent references an
130
+ // environment variable inside its config file:
131
+ // 'dollar' → ${VAR} (Gemini CLI, Claude Code .mcp.json)
132
+ // 'vscode' → ${env:VAR} (VS Code, Cursor, Devin)
133
+ // 'literal' → no interpolation documented; dxai substitutes the value
134
+ // from its own environment at write time (Antigravity)
135
+ // - { kind: 'toml' } → Codex-style TOML block; env vars are forwarded
136
+ // by name via `env_vars` (Codex never interpolates)
137
+ // - { kind: 'claude-cli' } → `claude mcp add ...` argv at user scope; env vars
138
+ // are passed as `--env VAR=${VAR}` and resolved at
139
+ // run time (Claude Code expands nothing at user scope)
140
+ // `projectMcpDialect` overrides the dialect for the project-level file when it
141
+ // differs from the global one (Claude Code: CLI globally, .mcp.json in projects).
142
+
143
+ const VERIFIED = '2026-09-10';
144
+
145
+ function vscodeUserDir(home, product) {
146
+ const platform = os.platform();
147
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', product, 'User');
148
+ if (platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), product, 'User');
149
+ return path.join(home, '.config', product, 'User');
150
+ }
151
+
152
+ function devinConfigDir(home) {
153
+ if (os.platform() === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'devin');
154
+ return path.join(home, '.config', 'devin');
155
+ }
156
+
157
+ const codexHome = (home) => process.env.CODEX_HOME || path.join(home, '.codex');
158
+ const claudeConfigDir = (home) => process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
159
+
160
+ const ANTIGRAVITY_MCP = (home) => path.join(home, '.gemini', 'config', 'mcp_config.json');
161
+ const ANTIGRAVITY_DIALECT = { kind: 'json', urlKey: 'serverUrl', envRef: 'literal' };
162
+ const ANTIGRAVITY_DOCS = ['https://antigravity.google/docs/mcp/', 'https://antigravity.google/docs/cli/getting-started/'];
163
+
164
+ const DEVIN_MCP = (home) => path.join(devinConfigDir(home), 'mcp_config.json');
165
+ const DEVIN_DIALECT = { kind: 'json', urlKey: 'url', envRef: 'vscode' };
166
+ const DEVIN_DOCS = ['https://docs.devin.ai/cli/extensibility/mcp/configuration', 'https://docs.devin.ai/desktop/getting-started'];
167
+
168
+ export const AGENT_DEFINITIONS = [
169
+ {
170
+ id: 'cursor',
171
+ name: 'Cursor',
172
+ description: 'AI-first IDE (VS Code fork)',
173
+ detectCommand: 'cursor',
174
+ detectApp: 'Cursor.app',
175
+ configDir: (home) => path.join(home, '.cursor'),
176
+ globalMcpPath: (home) => path.join(home, '.cursor', 'mcp.json'),
177
+ projectMcpPath: () => path.join('.cursor', 'mcp.json'),
178
+ configFormat: 'json',
179
+ mcpKey: 'mcpServers',
180
+ // Cursor's schema lists `type` as required for stdio only; remote servers
181
+ // are just { url, headers?, auth? }.
182
+ mcpDialect: { kind: 'json', urlKey: 'url', typed: { stdio: 'stdio' }, envRef: 'vscode' },
183
+ install: { brewCask: 'cursor', winget: 'Anysphere.Cursor' },
184
+ docs: ['https://cursor.com/docs/context/mcp', 'https://cursor.com/docs/get-started/installation'],
185
+ verifiedAt: VERIFIED,
186
+ },
187
+ {
188
+ id: 'claude-code',
189
+ name: 'Claude Code',
190
+ description: 'Anthropic\'s terminal AI agent (also bundled in the Claude desktop app)',
191
+ detectCommand: 'claude',
192
+ detectApp: 'Claude.app', // the desktop app ships Claude Code and shares its config
193
+ detectPaths: (home) => [path.join(home, '.local', 'bin', 'claude')],
194
+ configDir: claudeConfigDir,
195
+ globalMcpPath: (home) => path.join(home, '.claude.json'),
196
+ configFormat: 'cli', // uses `claude mcp add --scope user`
197
+ mcpKey: 'mcpServers',
198
+ mcpDialect: { kind: 'claude-cli' },
199
+ // Project scope is a plain JSON file at the repo root with ${VAR} expansion.
200
+ projectMcpPath: () => '.mcp.json',
201
+ projectConfigFormat: 'json',
202
+ projectMcpKey: 'mcpServers',
203
+ projectMcpDialect: { kind: 'json', urlKey: 'url', typed: { http: 'http', stdio: 'stdio' }, envRef: 'dollar' },
204
+ install: { brewCask: 'claude-code', winget: 'Anthropic.ClaudeCode' },
205
+ docs: ['https://code.claude.com/docs/en/mcp', 'https://code.claude.com/docs/en/setup'],
206
+ verifiedAt: VERIFIED,
207
+ },
208
+ {
209
+ id: 'vscode',
210
+ name: 'VS Code / GitHub Copilot',
211
+ description: 'VS Code with Copilot agent mode',
212
+ detectCommand: 'code',
213
+ detectApp: 'Visual Studio Code.app',
214
+ configDir: (home) => vscodeUserDir(home, 'Code'),
215
+ globalMcpPath: (home) => path.join(vscodeUserDir(home, 'Code'), 'mcp.json'),
216
+ projectMcpPath: () => path.join('.vscode', 'mcp.json'),
217
+ configFormat: 'json',
218
+ mcpKey: 'servers',
219
+ mcpDialect: { kind: 'json', urlKey: 'url', typed: { http: 'http', stdio: 'stdio' }, envRef: 'vscode' },
220
+ install: { brewCask: 'visual-studio-code', winget: 'Microsoft.VisualStudioCode' },
221
+ docs: ['https://code.visualstudio.com/docs/agents/reference/mcp-configuration', 'https://code.visualstudio.com/docs/setup/linux'],
222
+ verifiedAt: VERIFIED,
223
+ },
224
+ {
225
+ id: 'vscode-insiders',
226
+ name: 'VS Code Insiders',
227
+ description: 'VS Code Insiders build with Copilot agent mode',
228
+ detectCommand: 'code-insiders',
229
+ detectApp: 'Visual Studio Code - Insiders.app',
230
+ configDir: (home) => vscodeUserDir(home, 'Code - Insiders'),
231
+ globalMcpPath: (home) => path.join(vscodeUserDir(home, 'Code - Insiders'), 'mcp.json'),
232
+ projectMcpPath: () => path.join('.vscode', 'mcp.json'),
233
+ configFormat: 'json',
234
+ mcpKey: 'servers',
235
+ mcpDialect: { kind: 'json', urlKey: 'url', typed: { http: 'http', stdio: 'stdio' }, envRef: 'vscode' },
236
+ install: { brewCask: 'visual-studio-code@insiders', winget: 'Microsoft.VisualStudioCode.Insiders' },
237
+ docs: ['https://code.visualstudio.com/docs/agents/reference/mcp-configuration', 'https://code.visualstudio.com/docs/configure/profiles'],
238
+ verifiedAt: VERIFIED,
239
+ },
240
+ {
241
+ id: 'codex',
242
+ name: 'OpenAI Codex',
243
+ description: 'OpenAI\'s coding agent (CLI, IDE extension, and the ChatGPT desktop app share one config)',
244
+ detectCommand: 'codex',
245
+ detectApp: 'ChatGPT.app', // the standalone Codex app was folded into ChatGPT in July 2026
246
+ configDir: codexHome,
247
+ globalMcpPath: (home) => path.join(codexHome(home), 'config.toml'),
248
+ // Project-level config is honoured for trusted projects only.
249
+ projectMcpPath: () => path.join('.codex', 'config.toml'),
250
+ projectConfigFormat: 'toml',
251
+ configFormat: 'toml',
252
+ mcpKey: 'mcp_servers',
253
+ mcpDialect: { kind: 'toml' },
254
+ install: { brewCask: 'codex' },
255
+ docs: ['https://learn.chatgpt.com/docs/config-file/config-reference', 'https://learn.chatgpt.com/docs/extend/mcp?surface=cli'],
256
+ verifiedAt: VERIFIED,
257
+ },
258
+ {
259
+ id: 'gemini',
260
+ name: 'Gemini CLI',
261
+ description: 'Google\'s terminal AI agent (paid API keys and Code Assist licences only)',
262
+ // Since 2026-06-18 Gemini CLI no longer serves free-tier or Google One
263
+ // accounts; Antigravity CLI (`agy`) is the consumer successor. Paths and
264
+ // config format are unchanged and the npm package still ships.
265
+ notice: 'Gemini CLI stopped serving free and Google One accounts on 2026-06-18. Consumer users should pick Antigravity CLI instead.',
266
+ detectCommand: 'gemini',
267
+ configDir: (home) => path.join(home, '.gemini'),
268
+ globalMcpPath: (home) => path.join(home, '.gemini', 'settings.json'),
269
+ projectMcpPath: () => path.join('.gemini', 'settings.json'),
270
+ configFormat: 'json',
271
+ mcpKey: 'mcpServers',
272
+ mcpDialect: { kind: 'json', urlKey: 'httpUrl', envRef: 'dollar' },
273
+ install: {}, // Homebrew formula is deprecated (2026-06-18); npm only
274
+ docs: ['https://geminicli.com/docs/tools/mcp-server/', 'https://developers.googleblog.com/an-important-update-transitioning-gemini-cli-to-antigravity-cli/'],
275
+ verifiedAt: VERIFIED,
276
+ },
277
+ // Antigravity 2.x is three separate installs — the desktop agent app, the IDE
278
+ // (a separate download), and the `agy` CLI — that share one MCP config file at
279
+ // ~/.gemini/config/mcp_config.json. Each keeps its own state dir under ~/.gemini.
280
+ {
281
+ id: 'antigravity',
282
+ name: 'Antigravity',
283
+ description: 'Google\'s desktop agent app (Antigravity 2.0)',
284
+ detectApp: 'Antigravity.app', // ships no shell command
285
+ configDir: (home) => path.join(home, '.gemini', 'antigravity'),
286
+ globalMcpPath: ANTIGRAVITY_MCP,
287
+ configFormat: 'json',
288
+ mcpKey: 'mcpServers',
289
+ mcpDialect: ANTIGRAVITY_DIALECT,
290
+ install: { brewCask: 'antigravity' },
291
+ docs: ANTIGRAVITY_DOCS,
292
+ verifiedAt: VERIFIED,
293
+ },
294
+ {
295
+ id: 'antigravity-ide',
296
+ name: 'Antigravity IDE',
297
+ description: 'Google\'s agent-first AI IDE',
298
+ // The in-app "install agy-ide command in PATH" shim; Homebrew links both names.
299
+ detectCommand: ['agy-ide', 'antigravity-ide'],
300
+ detectApp: 'Antigravity IDE.app',
301
+ detectPaths: (home) => [path.join(home, '.antigravity-ide', 'antigravity-ide', 'bin', 'agy-ide')],
302
+ configDir: (home) => path.join(home, '.gemini', 'antigravity-ide'),
303
+ globalMcpPath: ANTIGRAVITY_MCP,
304
+ projectMcpPath: () => path.join('.agents', 'mcp_config.json'),
305
+ configFormat: 'json',
306
+ mcpKey: 'mcpServers',
307
+ mcpDialect: ANTIGRAVITY_DIALECT,
308
+ install: { brewCask: 'antigravity-ide' },
309
+ docs: ANTIGRAVITY_DOCS,
310
+ verifiedAt: VERIFIED,
311
+ },
312
+ {
313
+ id: 'antigravity-cli',
314
+ name: 'Antigravity CLI',
315
+ description: 'Google\'s terminal AI agent (successor to Gemini CLI)',
316
+ detectCommand: 'agy',
317
+ // The installer drops the binary here; ~/.local/bin is often not on PATH.
318
+ detectPaths: (home) => [
319
+ path.join(home, '.local', 'bin', 'agy'),
320
+ path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'agy', 'bin', 'agy.exe'),
321
+ ],
322
+ configDir: (home) => path.join(home, '.gemini', 'antigravity-cli'),
323
+ globalMcpPath: ANTIGRAVITY_MCP,
324
+ projectMcpPath: () => path.join('.agents', 'mcp_config.json'),
325
+ configFormat: 'json',
326
+ mcpKey: 'mcpServers',
327
+ mcpDialect: ANTIGRAVITY_DIALECT,
328
+ install: { brewCask: 'antigravity-cli' },
329
+ docs: ANTIGRAVITY_DOCS,
330
+ verifiedAt: VERIFIED,
331
+ },
332
+ // Windsurf became Devin Desktop on 2026-06-02 (Cognition); the Cascade agent
333
+ // was removed on 2026-09-08 and Devin Local is the only agent, so the MCP
334
+ // config moved from ~/.codeium/windsurf/ to ~/.config/devin/. Devin still
335
+ // imports the old file on read. The Devin CLI shares the same config files.
336
+ {
337
+ id: 'devin-desktop',
338
+ name: 'Devin Desktop',
339
+ description: 'Cognition\'s agentic IDE (formerly Windsurf)',
340
+ detectCommand: ['devin-desktop', 'windsurf'],
341
+ detectApp: ['Devin.app', 'Windsurf.app'],
342
+ configDir: devinConfigDir,
343
+ globalMcpPath: DEVIN_MCP,
344
+ // Where dxai wrote servers for Windsurf; still scanned by status/cleanup.
345
+ legacyGlobalMcpPaths: (home) => [path.join(home, '.codeium', 'windsurf', 'mcp_config.json')],
346
+ projectMcpPath: () => path.join('.devin', 'mcp_config.json'),
347
+ configFormat: 'json',
348
+ mcpKey: 'mcpServers',
349
+ mcpDialect: DEVIN_DIALECT,
350
+ install: { brewCask: 'devin-desktop', winget: 'CognitionAI.DevinDesktop' },
351
+ docs: DEVIN_DOCS,
352
+ verifiedAt: VERIFIED,
353
+ },
354
+ {
355
+ id: 'devin-cli',
356
+ name: 'Devin CLI',
357
+ description: 'Cognition\'s terminal coding agent',
358
+ detectCommand: 'devin',
359
+ configDir: (home) => path.join(home, '.devin'),
360
+ globalMcpPath: DEVIN_MCP,
361
+ projectMcpPath: () => path.join('.devin', 'mcp_config.json'),
362
+ configFormat: 'json',
363
+ mcpKey: 'mcpServers',
364
+ mcpDialect: DEVIN_DIALECT,
365
+ install: { brewCask: 'devin-cli', winget: 'CognitionAI.DevinCLI' },
366
+ docs: ['https://docs.devin.ai/cli', 'https://docs.devin.ai/cli/extensibility/mcp/configuration'],
367
+ verifiedAt: VERIFIED,
368
+ },
369
+ ];
370
+
371
+ // Former agent ids that profiles, manifests and `--agents` flags may still carry.
372
+ // Resolved by normalizeAgentIds / findAgent so a rename never breaks a user.
373
+ export const AGENT_ID_ALIASES = {
374
+ windsurf: 'devin-desktop',
375
+ };
376
+
377
+ export function normalizeAgentIds(ids) {
378
+ return [...new Set((ids || []).map((id) => AGENT_ID_ALIASES[id] || id))];
379
+ }
380
+
381
+ export function findAgent(id) {
382
+ const resolved = AGENT_ID_ALIASES[id] || id;
383
+ return AGENT_DEFINITIONS.find((a) => a.id === resolved) || null;
384
+ }
385
+
386
+ // Config-key aliases. A catalogue entry may carry a single family-level block
387
+ // (some were authored that way before the family was split into agents, or
388
+ // under a product's former name). Since every member of a family reads the same
389
+ // config file with the same dialect, derivation fans that one block out to each
390
+ // member. An alias may share its name with a real agent id, in which case that
391
+ // agent keeps the block and the other targets receive a copy.
392
+ export const MCP_CONFIG_ALIASES = {
393
+ antigravity: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
394
+ windsurf: ['devin-desktop', 'devin-cli'],
395
+ };
396
+
397
+ function envRefFor(dialect, name) {
398
+ // 'literal' still writes the placeholder — config-writer substitutes the real
399
+ // value from dxai's own environment at write time.
400
+ return dialect.envRef === 'vscode' ? `\${env:${name}}` : `\${${name}}`;
401
+ }
402
+
403
+ // Derive an agent's MCP config block from a server's canonical `transport`.
404
+ // Returns the config object (or, for Codex, a { toml } wrapper), or null when
405
+ // the server has no transport / the agent has no dialect. Pass { project: true }
406
+ // to render for the agent's project-level file (falls back to the global dialect
407
+ // when the agent has no separate one).
408
+ export function renderAgentConfig(agent, server, { project = false } = {}) {
409
+ const transport = server?.transport;
410
+ const dialect = project ? (agent?.projectMcpDialect || agent?.mcpDialect) : agent?.mcpDialect;
411
+ if (!transport || !dialect) return null;
412
+
413
+ const { id } = server;
414
+ const envVars = server.requiresEnv ? Object.keys(server.requiresEnv) : [];
415
+
416
+ switch (dialect.kind) {
417
+ case 'json': {
418
+ if (transport.type === 'http') {
419
+ if (!transport.url) return null;
420
+ const cfg = {};
421
+ if (dialect.typed?.http) cfg.type = dialect.typed.http;
422
+ cfg[dialect.urlKey] = transport.url;
423
+ return cfg;
424
+ }
425
+ if (transport.type === 'stdio') {
426
+ const cfg = {};
427
+ if (dialect.typed?.stdio) cfg.type = dialect.typed.stdio;
428
+ cfg.command = transport.command;
429
+ cfg.args = [...(transport.args || [])];
430
+ if (envVars.length) {
431
+ cfg.env = {};
432
+ for (const v of envVars) cfg.env[v] = envRefFor(dialect, v);
433
+ }
434
+ return cfg;
435
+ }
436
+ return null;
437
+ }
438
+ case 'claude-cli': {
439
+ // `--env` must not be immediately followed by the server name (the CLI would
440
+ // read the name as another KEY=value pair), so `--transport` sits between.
441
+ const head = ['mcp', 'add', '--scope', 'user'];
442
+ for (const v of envVars) head.push('--env', `${v}=\${${v}}`);
443
+ if (transport.type === 'http') {
444
+ return { command: 'claude', args: [...head, '--transport', 'http', id, transport.url] };
445
+ }
446
+ if (transport.type === 'stdio') {
447
+ return { command: 'claude', args: [...head, '--transport', 'stdio', id, '--', transport.command, ...(transport.args || [])] };
448
+ }
449
+ return null;
450
+ }
451
+ case 'toml': {
452
+ if (transport.type === 'http') {
453
+ return { toml: `[mcp_servers.${id}]\nurl = "${transport.url}"` };
454
+ }
455
+ if (transport.type === 'stdio') {
456
+ const argsList = (transport.args || []).map((a) => `"${a}"`).join(', ');
457
+ let toml = `[mcp_servers.${id}]\ncommand = "${transport.command}"\nargs = [${argsList}]`;
458
+ // Codex forwards named variables from its own environment; `env` values
459
+ // are literal and never interpolated.
460
+ if (envVars.length) toml += `\nenv_vars = [${envVars.map((v) => `"${v}"`).join(', ')}]`;
461
+ return { toml };
462
+ }
463
+ return null;
464
+ }
465
+ default:
466
+ return null;
467
+ }
468
+ }
469
+
470
+ export function detectAgents(home, { applicationsDir = DEFAULT_APPLICATIONS_DIR } = {}) {
471
+ const agents = AGENT_DEFINITIONS.map((def) => {
472
+ const commandFound = toList(def.detectCommand).find((cmd) => commandExists(cmd)) || null;
473
+ const pathFound = commandFound ? null : findInstalledPath(def, home);
474
+ const appFound = toList(def.detectApp).find((app) => appExists(app, applicationsDir)) || null;
475
+ const configExists = fs.existsSync(def.configDir(home));
476
+ const installed = Boolean(commandFound || pathFound || appFound);
477
+ let version = null;
478
+ if (commandFound) version = getVersion(commandFound);
479
+ else if (pathFound) version = getVersionAtPath(pathFound);
480
+ else if (appFound) version = appVersion(appFound, applicationsDir);
481
+ return {
482
+ ...def,
483
+ installed,
484
+ configExists,
485
+ version,
486
+ };
487
+ });
488
+ return agents;
489
+ }
490
+
491
+ export function printDetectionResults(osInfo, prereqs, agents) {
492
+ successMsg(`${osInfo.name} ${osInfo.arch}`);
493
+
494
+ if (prereqs.node.installed) {
495
+ successMsg(`Node.js ${prereqs.node.version}`);
496
+ } else {
497
+ warnMsg('Node.js not found (required)');
498
+ }
499
+ if (prereqs.git.installed) {
500
+ successMsg(`Git ${prereqs.git.version}`);
501
+ } else {
502
+ warnMsg('Git not found');
503
+ }
504
+ if (prereqs.python.installed) {
505
+ successMsg(`Python ${prereqs.python.version}`);
506
+ }
507
+
508
+ const found = agents.filter((a) => a.installed);
509
+ if (found.length > 0) {
510
+ successMsg(`Detected: ${found.map((a) => a.name).join(', ')}`);
511
+ } else {
512
+ warnMsg('No AI agents detected — you can still select which ones to configure');
513
+ }
514
+ for (const a of found) {
515
+ if (a.notice) warnMsg(`${a.name}: ${a.notice}`);
516
+ }
517
+ }
518
+
519
+ // ── Automation Tool Detection ──
520
+ export function detectAutomationTools(toolRegistry) {
521
+ return toolRegistry.map((tool) => ({
522
+ ...tool,
523
+ installed: commandExists(tool.detectCommand),
524
+ }));
525
+ }
526
+
527
+ // ── Agent Install Commands ──
528
+ // One line per OS, shown when a selected agent is not detected. Keep these to
529
+ // the vendor's documented primary method; `install.brewCask` / `install.winget`
530
+ // on the definition are the machine-checkable ids the health check verifies.
531
+ export const INSTALL_COMMANDS = {
532
+ 'cursor': {
533
+ macOS: 'brew install --cask cursor',
534
+ Linux: 'Add the Cursor apt/dnf repo, then `sudo apt install cursor` — https://cursor.com/docs/get-started/installation',
535
+ Windows: 'winget install Anysphere.Cursor',
536
+ },
537
+ 'claude-code': {
538
+ macOS: 'brew install --cask claude-code',
539
+ Linux: 'curl -fsSL https://claude.ai/install.sh | bash',
540
+ Windows: 'winget install Anthropic.ClaudeCode',
541
+ },
542
+ 'vscode': {
543
+ macOS: 'brew install --cask visual-studio-code',
544
+ Linux: 'sudo snap install --classic code',
545
+ Windows: 'winget install Microsoft.VisualStudioCode',
546
+ },
547
+ 'vscode-insiders': {
548
+ macOS: 'brew install --cask visual-studio-code@insiders',
549
+ Linux: 'sudo snap install --classic code-insiders',
550
+ Windows: 'winget install Microsoft.VisualStudioCode.Insiders',
551
+ },
552
+ 'codex': {
553
+ macOS: 'brew install --cask codex',
554
+ Linux: 'npm install -g @openai/codex',
555
+ Windows: 'npm install -g @openai/codex',
556
+ },
557
+ 'gemini': {
558
+ macOS: 'npm install -g @google/gemini-cli',
559
+ Linux: 'npm install -g @google/gemini-cli',
560
+ Windows: 'npm install -g @google/gemini-cli',
561
+ },
562
+ 'antigravity': {
563
+ macOS: 'brew install --cask antigravity',
564
+ Linux: 'Download from https://antigravity.google/download',
565
+ Windows: 'Download from https://antigravity.google/download',
566
+ },
567
+ 'antigravity-ide': {
568
+ macOS: 'brew install --cask antigravity-ide',
569
+ Linux: 'Download from https://antigravity.google/download',
570
+ Windows: 'Download from https://antigravity.google/download',
571
+ },
572
+ 'antigravity-cli': {
573
+ macOS: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
574
+ Linux: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
575
+ Windows: 'irm https://antigravity.google/cli/install.ps1 | iex',
576
+ },
577
+ 'devin-desktop': {
578
+ macOS: 'brew install --cask devin-desktop',
579
+ Linux: 'Add the Devin apt/yum repo, then `sudo apt install devin-desktop` — https://docs.devin.ai/desktop/getting-started',
580
+ Windows: 'winget install CognitionAI.DevinDesktop',
581
+ },
582
+ 'devin-cli': {
583
+ macOS: 'brew install --cask devin-cli',
584
+ Linux: 'curl -fsSL https://cli.devin.ai/install.sh | bash',
585
+ Windows: 'irm https://static.devin.ai/cli/setup.ps1 | iex',
586
+ },
587
+ };
@@ -0,0 +1,35 @@
1
+ // Atomic file writes — write to a unique temp sibling, then rename over the
2
+ // target. A rename on the same filesystem is atomic, so a crash mid-write can
3
+ // never leave a user's config/manifest truncated: either the old file survives
4
+ // intact or the new one is fully in place.
5
+
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+
9
+ // Monotonic-ish suffix so concurrent writes in the same process don't collide.
10
+ // (Date.now/Math.random are avoided elsewhere in the codebase, but here we only
11
+ // need uniqueness for a transient temp name, and process.hrtime is monotonic.)
12
+ let counter = 0;
13
+ function tempPath(filePath) {
14
+ const unique = `${process.pid}.${counter++}.${process.hrtime.bigint()}`;
15
+ return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${unique}.tmp`);
16
+ }
17
+
18
+ // Atomically write a string to filePath. Optional mode sets file permissions
19
+ // (e.g. 0o600 for files that may carry secret references).
20
+ export function writeFileAtomic(filePath, data, { mode } = {}) {
21
+ fs.ensureDirSync(path.dirname(filePath));
22
+ const tmp = tempPath(filePath);
23
+ try {
24
+ fs.writeFileSync(tmp, data, mode ? { mode } : undefined);
25
+ fs.moveSync(tmp, filePath, { overwrite: true });
26
+ if (mode !== undefined) fs.chmodSync(filePath, mode);
27
+ } finally {
28
+ if (fs.existsSync(tmp)) fs.removeSync(tmp);
29
+ }
30
+ }
31
+
32
+ // Atomically write an object as pretty JSON.
33
+ export function writeJsonAtomic(filePath, obj, { spaces = 2, mode } = {}) {
34
+ writeFileAtomic(filePath, JSON.stringify(obj, null, spaces) + '\n', { mode });
35
+ }