From d41b0b31e0f4b1a9bd3b0c3369556c528d6e6686 Mon Sep 17 00:00:00 2001 From: szdziedzic Date: Wed, 20 May 2026 20:21:59 +0200 Subject: [PATCH] [eas-cli] Detect argent MCP config and improve argent session instructions --- packages/eas-cli/package.json | 2 + .../eas-cli/src/commands/simulator/start.ts | 131 +++- .../src/simulator/__tests__/utils-test.ts | 481 ++++++++++++++ packages/eas-cli/src/simulator/utils.ts | 586 +++++++++++++++++- yarn.lock | 16 + 5 files changed, 1186 insertions(+), 30 deletions(-) create mode 100644 packages/eas-cli/src/simulator/__tests__/utils-test.ts diff --git a/packages/eas-cli/package.json b/packages/eas-cli/package.json index 0ef05a2a8f..067656965b 100644 --- a/packages/eas-cli/package.json +++ b/packages/eas-cli/package.json @@ -98,6 +98,7 @@ "invariant": "^2.2.2", "jks-js": "1.1.0", "joi": "17.11.0", + "jsonc-parser": "3.3.1", "keychain": "1.5.0", "log-symbols": "4.1.0", "mime": "3.0.0", @@ -119,6 +120,7 @@ "semver": "7.5.4", "set-interval-async": "3.0.3", "slash": "3.0.0", + "smol-toml": "1.6.1", "tar": "7.5.7", "tar-stream": "3.1.7", "terminal-link": "2.1.1", diff --git a/packages/eas-cli/src/commands/simulator/start.ts b/packages/eas-cli/src/commands/simulator/start.ts index d46b9a47ab..18b64f6b32 100644 --- a/packages/eas-cli/src/commands/simulator/start.ts +++ b/packages/eas-cli/src/commands/simulator/start.ts @@ -17,9 +17,14 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery'; import Log, { link } from '../../log'; import { ora } from '../../ora'; +import { promptAsync } from '../../prompts'; import { + ArgentEditPlan, DeviceRunSessionRemoteConfig, + applyArgentEdits, + captureWritableArgentEdits, formatRemoteSessionInstructions, + revertArgentEdits, } from '../../simulator/utils'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; import { sleepAsync } from '../../utils/promise'; @@ -168,22 +173,47 @@ export default class SimulatorStart extends EasCommand { return; } - Log.newLine(); - Log.log(formatRemoteSessionInstructions(remoteConfig)); - Log.newLine(); + let appliedArgentEdits: ArgentEditPlan[] = []; + try { + Log.newLine(); - if (nonInteractive) { - Log.log( - `When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}` - ); - return; - } + if (!nonInteractive && remoteConfig.__typename === 'ArgentRunSessionRemoteConfig') { + appliedArgentEdits = await promptAndApplyArgentEditsAsync({ + toolsUrl: remoteConfig.toolsUrl, + }); + } - await waitForSessionEndOrInterruptAsync({ - graphqlClient, - deviceRunSessionId, - jobRunUrl, - }); + if (appliedArgentEdits.length > 0) { + printArgentEditSummary(appliedArgentEdits, remoteConfig); + } else { + Log.log(formatRemoteSessionInstructions(remoteConfig)); + } + Log.newLine(); + + if (nonInteractive) { + Log.log( + `When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}` + ); + return; + } + + await waitForSessionEndOrInterruptAsync({ + graphqlClient, + deviceRunSessionId, + jobRunUrl, + }); + } finally { + if (appliedArgentEdits.length > 0) { + Log.log( + `Reverting ARGENT_TOOLS_URL in ${appliedArgentEdits.length} MCP config file${appliedArgentEdits.length === 1 ? '' : 's'}...` + ); + revertArgentEdits(appliedArgentEdits, (filePath, err) => { + Log.warn( + `Failed to revert ${filePath}: ${err instanceof Error ? err.message : String(err)}` + ); + }); + } + } } } @@ -274,6 +304,79 @@ async function waitForSessionEndOrInterruptAsync({ } } +async function promptAndApplyArgentEditsAsync({ + toolsUrl, +}: { + toolsUrl: string; +}): Promise { + const candidates = captureWritableArgentEdits(); + if (candidates.length === 0) { + return []; + } + + Log.log('🚀 Argent session is live.'); + Log.newLine(); + Log.log( + `Detected argent in ${candidates.length} writable MCP config file${candidates.length === 1 ? '' : 's'}. Proposed edits:` + ); + Log.newLine(); + for (const edit of candidates) { + Log.log(` • ${edit.editorLabel} (${edit.scope}): ${edit.filePath}`); + if (edit.previousValue !== null && edit.previousValue !== toolsUrl) { + Log.log(` - ARGENT_TOOLS_URL = "${edit.previousValue}"`); + } + Log.log(` + ARGENT_TOOLS_URL = "${toolsUrl}"`); + } + Log.newLine(); + + const { selectedPaths } = await promptAsync({ + type: 'multiselect', + name: 'selectedPaths', + message: 'Apply these edits? (will revert when the session ends)', + instructions: false, + choices: candidates.map(edit => ({ + title: `${edit.editorLabel} (${edit.scope}): ${edit.filePath}`, + value: edit.filePath, + selected: true, + })), + }); + + const selectedSet = new Set((selectedPaths as string[] | undefined) ?? []); + const selected = candidates.filter(candidate => selectedSet.has(candidate.filePath)); + if (selected.length === 0) { + return []; + } + + try { + applyArgentEdits(selected, toolsUrl); + } catch (err) { + Log.warn(`Failed to apply argent edits: ${err instanceof Error ? err.message : String(err)}`); + return []; + } + return selected; +} + +function printArgentEditSummary( + applied: readonly ArgentEditPlan[], + remoteConfig: DeviceRunSessionRemoteConfig +): void { + Log.log(`✅ Updated ARGENT_TOOLS_URL in:`); + for (const edit of applied) { + Log.log(` • ${edit.editorLabel} (${edit.scope}): ${edit.filePath}`); + } + Log.newLine(); + Log.log('Restart your editor (or reload MCP servers — e.g. /mcp → reconnect in Claude Code)'); + Log.log('so it re-reads the config. Edits revert automatically when this session ends.'); + Log.log('If anything goes sideways, `npx -y @swmansion/argent init` resets the config.'); + + if (remoteConfig.__typename === 'ArgentRunSessionRemoteConfig' && remoteConfig.webPreviewUrl) { + Log.newLine(); + Log.log('🌐 Open the following URL in your browser to preview the simulator:'); + Log.newLine(); + Log.log(remoteConfig.webPreviewUrl); + } +} + async function ensureDeviceRunSessionStoppedSafelyAsync( graphqlClient: ExpoGraphqlClient, deviceRunSessionId: string diff --git a/packages/eas-cli/src/simulator/__tests__/utils-test.ts b/packages/eas-cli/src/simulator/__tests__/utils-test.ts new file mode 100644 index 0000000000..a44c49a3df --- /dev/null +++ b/packages/eas-cli/src/simulator/__tests__/utils-test.ts @@ -0,0 +1,481 @@ +import { vol } from 'memfs'; +import os from 'node:os'; +import YAML from 'yaml'; + +import { applyArgentEdits, captureWritableArgentEdits, revertArgentEdits } from '../utils'; + +jest.mock('fs'); +// __mocks__/fs.ts only intercepts the bare `'fs'` specifier; the source uses +// `import fs from 'node:fs'`, so we have to redirect that to the mocked one +// here as well. Keep both lines. +jest.mock('node:fs', () => require('fs')); +jest.mock('../../log'); + +const HOME = '/home/test'; +const CWD = '/home/test/project'; + +beforeEach(() => { + vol.reset(); + jest.spyOn(os, 'homedir').mockReturnValue(HOME); + jest.spyOn(process, 'cwd').mockReturnValue(CWD); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function readFile(filePath: string): string { + return vol.readFileSync(filePath, 'utf8') as string; +} + +describe('captureWritableArgentEdits', () => { + it('returns empty when no MCP configs exist', () => { + expect(captureWritableArgentEdits()).toEqual([]); + }); + + it('captures a Claude Code project JSON config', () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify( + { + mcpServers: { + argent: { command: 'argent', args: ['mcp'], env: {} }, + }, + }, + null, + 2 + ), + }); + + expect(captureWritableArgentEdits()).toEqual([ + { + filePath: `${CWD}/.mcp.json`, + editorLabel: 'Claude Code', + scope: 'project', + format: 'json', + toolsUrlKeyPath: ['mcpServers', 'argent', 'env', 'ARGENT_TOOLS_URL'], + previousValue: null, + }, + ]); + }); + + it('captures the previousValue when ARGENT_TOOLS_URL is already set', () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify({ + mcpServers: { + argent: { env: { ARGENT_TOOLS_URL: 'https://old.example.com' } }, + }, + }), + }); + + const edits = captureWritableArgentEdits(); + expect(edits[0].previousValue).toBe('https://old.example.com'); + }); + + it('uses servers.argent (not mcpServers.argent) for VS Code configs', () => { + vol.fromJSON({ + [`${CWD}/.vscode/mcp.json`]: JSON.stringify({ + servers: { argent: { env: {} } }, + }), + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + expect(edits[0].editorLabel).toBe('VS Code'); + expect(edits[0].toolsUrlKeyPath).toEqual(['servers', 'argent', 'env', 'ARGENT_TOOLS_URL']); + }); + + it('captures a Zed JSONC config with surrounding comments', () => { + vol.fromJSON({ + [`${HOME}/.config/zed/settings.json`]: `// User settings +{ + // MCP servers + "context_servers": { + "argent": { "command": "argent", "args": ["mcp"], "env": {} } + } +} +`, + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ + editorLabel: 'Zed', + scope: 'global', + format: 'jsonc', + toolsUrlKeyPath: ['context_servers', 'argent', 'env', 'ARGENT_TOOLS_URL'], + previousValue: null, + }); + }); + + it('uses mcp.argent.environment (not env) for opencode configs', () => { + vol.fromJSON({ + [`${CWD}/opencode.json`]: JSON.stringify({ + mcp: { + argent: { + type: 'local', + command: ['argent', 'mcp'], + enabled: true, + environment: { ARGENT_MCP_LOG: '/tmp/foo' }, + }, + }, + }), + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ + editorLabel: 'opencode', + format: 'jsonc', + toolsUrlKeyPath: ['mcp', 'argent', 'environment', 'ARGENT_TOOLS_URL'], + }); + }); + + it('captures a Codex TOML config', () => { + vol.fromJSON({ + [`${HOME}/.codex/config.toml`]: `[mcp_servers.argent] +command = "argent" +args = ["mcp"] +env = { ARGENT_MCP_LOG = "/tmp/foo" } +`, + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ + editorLabel: 'Codex', + scope: 'global', + format: 'toml', + toolsUrlKeyPath: ['mcp_servers', 'argent', 'env', 'ARGENT_TOOLS_URL'], + }); + }); + + it('captures a Hermes YAML config', () => { + vol.fromJSON({ + [`${HOME}/.hermes/config.yaml`]: `mcp_servers: + argent: + command: argent + args: + - mcp + env: + ARGENT_TOOLS_URL: https://existing.example.com +`, + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + expect(edits[0]).toMatchObject({ + editorLabel: 'Hermes', + format: 'yaml', + previousValue: 'https://existing.example.com', + }); + }); + + it('skips files whose argent entry is absent', () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify({ mcpServers: { other: {} } }), + }); + + expect(captureWritableArgentEdits()).toEqual([]); + }); + + it('skips files with unparseable JSON', () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: 'not json at all', + }); + + expect(captureWritableArgentEdits()).toEqual([]); + }); + + it('dedupes opencode by (editor, scope) when multiple filenames coexist', () => { + const opencodeBody = JSON.stringify({ + mcp: { argent: { environment: {} } }, + }); + vol.fromJSON({ + [`${CWD}/opencode.jsonc`]: opencodeBody, + [`${CWD}/opencode.json`]: opencodeBody, + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + // Argent's installer prioritizes opencode.jsonc over opencode.json. + expect(edits[0].filePath).toBe(`${CWD}/opencode.jsonc`); + }); + + it('reports both project and global Claude Code configs independently', () => { + const body = JSON.stringify({ mcpServers: { argent: { env: {} } } }); + vol.fromJSON({ + [`${CWD}/.mcp.json`]: body, + [`${HOME}/.claude.json`]: body, + }); + + const edits = captureWritableArgentEdits(); + expect(edits.map(e => e.scope).sort()).toEqual(['global', 'project']); + }); +}); + +describe('applyArgentEdits + revertArgentEdits', () => { + const NEW_URL = 'https://new.trycloudflare.com'; + + it('writes to JSON preserving 2-space indent and trailing newline', () => { + const original = + JSON.stringify( + { + mcpServers: { + argent: { command: 'argent', args: ['mcp'], env: {} }, + }, + }, + null, + 2 + ) + '\n'; + vol.fromJSON({ [`${CWD}/.mcp.json`]: original }); + + const edits = captureWritableArgentEdits(); + applyArgentEdits(edits, NEW_URL); + + const written = readFile(`${CWD}/.mcp.json`); + expect(JSON.parse(written).mcpServers.argent.env.ARGENT_TOOLS_URL).toBe(NEW_URL); + // Preserves 2-space indent and trailing newline. + expect(written).toMatch(/^\{\n {2}"mcpServers"/); + expect(written.endsWith('\n')).toBe(true); + }); + + it('preserves JSONC comments around the touched key', () => { + const original = `// Project Zed settings +{ + // The MCP servers section + "context_servers": { + /* argent — managed by argent init */ + "argent": { + "command": "argent", + "args": ["mcp"], + "env": {} // log file lives here normally + } + } +} +`; + vol.fromJSON({ [`${CWD}/.zed/settings.json`]: original }); + + const edits = captureWritableArgentEdits(); + applyArgentEdits(edits, NEW_URL); + + const written = readFile(`${CWD}/.zed/settings.json`); + expect(written).toContain('// Project Zed settings'); + expect(written).toContain('// The MCP servers section'); + expect(written).toContain('/* argent — managed by argent init */'); + expect(written).toContain('// log file lives here normally'); + expect(written).toContain(`"ARGENT_TOOLS_URL": "${NEW_URL}"`); + }); + + it('preserves YAML comments around the touched key', () => { + const original = `# Hermes config +mcp_servers: + # argent MCP server + argent: + command: argent + args: + - mcp + env: + ARGENT_MCP_LOG: /tmp/argent.log # log path +`; + vol.fromJSON({ [`${HOME}/.hermes/config.yaml`]: original }); + + const edits = captureWritableArgentEdits(); + applyArgentEdits(edits, NEW_URL); + + const written = readFile(`${HOME}/.hermes/config.yaml`); + expect(written).toContain('# Hermes config'); + expect(written).toContain('# argent MCP server'); + expect(written).toContain('# log path'); + expect(written).toContain(NEW_URL); + }); + + it('handles a YAML config whose argent entry has no env block yet', () => { + // Hermes hits the YAML driver, which uses Document.setIn — verify it can + // create the intermediate env block on the fly (analogous to JSON's + // setNestedValue but exercising the yaml lib's behavior). + const original = `mcp_servers: + argent: + command: argent + args: + - mcp +`; + vol.fromJSON({ [`${HOME}/.hermes/config.yaml`]: original }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(1); + expect(edits[0].previousValue).toBeNull(); + + applyArgentEdits(edits, NEW_URL); + const afterApply = YAML.parse(readFile(`${HOME}/.hermes/config.yaml`)); + expect(afterApply.mcp_servers.argent.env.ARGENT_TOOLS_URL).toBe(NEW_URL); + + revertArgentEdits(edits); + const afterRevert = YAML.parse(readFile(`${HOME}/.hermes/config.yaml`)); + // The key we set should be gone after revert (the env block itself may + // linger as an empty map — that's harmless and not what we're testing). + expect(afterRevert.mcp_servers.argent?.env?.ARGENT_TOOLS_URL).toBeUndefined(); + }); + + it('writes the new value to a Codex TOML config (round-trip is lossy)', () => { + const original = `[mcp_servers.argent] +command = "argent" +args = ["mcp"] +env = { ARGENT_MCP_LOG = "/tmp/log" } +`; + vol.fromJSON({ [`${HOME}/.codex/config.toml`]: original }); + + const edits = captureWritableArgentEdits(); + applyArgentEdits(edits, NEW_URL); + + const written = readFile(`${HOME}/.codex/config.toml`); + expect(written).toContain(NEW_URL); + expect(written).toContain('ARGENT_TOOLS_URL'); + }); + + it('reverts back to null (removes the key) when there was no previous value', () => { + const original = JSON.stringify({ mcpServers: { argent: { env: {} } } }, null, 2); + vol.fromJSON({ [`${CWD}/.mcp.json`]: original }); + + const edits = captureWritableArgentEdits(); + expect(edits[0].previousValue).toBeNull(); + + applyArgentEdits(edits, NEW_URL); + revertArgentEdits(edits); + + const after = JSON.parse(readFile(`${CWD}/.mcp.json`)); + expect(after.mcpServers.argent.env).not.toHaveProperty('ARGENT_TOOLS_URL'); + }); + + it('reverts back to the prior string when ARGENT_TOOLS_URL was already set', () => { + const PREVIOUS_URL = 'https://old.trycloudflare.com'; + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify({ + mcpServers: { argent: { env: { ARGENT_TOOLS_URL: PREVIOUS_URL } } }, + }), + }); + + const edits = captureWritableArgentEdits(); + expect(edits[0].previousValue).toBe(PREVIOUS_URL); + + applyArgentEdits(edits, NEW_URL); + expect(JSON.parse(readFile(`${CWD}/.mcp.json`)).mcpServers.argent.env.ARGENT_TOOLS_URL).toBe( + NEW_URL + ); + + revertArgentEdits(edits); + expect(JSON.parse(readFile(`${CWD}/.mcp.json`)).mcpServers.argent.env.ARGENT_TOOLS_URL).toBe( + PREVIOUS_URL + ); + }); + + it('applies multiple edits and reverts each independently', () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify({ + mcpServers: { argent: { env: { ARGENT_TOOLS_URL: 'https://prev.example.com' } } }, + }), + [`${HOME}/.cursor/mcp.json`]: JSON.stringify({ + mcpServers: { argent: { env: {} } }, + }), + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(2); + applyArgentEdits(edits, NEW_URL); + + expect(JSON.parse(readFile(`${CWD}/.mcp.json`)).mcpServers.argent.env.ARGENT_TOOLS_URL).toBe( + NEW_URL + ); + expect( + JSON.parse(readFile(`${HOME}/.cursor/mcp.json`)).mcpServers.argent.env.ARGENT_TOOLS_URL + ).toBe(NEW_URL); + + revertArgentEdits(edits); + + expect(JSON.parse(readFile(`${CWD}/.mcp.json`)).mcpServers.argent.env.ARGENT_TOOLS_URL).toBe( + 'https://prev.example.com' + ); + expect( + JSON.parse(readFile(`${HOME}/.cursor/mcp.json`)).mcpServers.argent.env + ).not.toHaveProperty('ARGENT_TOOLS_URL'); + }); + + it("revert is a no-op when the file's argent entry no longer has env (user moved it)", () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify({ + mcpServers: { argent: { env: {} } }, + }), + }); + const edits = captureWritableArgentEdits(); + applyArgentEdits(edits, NEW_URL); + + // Simulate the user deleting the env block between apply and revert. + vol.writeFileSync(`${CWD}/.mcp.json`, JSON.stringify({ mcpServers: { argent: {} } })); + + expect(() => revertArgentEdits(edits)).not.toThrow(); + expect(JSON.parse(readFile(`${CWD}/.mcp.json`))).toEqual({ + mcpServers: { argent: {} }, + }); + }); + + it('rolls back already-written edits when a later write fails', () => { + const claudeOriginal = JSON.stringify({ mcpServers: { argent: { env: {} } } }, null, 2); + const cursorOriginal = JSON.stringify({ mcpServers: { argent: { env: {} } } }, null, 2); + vol.fromJSON({ + [`${CWD}/.mcp.json`]: claudeOriginal, + [`${HOME}/.cursor/mcp.json`]: cursorOriginal, + }); + + const edits = captureWritableArgentEdits(); + expect(edits).toHaveLength(2); + + // Make the SECOND writeFileSync throw. The first edit applies; the + // catch-and-rethrow path in applyArgentEdits then has to revert the first + // one before the throw escapes. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fsMod = require('node:fs') as typeof import('node:fs'); + const realWrite = fsMod.writeFileSync.bind(fsMod); + let writeCount = 0; + jest.spyOn(fsMod, 'writeFileSync').mockImplementation(((path: string, data: string) => { + writeCount += 1; + if (writeCount === 2) { + throw new Error('disk full'); + } + realWrite(path, data); + }) as typeof fsMod.writeFileSync); + + expect(() => applyArgentEdits(edits, NEW_URL)).toThrow('disk full'); + + // Both files should look like they did before the apply ran. + expect(JSON.parse(readFile(`${CWD}/.mcp.json`))).toEqual(JSON.parse(claudeOriginal)); + expect(JSON.parse(readFile(`${HOME}/.cursor/mcp.json`))).toEqual(JSON.parse(cursorOriginal)); + }); + + it('routes per-file failures to the onError callback and keeps going', () => { + vol.fromJSON({ + [`${CWD}/.mcp.json`]: JSON.stringify({ + mcpServers: { argent: { env: {} } }, + }), + [`${HOME}/.cursor/mcp.json`]: JSON.stringify({ + mcpServers: { argent: { env: {} } }, + }), + }); + + const edits = captureWritableArgentEdits(); + applyArgentEdits(edits, NEW_URL); + + // Drop one file before revert to force a failure on that path. + vol.unlinkSync(`${CWD}/.mcp.json`); + + const errors: Array<{ filePath: string; error: unknown }> = []; + revertArgentEdits(edits, (filePath, error) => { + errors.push({ filePath, error }); + }); + + expect(errors).toHaveLength(1); + expect(errors[0].filePath).toBe(`${CWD}/.mcp.json`); + // The other file still reverted cleanly. + expect( + JSON.parse(readFile(`${HOME}/.cursor/mcp.json`)).mcpServers.argent.env + ).not.toHaveProperty('ARGENT_TOOLS_URL'); + }); +}); diff --git a/packages/eas-cli/src/simulator/utils.ts b/packages/eas-cli/src/simulator/utils.ts index 34665d960b..5ba698f4c0 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -1,8 +1,576 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { applyEdits, modify, parse as parseJsonc } from 'jsonc-parser'; +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; +import YAML from 'yaml'; + import { DeviceRunSessionByIdQuery } from '../graphql/generated'; +import Log from '../log'; type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId']; export type DeviceRunSessionRemoteConfig = NonNullable; +type ArgentConfigFormat = 'json' | 'jsonc' | 'yaml' | 'toml'; + +// Single source of truth for argent's MCP server identifier. Used in every +// candidate's keyPath; if argent ever renames the entry, this is the only +// constant that needs updating. +const MCP_SERVER_KEY = 'argent'; + +type ArgentMcpConfigLocation = { + filePath: string; + editorLabel: string; + scope: 'project' | 'global'; +}; + +type ArgentMcpConfigCandidate = { + filePath: string; + editorLabel: string; + scope: 'project' | 'global'; + format: ArgentConfigFormat; + // Path within the parsed config to the argent server entry. + keyPath: readonly string[]; + // Name of the env block on the argent entry. Usually 'env'; opencode uses + // 'environment'. + envKeyName: string; +}; + +// Mirrors the adapter set in @swmansion/argent's installer (packages/argent- +// installer/src/mcp-configs.ts). Update both together if argent adds an +// editor or changes a path / key / format. +function buildArgentMcpConfigCandidates(): ArgentMcpConfigCandidate[] { + const cwd = process.cwd(); + const home = os.homedir(); + return [ + // Cursor — JSON, mcpServers.argent.env + { + filePath: path.join(cwd, '.cursor', 'mcp.json'), + editorLabel: 'Cursor', + scope: 'project', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + { + filePath: path.join(home, '.cursor', 'mcp.json'), + editorLabel: 'Cursor', + scope: 'global', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // Claude Code — JSON, mcpServers.argent.env + { + filePath: path.join(cwd, '.mcp.json'), + editorLabel: 'Claude Code', + scope: 'project', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + { + filePath: path.join(home, '.claude.json'), + editorLabel: 'Claude Code', + scope: 'global', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // VS Code — JSON, servers.argent.env (no global path) + { + filePath: path.join(cwd, '.vscode', 'mcp.json'), + editorLabel: 'VS Code', + scope: 'project', + format: 'json', + keyPath: ['servers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // Windsurf — JSON, mcpServers.argent.env (global only) + { + filePath: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), + editorLabel: 'Windsurf', + scope: 'global', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // Zed — JSONC, context_servers.argent.env + { + filePath: path.join(cwd, '.zed', 'settings.json'), + editorLabel: 'Zed', + scope: 'project', + format: 'jsonc', + keyPath: ['context_servers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + { + filePath: path.join(home, '.config', 'zed', 'settings.json'), + editorLabel: 'Zed', + scope: 'global', + format: 'jsonc', + keyPath: ['context_servers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // Gemini — JSON, mcpServers.argent.env + { + filePath: path.join(cwd, '.gemini', 'settings.json'), + editorLabel: 'Gemini', + scope: 'project', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + { + filePath: path.join(home, '.gemini', 'settings.json'), + editorLabel: 'Gemini', + scope: 'global', + format: 'json', + keyPath: ['mcpServers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // Codex — TOML, mcp_servers.argent.env. NOTE: smol-toml round-trips drop + // user comments and reorder keys — argent's installer has the same + // limitation, so we accept it. + { + filePath: path.join(cwd, '.codex', 'config.toml'), + editorLabel: 'Codex', + scope: 'project', + format: 'toml', + keyPath: ['mcp_servers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + { + filePath: path.join(home, '.codex', 'config.toml'), + editorLabel: 'Codex', + scope: 'global', + format: 'toml', + keyPath: ['mcp_servers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // Hermes — YAML, mcp_servers.argent.env (global only). Document API + // preserves comments + formatting. + { + filePath: path.join(home, '.hermes', 'config.yaml'), + editorLabel: 'Hermes', + scope: 'global', + format: 'yaml', + keyPath: ['mcp_servers', MCP_SERVER_KEY], + envKeyName: 'env', + }, + // opencode — JSONC, mcp.argent.environment (note: 'environment', not + // 'env'). Multiple filename candidates per scope; argent's installer + // picks the first existing one. We list them all and dedupe by + // (editor, scope) at the call site. + { + filePath: path.join(cwd, 'opencode.jsonc'), + editorLabel: 'opencode', + scope: 'project', + format: 'jsonc', + keyPath: ['mcp', MCP_SERVER_KEY], + envKeyName: 'environment', + }, + { + filePath: path.join(cwd, 'opencode.json'), + editorLabel: 'opencode', + scope: 'project', + format: 'jsonc', + keyPath: ['mcp', MCP_SERVER_KEY], + envKeyName: 'environment', + }, + { + filePath: path.join(home, '.config', 'opencode', 'opencode.jsonc'), + editorLabel: 'opencode', + scope: 'global', + format: 'jsonc', + keyPath: ['mcp', MCP_SERVER_KEY], + envKeyName: 'environment', + }, + { + filePath: path.join(home, '.config', 'opencode', 'opencode.json'), + editorLabel: 'opencode', + scope: 'global', + format: 'jsonc', + keyPath: ['mcp', MCP_SERVER_KEY], + envKeyName: 'environment', + }, + { + filePath: path.join(home, '.config', 'opencode', 'config.json'), + editorLabel: 'opencode', + scope: 'global', + format: 'jsonc', + keyPath: ['mcp', MCP_SERVER_KEY], + envKeyName: 'environment', + }, + ]; +} + +// ── Format drivers ──────────────────────────────────────────────────────────── +// Each driver knows how to inspect / read / write / delete a value at a key +// path inside one config format. JSON, JSONC, and YAML round-trips preserve +// user formatting + comments; TOML round-trips do not (smol-toml is a +// value-preserving but not text-preserving parser, matching argent's own +// installer behavior). + +type FormatDriver = { + hasKeyPath(raw: string, keyPath: readonly string[]): boolean; + readString(raw: string, keyPath: readonly string[]): string | null; + writeString(raw: string, keyPath: readonly string[], value: string): string; + removeKey(raw: string, keyPath: readonly string[]): string; +}; + +const JSON_DRIVER: FormatDriver = { + hasKeyPath: (raw, keyPath) => hasNestedKey(JSON.parse(raw), keyPath), + readString: (raw, keyPath) => readNestedString(JSON.parse(raw), keyPath), + writeString: (raw, keyPath, value) => { + const config = JSON.parse(raw) as Record; + setNestedValue(config, keyPath, value); + return serializeJsonPreservingShape(raw, config); + }, + removeKey: (raw, keyPath) => { + const config = JSON.parse(raw) as Record; + if (!deleteNested(config, keyPath)) { + return raw; + } + return serializeJsonPreservingShape(raw, config); + }, +}; + +const JSONC_DRIVER: FormatDriver = { + hasKeyPath: (raw, keyPath) => hasNestedKey(parseJsonc(raw), keyPath), + readString: (raw, keyPath) => readNestedString(parseJsonc(raw), keyPath), + writeString: (raw, keyPath, value) => { + const edits = modify(raw, [...keyPath], value, { + formattingOptions: jsonFormattingOptions(raw), + }); + return applyEdits(raw, edits); + }, + removeKey: (raw, keyPath) => { + const edits = modify(raw, [...keyPath], undefined, { + formattingOptions: jsonFormattingOptions(raw), + }); + return applyEdits(raw, edits); + }, +}; + +const YAML_DRIVER: FormatDriver = { + hasKeyPath: (raw, keyPath) => YAML.parseDocument(raw).hasIn([...keyPath]), + readString: (raw, keyPath) => { + const value = YAML.parseDocument(raw).getIn([...keyPath]); + return typeof value === 'string' ? value : null; + }, + writeString: (raw, keyPath, value) => { + const doc = YAML.parseDocument(raw); + doc.setIn([...keyPath], value); + return doc.toString(); + }, + removeKey: (raw, keyPath) => { + const doc = YAML.parseDocument(raw); + doc.deleteIn([...keyPath]); + return doc.toString(); + }, +}; + +const TOML_DRIVER: FormatDriver = { + hasKeyPath: (raw, keyPath) => hasNestedKey(parseToml(raw), keyPath), + readString: (raw, keyPath) => readNestedString(parseToml(raw), keyPath), + writeString: (raw, keyPath, value) => { + const config = parseToml(raw) as Record; + setNestedValue(config, keyPath, value); + return stringifyToml(config); + }, + removeKey: (raw, keyPath) => { + const config = parseToml(raw) as Record; + if (!deleteNested(config, keyPath)) { + return raw; + } + return stringifyToml(config); + }, +}; + +function driverFor(format: ArgentConfigFormat): FormatDriver { + switch (format) { + case 'json': + return JSON_DRIVER; + case 'jsonc': + return JSONC_DRIVER; + case 'yaml': + return YAML_DRIVER; + case 'toml': + return TOML_DRIVER; + } +} + +// ── Nested-object helpers (used by JSON + TOML drivers) ─────────────────────── + +function hasNestedKey(parsed: unknown, keyPath: readonly string[]): boolean { + let cursor: unknown = parsed; + for (const key of keyPath) { + if (!cursor || typeof cursor !== 'object' || !(key in cursor)) { + return false; + } + cursor = (cursor as Record)[key]; + } + return cursor !== undefined; +} + +function readNestedString(parsed: unknown, keyPath: readonly string[]): string | null { + let cursor: unknown = parsed; + for (const key of keyPath) { + if (!cursor || typeof cursor !== 'object' || !(key in cursor)) { + return null; + } + cursor = (cursor as Record)[key]; + } + return typeof cursor === 'string' ? cursor : null; +} + +function setNestedValue( + root: Record, + keyPath: readonly string[], + value: string +): void { + let cursor: Record = root; + for (let i = 0; i < keyPath.length - 1; i++) { + const key = keyPath[i]; + const next = cursor[key]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + cursor[key] = {}; + } + cursor = cursor[key] as Record; + } + cursor[keyPath[keyPath.length - 1]] = value; +} + +function deleteNested(root: unknown, keyPath: readonly string[]): boolean { + let cursor: unknown = root; + for (let i = 0; i < keyPath.length - 1; i++) { + if (!cursor || typeof cursor !== 'object' || !(keyPath[i] in cursor)) { + return false; + } + cursor = (cursor as Record)[keyPath[i]]; + } + if (!cursor || typeof cursor !== 'object') { + return false; + } + const lastKey = keyPath[keyPath.length - 1]; + if (!(lastKey in cursor)) { + return false; + } + delete (cursor as Record)[lastKey]; + return true; +} + +function serializeJsonPreservingShape(raw: string, config: Record): string { + const indent = detectJsonIndent(raw); + const trailingNewline = raw.endsWith('\n') ? '\n' : ''; + return JSON.stringify(config, null, indent) + trailingNewline; +} + +function detectJsonIndent(raw: string): number | string { + // First indented line in the file is a reliable proxy for indent style. + // Defaults to 2 spaces if nothing matches. + for (const line of raw.split('\n')) { + const match = line.match(/^([ \t]+)\S/); + if (match) { + return match[1]; + } + } + return 2; +} + +function jsonFormattingOptions(raw: string): { tabSize: number; insertSpaces: boolean } { + const indent = detectJsonIndent(raw); + if (typeof indent === 'string') { + const usesTabs = indent.startsWith('\t'); + return { tabSize: usesTabs ? 1 : indent.length, insertSpaces: !usesTabs }; + } + return { tabSize: indent, insertSpaces: true }; +} + +// ── Public API: detection, capture, apply, revert ───────────────────────────── + +export type ArgentEditPlan = { + filePath: string; + editorLabel: string; + scope: 'project' | 'global'; + format: ArgentConfigFormat; + // Full path from the parsed root to the ARGENT_TOOLS_URL key, e.g. + // ['mcpServers', 'argent', 'env', 'ARGENT_TOOLS_URL']. + toolsUrlKeyPath: readonly string[]; + previousValue: string | null; +}; + +/** + * Detects every writable argent MCP config across all supported formats + * (JSON, JSONC, YAML, TOML) and captures the state needed to apply + later + * revert an ARGENT_TOOLS_URL edit on each. + */ +export function captureWritableArgentEdits(): ArgentEditPlan[] { + const found: ArgentEditPlan[] = []; + const seen = new Set(); + for (const candidate of buildArgentMcpConfigCandidates()) { + const dedupeKey = `${candidate.editorLabel}:${candidate.scope}`; + if (seen.has(dedupeKey)) { + continue; + } + try { + const raw = fs.readFileSync(candidate.filePath, 'utf8'); + const driver = driverFor(candidate.format); + if (!driver.hasKeyPath(raw, candidate.keyPath)) { + continue; + } + const toolsUrlKeyPath = [...candidate.keyPath, candidate.envKeyName, 'ARGENT_TOOLS_URL']; + found.push({ + filePath: candidate.filePath, + editorLabel: candidate.editorLabel, + scope: candidate.scope, + format: candidate.format, + toolsUrlKeyPath, + previousValue: driver.readString(raw, toolsUrlKeyPath), + }); + seen.add(dedupeKey); + } catch (err) { + // ENOENT is the common case (we probe ~17 paths most of which won't + // exist on any given system) — skip silently. Anything else means the + // file is real but unreadable/unparseable, which the user likely wants + // to know about so they can debug why detection didn't fire. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + Log.warn( + `Skipped ${candidate.filePath} (${candidate.editorLabel}, ${candidate.scope}) while looking for argent MCP configs: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + } + return found; +} + +/** + * Applies the edits. On any failure, rolls back the edits already applied + * in this batch and re-throws — callers don't have to track partial state. + */ +export function applyArgentEdits(edits: readonly ArgentEditPlan[], toolsUrl: string): void { + const applied: ArgentEditPlan[] = []; + try { + for (const edit of edits) { + writeAtPath(edit.filePath, edit.format, edit.toolsUrlKeyPath, toolsUrl); + applied.push(edit); + } + } catch (err) { + revertArgentEdits(applied); + throw err; + } +} + +/** + * Reverts the edits to their previous values. Best-effort — failures are + * surfaced via the optional callback and the loop continues so one bad file + * can't block reverting the others. + */ +export function revertArgentEdits( + edits: readonly ArgentEditPlan[], + onError?: (filePath: string, error: unknown) => void +): void { + for (const edit of edits) { + try { + if (edit.previousValue === null) { + removeAtPath(edit.filePath, edit.format, edit.toolsUrlKeyPath); + } else { + writeAtPath(edit.filePath, edit.format, edit.toolsUrlKeyPath, edit.previousValue); + } + } catch (err) { + onError?.(edit.filePath, err); + } + } +} + +function writeAtPath( + filePath: string, + format: ArgentConfigFormat, + keyPath: readonly string[], + value: string +): void { + const raw = fs.readFileSync(filePath, 'utf8'); + fs.writeFileSync(filePath, driverFor(format).writeString(raw, keyPath, value)); +} + +function removeAtPath( + filePath: string, + format: ArgentConfigFormat, + keyPath: readonly string[] +): void { + const raw = fs.readFileSync(filePath, 'utf8'); + const next = driverFor(format).removeKey(raw, keyPath); + if (next !== raw) { + fs.writeFileSync(filePath, next); + } +} + +// ── Internal: detection used by the instruction-printing fallback ───────────── +// captureWritableArgentEdits is the superset (same iteration, same detection, +// plus the previousValue + format + toolsUrlKeyPath fields); the +// instruction-printing path just doesn't care about the extra fields. + +function findArgentMcpConfigLocations(): ArgentMcpConfigLocation[] { + return captureWritableArgentEdits().map(edit => ({ + filePath: edit.filePath, + editorLabel: edit.editorLabel, + scope: edit.scope, + })); +} + +function formatArgentInstructions(toolsUrl: string, webPreviewUrl?: string | null): string { + const found = findArgentMcpConfigLocations(); + const lines: string[] = ['🚀 Argent session is live.', '']; + + if (found.length > 0) { + lines.push('Detected existing argent MCP config:'); + for (const location of found) { + lines.push(` • ${location.editorLabel} (${location.scope}): ${location.filePath}`); + } + lines.push('', 'Add this env var to the "argent" server\'s "env" block:', ''); + lines.push(` "ARGENT_TOOLS_URL": "${toolsUrl}"`); + lines.push( + '', + 'Then restart your editor (or reload MCP servers — e.g. /mcp → reconnect', + 'in Claude Code) so it re-reads the config.' + ); + } else { + lines.push("Didn't find an existing argent MCP config. To wire one up:", ''); + lines.push(' 1. (One-time setup) Install argent and register the MCP server:'); + lines.push(' npx -y @swmansion/argent init'); + lines.push(''); + lines.push(" 2. Open your editor's MCP config and add this env var to the"); + lines.push(' "argent" server entry:'); + lines.push(''); + lines.push(` "ARGENT_TOOLS_URL": "${toolsUrl}"`); + lines.push(''); + lines.push(' Config locations (project / global) used by argent:'); + lines.push(' • Claude Code: /.mcp.json | ~/.claude.json'); + lines.push(' • Cursor: /.cursor/mcp.json | ~/.cursor/mcp.json'); + lines.push(' • VS Code: /.vscode/mcp.json'); + lines.push(' • Windsurf: ~/.codeium/windsurf/mcp_config.json'); + lines.push(' • Zed: /.zed/settings.json | ~/.config/zed/settings.json'); + lines.push(' • Gemini: /.gemini/settings.json | ~/.gemini/settings.json'); + lines.push(' • Codex: /.codex/config.toml | ~/.codex/config.toml'); + lines.push(' • Hermes: ~/.hermes/config.yaml'); + lines.push(' • opencode: /opencode.json | ~/.config/opencode/opencode.json'); + lines.push(''); + lines.push(' 3. Restart your editor (or reload MCP servers) to pick up the new env.'); + } + + if (webPreviewUrl) { + lines.push( + '', + '🌐 Open the following URL in your browser to preview the simulator:', + '', + webPreviewUrl + ); + } + + return lines.join('\n'); +} + export function formatRemoteSessionInstructions( remoteConfig: DeviceRunSessionRemoteConfig ): string { @@ -24,22 +592,8 @@ export function formatRemoteSessionInstructions( } return lines.join('\n'); } - case 'ArgentRunSessionRemoteConfig': { - const lines = [ - '🔑 Open the following URL to access the Argent tools for this session:', - '', - remoteConfig.toolsUrl, - ]; - if (remoteConfig.webPreviewUrl) { - lines.push( - '', - '🌐 Open the following URL in your browser to preview the simulator:', - '', - remoteConfig.webPreviewUrl - ); - } - return lines.join('\n'); - } + case 'ArgentRunSessionRemoteConfig': + return formatArgentInstructions(remoteConfig.toolsUrl, remoteConfig.webPreviewUrl); case 'ServeSimRunSessionRemoteConfig': return [ '🌐 Open the following URL in your browser to access the simulator:', diff --git a/yarn.lock b/yarn.lock index 342a46e403..c1c5bafebe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11100,6 +11100,7 @@ __metadata: jest: "npm:29.7.0" jks-js: "npm:1.1.0" joi: "npm:17.11.0" + jsonc-parser: "npm:3.3.1" keychain: "npm:1.5.0" log-symbols: "npm:4.1.0" memfs: "npm:3.4.13" @@ -11125,6 +11126,7 @@ __metadata: semver: "npm:7.5.4" set-interval-async: "npm:3.0.3" slash: "npm:3.0.0" + smol-toml: "npm:1.6.1" tar: "npm:7.5.7" tar-stream: "npm:3.1.7" terminal-link: "npm:2.1.1" @@ -14848,6 +14850,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -19093,6 +19102,13 @@ __metadata: languageName: node linkType: hard +"smol-toml@npm:1.6.1": + version: 1.6.1 + resolution: "smol-toml@npm:1.6.1" + checksum: 10c0/511a78722f99c7616fdb46af708de3d7e81434b5a3d58061166da73f28bfc6cae4f0cd04683f60515b9c490cd10152fce72287c960b337419c0299cc1f0f2a22 + languageName: node + linkType: hard + "snake-case@npm:^3.0.4": version: 3.0.4 resolution: "snake-case@npm:3.0.4"