diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index cc67c2c..34dc8d3 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -148,7 +148,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - client: [claude-code, codex, cursor, opencode, hermes, openclaw] + client: [claude-code, codex, cursor, opencode, gemini-cli, hermes, openclaw] exclude: # cursor-agent ships for Linux and macOS only. - os: windows-latest diff --git a/README.md b/README.md index 241bb3a..ee75146 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Most agents need no plugin: add the MCP server `https://relay.simular.cloud/mcp` | --- | --- | --- | --- | | Claude Code and Cowork | [`claude/`](claude/) | OAuth, once in the browser | `/plugin marketplace add simular-ai/box-plugin`, then `/plugin install box@simular-box` | | DeepSeek Harness (dsh) | [`dsh/`](dsh/) | Personal key (`cb_...`), pasted on the plugin's page in Plugins | [dsh/README.md](dsh/README.md) | -| Any of Claude Code, Codex, Cursor, OpenCode, Hermes, OpenClaw, VS Code (and steps for Claude Desktop) | [`cli/`](cli/) | OAuth; personal key with `--auth key` for Hermes and OpenClaw | `npx @simular-ai/box connect` (check with `npx @simular-ai/box doctor`) | +| Any of Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Hermes, OpenClaw, VS Code (and steps for Claude Desktop) | [`cli/`](cli/) | OAuth; personal key with `--auth key` for Hermes and OpenClaw | `npx @simular-ai/box connect` (check with `npx @simular-ai/box doctor`) | | Hermes Agent | [`hermes/`](hermes/) | Personal key (`cb_...`) in `MCP_BOX_API_KEY`; OAuth optional | [hermes/README.md](hermes/README.md) | ## Adding an agent diff --git a/cli/README.md b/cli/README.md index 69fb8ee..f6c67bd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -20,17 +20,20 @@ The command is `simular-box` (`box` belongs to box.com's CLI), so after `npm i - | App | `--client` | What connect does | Sign-in | | --- | --- | --- | --- | | Claude Code | `claude-code` | Installs the [`claude/`](../claude/) plugin (box server + box skill) with `claude plugin install box@simular-box`. With `BOX_MCP_URL` set, `claude mcp add --scope user --transport http box ` instead | OAuth: `/mcp` → box → Authenticate | -| Codex | `codex` | Writes `[mcp_servers.box]` to `$CODEX_HOME/config.toml` (default `~/.codex`) | OAuth: `codex mcp login box` | +| Codex | `codex` | Writes `[mcp_servers.box]` to `$CODEX_HOME/config.toml` (default `~/.codex`) | OAuth: `codex mcp login box`, or Settings → MCP servers → Authenticate in the Codex app | | Cursor | `cursor` | Merges `mcpServers.box` into `~/.cursor/mcp.json` | OAuth: Settings → MCP → Login | -| OpenCode | `opencode` | Merges `mcp.box` (`type: remote`) into `~/.config/opencode/opencode.json(c)` | OAuth: `opencode mcp auth box` | +| Gemini CLI | `gemini-cli` | Merges `mcpServers.box` (`type: http`) into `~/.gemini/settings.json` | OAuth: `/mcp auth box` (in a trusted folder) | +| OpenCode | `opencode` | Merges `mcp.box` (`type: remote`) into `~/.config/opencode/opencode.json(c)` (`mcp.servers.box` when the file uses that layout) | OAuth: `opencode mcp auth box` | | Hermes Agent | `hermes` | Writes `mcp_servers.box` to `$HERMES_HOME/config.yaml` (default `~/.hermes`); restarts a running gateway | OAuth (`hermes mcp login box`) or `--auth key` | | OpenClaw | `openclaw` | `openclaw mcp set box …` with `transport: streamable-http`; restarts a running Gateway when the key changes | OAuth (`openclaw mcp login box`) or `--auth key` | | VS Code | `vscode` | Merges `servers.box` (`type: http`) into the user `mcp.json` | OAuth, when the server starts | | Claude Desktop | `claude-desktop` | Writes nothing: remote servers are connectors in the app. Prints the steps | OAuth in the app | +Cursor, Gemini CLI, OpenCode and VS Code are written through [add-mcp](https://github.com/neon-solutions/add-mcp) (Apache-2.0, pinned in `package.json`, wrapped by `src/config-writer.js`), which tracks where each app keeps its servers on macOS, Linux and Windows. The rest are box-specific and stay in `src/clients/`: the Claude Code plugin, Codex and Hermes (format-preserving edits and Hermes's OAuth flag, which add-mcp does not keep), OpenClaw (not in add-mcp), and key mode. + Pick apps without prompts with `--client cursor,codex --yes`; `--yes` alone picks every app found. `--dry-run` shows what would change. -The CLI only changes or removes a server that points at box by Simular (`relay.simular.cloud`, its staging host, `mcp.box.simular.ai`, or your `BOX_MCP_URL`). If an app already has a different server named `box`, such as box.com's MCP server, `connect` stops without changing anything, and `remove` leaves it alone. To add box next to it, use `--name simular-box`. A symlinked config stays a symlink: the file it points to is updated. +The CLI only changes or removes a server that points at box by Simular (`relay.simular.cloud`, its staging host, `mcp.box.simular.ai`, or your `BOX_MCP_URL`). If an app already has a different server named `box`, such as box.com's MCP server, `connect` stops without changing anything, and `remove` leaves it alone. To add box next to it, use `--name simular-box`. A symlinked config stays a symlink: the file it points to is updated. If a config can't be written (owned by another user, or locked by the app), connect says so; for Codex it prints the steps to add box in the Codex app's Settings instead. ## Sign-in diff --git a/cli/e2e/clients.json b/cli/e2e/clients.json index ebc259c..6c9659e 100644 --- a/cli/e2e/clients.json +++ b/cli/e2e/clients.json @@ -3,6 +3,7 @@ "claude-code": { "npm": "@anthropic-ai/claude-code@2.1.316" }, "codex": { "npm": "@openai/codex@0.166.0" }, "opencode": { "npm": "opencode-ai@1.19.5" }, + "gemini-cli": { "npm": "@google/gemini-cli@0.66.0" }, "openclaw": { "npm": "openclaw@2026.10.9" }, "hermes": { "git": "https://github.com/NousResearch/hermes-agent", "tag": "v0.22.6" }, "cursor": { "cursorAgent": "2026.10.22-27dcf5f" } diff --git a/cli/e2e/install-matrix.mjs b/cli/e2e/install-matrix.mjs index 6caf83c..243eff0 100644 --- a/cli/e2e/install-matrix.mjs +++ b/cli/e2e/install-matrix.mjs @@ -97,6 +97,15 @@ const ADAPTERS = { present: (out) => /\bbox\s+https:\/\/relay\.simular/.test(out), otherKept: (out) => /\bother\b/.test(out), }, + 'gemini-cli': { + bin: 'gemini', + seed: () => writeJson(client.configLocation(ctx), { mcpServers: { other: { httpUrl: 'https://example.com/mcp' } } }), + // The isolated HOME is not a trusted folder, so Gemini lists the servers as Disabled and + // never connects; the listing still proves it reads our entry as an http server. + list: () => sh('gemini', ['mcp', 'list']), + present: (out) => /\bbox: https:\/\/relay\.simular\.cloud\/mcp \(http\)/.test(out), + otherKept: (out) => /\bother: https:\/\/example\.com\/mcp/.test(out), + }, cursor: { bin: 'cursor-agent', seed: () => writeJson(client.configLocation(ctx), { mcpServers: { other: { url: 'https://example.com/mcp' } } }), diff --git a/cli/package-lock.json b/cli/package-lock.json index 0850686..578956e 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@clack/prompts": "0.11.0", + "add-mcp": "2.6.1", "cross-spawn": "7.0.6", "jsonc-parser": "3.3.1", "smol-toml": "1.9.1", @@ -271,6 +272,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, "node_modules/@types/estree": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", @@ -308,6 +315,59 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/add-mcp": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/add-mcp/-/add-mcp-2.6.1.tgz", + "integrity": "sha512-Jimza7/1JLaC3RVoaImX+CJSYKrQZkKuBZoV77zdo5yZwl/ZI66ba2uBVz690VSiqXR3yWKIPaw6Ou4ntzuRwg==", + "license": "Apache-2.0", + "dependencies": { + "@clack/prompts": "^0.9.1", + "@iarna/toml": "^2.2.5", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "jsonc-parser": "^3.3.1", + "yaml": "^2.9.1" + }, + "bin": { + "add-mcp": "dist/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/add-mcp/node_modules/@clack/core": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.1.tgz", + "integrity": "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/add-mcp/node_modules/@clack/prompts": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.9.1.tgz", + "integrity": "sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==", + "license": "MIT", + "dependencies": { + "@clack/core": "0.4.1", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/add-mcp/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -413,6 +473,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/cli/package.json b/cli/package.json index 13d8f73..b3743b5 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@simular-ai/box", "version": "0.1.2", - "description": "Connect box by Simular (a cloud computer your AI agent drives over MCP) to Claude Code, Codex, Cursor, OpenCode, Hermes, OpenClaw and more in one command.", + "description": "Connect box by Simular (a cloud computer your AI agent drives over MCP) to Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Hermes, OpenClaw and more in one command.", "license": "MIT", "type": "module", "bin": { @@ -24,6 +24,7 @@ }, "dependencies": { "@clack/prompts": "0.11.0", + "add-mcp": "2.6.1", "cross-spawn": "7.0.6", "jsonc-parser": "3.3.1", "smol-toml": "1.9.1", @@ -45,6 +46,7 @@ "codex", "cursor", "opencode", + "gemini-cli", "hermes", "openclaw" ], diff --git a/cli/src/add-mcp-worker.js b/cli/src/add-mcp-worker.js new file mode 100644 index 0000000..a7933f7 --- /dev/null +++ b/cli/src/add-mcp-worker.js @@ -0,0 +1,64 @@ +// Runs add-mcp in a child process; only config-writer.js starts it. +// add-mcp resolves every config path from os.homedir() and process.env once, when it is imported, +// so it runs in a process whose environment is the CLI context's (tests use a temporary HOME). +// Request on stdin: {agent, name, url, ops: [{op: 'inspect' | 'upsert' | 'remove', name?}]}. +// Reply on stdout: {results: [...]} or {error}. Never pass a secret: none is needed here. +import fs from 'node:fs' +import * as jsonc from 'jsonc-parser' +import { agents, listInstalledServers, removeServer, upsertServer } from 'add-mcp' + +function configPath(agent) { + return agent.resolveConfigPath ? agent.resolveConfigPath(agent, { local: false, cwd: process.cwd() }) : agent.configPath +} + +// OpenCode keeps servers under `mcp` (V1) or `mcp.servers` (V2). add-mcp writes a new server into +// `mcp.servers` when the file has that map (even empty) and no V1 servers; mirror that here. +function openCodeKey(file, servers) { + if (servers.some((s) => s.configKey === 'mcp')) return 'mcp' + if (servers.length) return servers[0].configKey + if (!fs.existsSync(file)) return 'mcp' + const map = jsonc.parse(fs.readFileSync(file, 'utf8'), [], { allowTrailingComma: true })?.mcp?.servers + return map && typeof map === 'object' && !Array.isArray(map) ? 'mcp.servers' : 'mcp' +} + +async function inspect(agentId, name, url) { + const agent = agents[agentId] + const file = configPath(agent) + let servers = [] + if (fs.existsSync(file)) { + const [read] = await listInstalledServers({ global: true, agents: [agentId] }) + if (read.error) throw new Error(read.error) + if (!read.detected) throw new Error(`add-mcp did not read ${file}`) + servers = read.servers.map((s) => ({ name: s.serverName, configKey: s.configKey, config: s.config })) + } + const existing = servers.find((s) => s.name === name) + const configKey = existing?.configKey ?? (agentId === 'opencode' ? openCodeKey(file, servers) : agent.configKey) + const entry = agent.transformConfig(name, { type: 'http', url }, { local: false, configKey }) + return { path: file, format: agent.format, configKey, entry: JSON.parse(JSON.stringify(entry)), servers } +} + +async function run(req) { + if (!Object.prototype.hasOwnProperty.call(agents, req.agent)) throw new Error(`add-mcp does not know ${req.agent}`) + const results = [] + for (const step of req.ops) { + const name = step.name ?? req.name + if (step.op === 'inspect') results.push(await inspect(req.agent, name, req.url)) + else if (step.op === 'upsert') results.push(upsertServer(req.agent, name, { type: 'http', url: req.url })) + else if (step.op === 'remove') results.push(removeServer(req.agent, name)) + else throw new Error(`unknown op ${step.op}`) + const last = results[results.length - 1] + if (last.success === false) break + } + return results +} + +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => (input += chunk)) +process.stdin.on('end', async () => { + try { + process.stdout.write(JSON.stringify({ results: await run(JSON.parse(input)) })) + } catch (err) { + process.stdout.write(JSON.stringify({ error: err.message, code: err.code })) + } +}) diff --git a/cli/src/cli.js b/cli/src/cli.js index fb23af7..c8812ad 100644 --- a/cli/src/cli.js +++ b/cli/src/cli.js @@ -192,6 +192,11 @@ async function connect(ctx, flags, { interactive, env, isTTY }) { failed++ spin.stop(`${client.name}: failed`, 1) p.log.error(` ${err.message}`) + if (isPermissionError(err)) { + const where = err.path ? ` ${err.path}` : ' its config' + p.log.warn(` box could not write${where}: permission denied. The file may belong to another user (for example after a run with sudo) or be locked by the app; check its owner, or quit the app and run again.`) + for (const n of client.permissionHelp?.(ctx) || []) next.push(`${client.name}: ${n}`) + } } } if (next.length) p.note(next.join('\n'), 'Next') @@ -202,6 +207,11 @@ async function connect(ctx, flags, { interactive, env, isTTY }) { return failed ? 1 : 0 } +/** EACCES/EPERM from Node, or the same reported as text (add-mcp, Windows "Access is denied"). */ +export function isPermissionError(err) { + return ['EACCES', 'EPERM'].includes(err?.code) || /\bEACCES\b|\bEPERM\b|permission denied|access is denied|operation not permitted/i.test(err?.message || '') +} + async function remove(ctx, flags, { interactive }) { p.intro(`Remove box ${ctx.dryRun ? '(dry run)' : ''}`) let clients = selectedFromFlags(flags) diff --git a/cli/src/clients/add-mcp-client.js b/cli/src/clients/add-mcp-client.js new file mode 100644 index 0000000..d015445 --- /dev/null +++ b/cli/src/clients/add-mcp-client.js @@ -0,0 +1,123 @@ +// Clients whose MCP config file add-mcp writes (../config-writer.js knows where and in what shape). +// What stays here is specific to box: only an entry whose URL is box by Simular is ours to replace +// or remove, a file we cannot parse is never touched, old box-control entries go, the previous +// file is kept as a backup, and the result is read back after add-mcp writes it. +import fs from 'node:fs' +import { ForeignServerError, LEGACY_SERVER_NAMES } from '../constants.js' +import { readText, saveBackup, writeAtomic } from '../fsutil.js' +import { getPath, parseJsonc, sameValue } from '../formats.js' +import * as writer from '../config-writer.js' + +const entryUrl = (e) => e?.url ?? e?.serverUrl ?? e?.httpUrl + +/** + * @param {object} spec + * @param {string} spec.id our client id + * @param {string} spec.agent add-mcp's agent id + * @param {string} spec.name + * @param {string[]} [spec.aliases] + * @param {string[]} [spec.bins] binaries that mean "installed" + * @param {(ctx) => string[]} [spec.dirs] directories that mean "installed" + * @param {(ctx, info) => void} [spec.check] throws when add-mcp would write a file the client does not read + * @param {(ctx) => string[]} spec.next + */ +export function addMcpClient(spec) { + function look(ctx) { + const info = writer.inspect(ctx, spec.agent) + spec.check?.(ctx, info) + if (info.format !== 'json') throw new Error(`${spec.name}: unexpected config format ${info.format}`) + const text = readText(info.path) + // add-mcp reads leniently; never let it write over a file we cannot parse. + parseJsonc(text, info.path) + return { info, text, map: Object.fromEntries(info.servers.map((s) => [s.name, s.config])) } + } + const ourLegacy = (ctx, map) => LEGACY_SERVER_NAMES.filter((n) => map[n] && ctx.ours(entryUrl(map[n]))) + + /** + * Let add-mcp change the file, with our safety around it: the old text is backed up first and + * put back if add-mcp fails part-way (it writes in place, not atomically) or the result does + * not read back as asked. Returns the backups kept. + */ + function change(ctx, info, before, ops, { expect, gone }) { + const backup = before === null ? null : saveBackup(info.path, before) + const restore = () => { + if (before === null) fs.rmSync(info.path, { force: true }) + else writeAtomic(info.path, before) + } + try { + writer.apply(ctx, spec.agent, ops) + } catch (err) { + restore() + if (backup) fs.rmSync(backup, { force: true }) + throw err + } + const after = readText(info.path) + try { + const servers = getPath(parseJsonc(after, info.path), info.configKey.split('.')) || {} + if (expect && !sameValue(servers[ctx.name], expect)) throw new Error('box entry not as written') + for (const n of gone) if (servers[n] !== undefined) throw new Error(`${n} still present`) + } catch (err) { + restore() + if (backup) fs.rmSync(backup, { force: true }) + throw new Error(`add-mcp's change to ${info.path} did not read back as expected (${err.message}); the file was restored`) + } + if (backup && after === before) { + fs.rmSync(backup, { force: true }) + return [] + } + return backup ? [backup] : [] + } + + return { + id: spec.id, + name: spec.name, + auth: 'oauth', + aliases: spec.aliases || [], + + detect(ctx) { + for (const bin of spec.bins || []) if (ctx.which(bin)) return { installed: true, via: `${bin} on PATH` } + for (const dir of spec.dirs ? spec.dirs(ctx) : []) if (fs.existsSync(dir)) return { installed: true, via: dir } + return { installed: false } + }, + + configLocation(ctx) { + return writer.inspect(ctx, spec.agent).path + }, + + status(ctx) { + const { info, map } = look(ctx) + const entry = map[ctx.name] + if (entry && !ctx.ours(entryUrl(entry))) { + return { entries: [], legacy: [], keyConfigured: false, foreign: { where: info.path, url: entryUrl(entry) } } + } + return { + entries: entry ? [{ where: info.path, url: entryUrl(entry), enabled: entry.enabled !== false && entry.disabled !== true }] : [], + legacy: ourLegacy(ctx, map), + keyConfigured: false, + } + }, + + connect(ctx) { + const { info, text, map } = look(ctx) + const current = map[ctx.name] + if (current && !ctx.ours(entryUrl(current))) throw new ForeignServerError(info.path, ctx.name, entryUrl(current)) + const legacy = ourLegacy(ctx, map) + const upsert = !sameValue(current, info.entry) + const next = spec.next(ctx) + if (!upsert && !legacy.length) return { actions: [], backups: [], next } + if (ctx.dryRun) return { actions: [`would write ${ctx.name} to ${info.path}`], backups: [], next } + const backups = change(ctx, info, text, { upsert, drop: legacy }, { expect: info.entry, gone: legacy }) + return { actions: [`wrote ${ctx.name} to ${info.path}`], backups, next } + }, + + remove(ctx) { + const { info, text, map } = look(ctx) + const names = [ctx.name, ...LEGACY_SERVER_NAMES].filter((n) => map[n] && ctx.ours(entryUrl(map[n]))) + const warnings = map[ctx.name] && !names.includes(ctx.name) ? [`left "${ctx.name}" in ${info.path}: it points at ${entryUrl(map[ctx.name])}, not box by Simular`] : [] + if (!names.length) return { actions: [], backups: [], warnings, next: [] } + if (ctx.dryRun) return { actions: [`would remove ${names.join(', ')} from ${info.path}`], backups: [], warnings, next: [] } + const backups = change(ctx, info, text, { upsert: false, drop: names }, { gone: names }) + return { actions: [`removed ${names.join(', ')} from ${info.path}`], backups, warnings, next: [] } + }, + } +} diff --git a/cli/src/clients/codex.js b/cli/src/clients/codex.js index dd4b6ad..6f4afe5 100644 --- a/cli/src/clients/codex.js +++ b/cli/src/clients/codex.js @@ -14,6 +14,12 @@ const file = (ctx) => path.join(home(ctx), 'config.toml') const servers = (ctx) => parseToml(readText(file(ctx)), file(ctx)).mcp_servers || {} const ourLegacy = (ctx, map) => LEGACY_SERVER_NAMES.filter((n) => map[n] && ctx.ours(map[n].url)) +// The Codex app (ChatGPT desktop) reads the same config.toml but ships no `codex` on PATH. +function signInHint(ctx) { + if (ctx.which('codex')) return `Run \`codex mcp login ${ctx.name}\`, sign in in the browser and click Approve.` + return `Restart the Codex app, open Settings -> MCP servers and click Authenticate next to ${ctx.name}; sign in in the browser and click Approve.` +} + export default { id: 'codex', name: 'Codex', @@ -56,10 +62,18 @@ export default { return { actions: res.changed ? [`${ctx.dryRun ? 'would write' : 'wrote'} [mcp_servers.${ctx.name}] to ${f}`] : [], backups: res.backup ? [res.backup] : [], - next: [`Run \`codex mcp login ${ctx.name}\`, sign in in the browser and click Approve.`], + next: [signInHint(ctx)], } }, + /** Shown when config.toml cannot be written (EACCES/EPERM, "Access is denied" on Windows). */ + permissionHelp(ctx) { + return [ + `Add box in the Codex app instead: Settings -> MCP servers -> Add server, name it ${ctx.name}, choose Streamable HTTP, paste ${ctx.url} as the URL and click Save.`, + `Restart Codex, then click Authenticate next to ${ctx.name}, sign in in the browser and click Approve.`, + ] + }, + remove(ctx) { const f = file(ctx) const map = servers(ctx) diff --git a/cli/src/clients/cursor.js b/cli/src/clients/cursor.js index adbd730..437bc02 100644 --- a/cli/src/clients/cursor.js +++ b/cli/src/clients/cursor.js @@ -1,15 +1,12 @@ -// Cursor: ~/.cursor/mcp.json, `mcpServers`. Cursor signs in to remote servers with OAuth -// (DCR + PKCE) itself. Docs: https://cursor.com/docs/context/mcp +// Cursor: ~/.cursor/mcp.json, `mcpServers`, written by add-mcp. Cursor signs in to remote servers +// with OAuth (DCR + PKCE) itself. Docs: https://cursor.com/docs/context/mcp import path from 'node:path' -import { jsonClient } from './json-client.js' +import { addMcpClient } from './add-mcp-client.js' -export default jsonClient({ +export default addMcpClient({ id: 'cursor', + agent: 'cursor', name: 'Cursor', - auth: 'oauth', - file: (ctx) => path.join(ctx.home, '.cursor', 'mcp.json'), - container: ['mcpServers'], - entry: (ctx) => ({ url: ctx.url }), bins: ['cursor', 'agent', 'cursor-agent'], dirs: (ctx) => [path.join(ctx.home, '.cursor'), ...(ctx.platform === 'darwin' ? ['/Applications/Cursor.app'] : [])], next: (ctx) => [`Open Cursor Settings -> MCP (Tools & MCP) and click Login next to ${ctx.name}; sign in and click Approve.`], diff --git a/cli/src/clients/gemini-cli.js b/cli/src/clients/gemini-cli.js new file mode 100644 index 0000000..9768c6a --- /dev/null +++ b/cli/src/clients/gemini-cli.js @@ -0,0 +1,15 @@ +// Gemini CLI: ~/.gemini/settings.json, `mcpServers`, `{ type: "http", url }`, written by add-mcp. +// Gemini CLI discovers OAuth from the 401 and signs in with `/mcp auth box`. It loads MCP servers +// only in a trusted folder. Docs: https://geminicli.com/docs/tools/mcp-server/ +import path from 'node:path' +import { addMcpClient } from './add-mcp-client.js' + +export default addMcpClient({ + id: 'gemini-cli', + agent: 'gemini-cli', + name: 'Gemini CLI', + aliases: ['gemini'], + bins: ['gemini'], + dirs: (ctx) => [path.join(ctx.home, '.gemini')], + next: (ctx) => [`Start Gemini CLI in a trusted folder, run \`/mcp auth ${ctx.name}\`, sign in in the browser and click Approve.`], +}) diff --git a/cli/src/clients/index.js b/cli/src/clients/index.js index 0744fde..c5e5ad8 100644 --- a/cli/src/clients/index.js +++ b/cli/src/clients/index.js @@ -2,12 +2,13 @@ import claudeCode from './claude-code.js' import claudeDesktop from './claude-desktop.js' import codex from './codex.js' import cursor from './cursor.js' +import geminiCli from './gemini-cli.js' import hermes from './hermes.js' import openclaw from './openclaw.js' import opencode from './opencode.js' import vscode from './vscode.js' -export const CLIENTS = [claudeCode, codex, cursor, opencode, hermes, openclaw, vscode, claudeDesktop] +export const CLIENTS = [claudeCode, codex, cursor, opencode, geminiCli, hermes, openclaw, vscode, claudeDesktop] const ALIASES = { claude: 'claude-code', 'claude-code-cli': 'claude-code' } diff --git a/cli/src/clients/json-client.js b/cli/src/clients/json-client.js deleted file mode 100644 index 6902d4a..0000000 --- a/cli/src/clients/json-client.js +++ /dev/null @@ -1,87 +0,0 @@ -// Shared logic for clients whose config is a JSON/JSONC file with a map of servers. -import fs from 'node:fs' -import { ForeignServerError, LEGACY_SERVER_NAMES } from '../constants.js' -import { readText, updateFile } from '../fsutil.js' -import { getPath, parseJsonc, removeJsonServer, upsertJsonServer } from '../formats.js' - -/** - * @param {object} spec - * @param {string} spec.id - * @param {string} spec.name - * @param {'oauth'} spec.auth - * @param {(ctx) => string} spec.file config file path - * @param {string[]} spec.container JSON path of the servers map - * @param {(ctx) => object} spec.entry the box entry to write - * @param {(entry) => string|undefined} [spec.entryUrl] - * @param {string[]} [spec.bins] binaries that mean "installed" - * @param {(ctx) => string[]} [spec.dirs] directories that mean "installed" - * @param {string[]|((ctx) => string[])} [spec.next] - */ -export function jsonClient(spec) { - const entryUrl = spec.entryUrl || ((e) => e?.url ?? e?.serverUrl) - const servers = (ctx) => { - const file = spec.file(ctx) - return getPath(parseJsonc(readText(file), file), spec.container) || {} - } - // Only entries that point at box by Simular are ours to change or remove. - const ourLegacy = (ctx, map) => LEGACY_SERVER_NAMES.filter((n) => map[n] && ctx.ours(entryUrl(map[n]))) - - return { - id: spec.id, - name: spec.name, - auth: spec.auth, - aliases: spec.aliases || [], - - detect(ctx) { - for (const bin of spec.bins || []) if (ctx.which(bin)) return { installed: true, via: `${bin} on PATH` } - for (const dir of spec.dirs ? spec.dirs(ctx) : []) if (fs.existsSync(dir)) return { installed: true, via: dir } - return { installed: false } - }, - - configLocation(ctx) { - return spec.file(ctx) - }, - - status(ctx) { - const file = spec.file(ctx) - const map = servers(ctx) - const entry = map[ctx.name] - if (entry && !ctx.ours(entryUrl(entry))) { - return { entries: [], legacy: [], keyConfigured: false, foreign: { where: file, url: entryUrl(entry) } } - } - return { - entries: entry ? [{ where: file, url: entryUrl(entry), enabled: entry.enabled !== false && entry.disabled !== true }] : [], - legacy: ourLegacy(ctx, map), - keyConfigured: false, - } - }, - - connect(ctx) { - const file = spec.file(ctx) - const map = servers(ctx) - if (map[ctx.name] && !ctx.ours(entryUrl(map[ctx.name]))) throw new ForeignServerError(file, ctx.name, entryUrl(map[ctx.name])) - const res = updateFile(file, (text) => upsertJsonServer(text, spec.container, ctx.name, spec.entry(ctx), { legacy: ourLegacy(ctx, map), file }), { - dryRun: ctx.dryRun, - }) - return { - actions: res.changed ? [`${ctx.dryRun ? 'would write' : 'wrote'} ${ctx.name} to ${file}`] : [], - backups: res.backup ? [res.backup] : [], - next: typeof spec.next === 'function' ? spec.next(ctx) : spec.next || [], - } - }, - - remove(ctx) { - const file = spec.file(ctx) - const map = servers(ctx) - const names = [ctx.name, ...LEGACY_SERVER_NAMES].filter((n) => map[n] && ctx.ours(entryUrl(map[n]))) - const warnings = map[ctx.name] && !names.includes(ctx.name) ? [`left "${ctx.name}" in ${file}: it points at ${entryUrl(map[ctx.name])}, not box by Simular`] : [] - const res = updateFile(file, (text) => removeJsonServer(text, spec.container, names, { file }), { dryRun: ctx.dryRun }) - return { - actions: res.changed ? [`${ctx.dryRun ? 'would remove' : 'removed'} ${names.join(', ')} from ${file}`] : [], - backups: res.backup ? [res.backup] : [], - warnings, - next: [], - } - }, - } -} diff --git a/cli/src/clients/opencode.js b/cli/src/clients/opencode.js index aa1f16d..5702b18 100644 --- a/cli/src/clients/opencode.js +++ b/cli/src/clients/opencode.js @@ -1,25 +1,24 @@ -// OpenCode: ~/.config/opencode/opencode.json(c), `mcp`, `{ type: "remote", url }`. OpenCode -// detects the 401 and runs OAuth (DCR) itself; `opencode mcp auth box` signs in. -// Docs: https://opencode.ai/docs/mcp-servers/ , https://opencode.ai/docs/config/ -import fs from 'node:fs' +// OpenCode: ~/.config/opencode/opencode.json(c), `mcp` (or `mcp.servers` in the newer layout), +// `{ type: "remote", url }`, written by add-mcp. OpenCode detects the 401 and runs OAuth (DCR) +// itself; `opencode mcp auth box` signs in. Docs: https://opencode.ai/docs/mcp-servers/ import path from 'node:path' -import { jsonClient } from './json-client.js' +import { addMcpClient } from './add-mcp-client.js' -function file(ctx) { - if (ctx.env.OPENCODE_CONFIG) return ctx.env.OPENCODE_CONFIG - const dir = path.join(ctx.xdgConfig, 'opencode') - const jsonc = path.join(dir, 'opencode.jsonc') - return fs.existsSync(jsonc) ? jsonc : path.join(dir, 'opencode.json') -} - -export default jsonClient({ +export default addMcpClient({ id: 'opencode', + agent: 'opencode', name: 'OpenCode', - auth: 'oauth', - file, - container: ['mcp'], - entry: (ctx) => ({ type: 'remote', url: ctx.url, enabled: true }), bins: ['opencode'], dirs: (ctx) => [path.join(ctx.xdgConfig, 'opencode')], + // OpenCode honours XDG_CONFIG_HOME; add-mcp 2.6.1 always writes ~/.config/opencode. Stop rather + // than write a file OpenCode never reads. + check(ctx, info) { + if (path.resolve(path.dirname(info.path)) !== path.resolve(ctx.xdgConfig, 'opencode')) { + throw new Error( + `OpenCode reads ${path.join(ctx.xdgConfig, 'opencode')} (XDG_CONFIG_HOME), but this version can only write ${info.path}. ` + + `Add box by hand under "mcp" in ${path.join(ctx.xdgConfig, 'opencode', 'opencode.json')}: "${ctx.name}": { "type": "remote", "url": "${ctx.url}" }`, + ) + } + }, next: (ctx) => [`Run \`opencode mcp auth ${ctx.name}\`, sign in in the browser and click Approve.`], }) diff --git a/cli/src/clients/vscode.js b/cli/src/clients/vscode.js index dcd92a4..1ac61d5 100644 --- a/cli/src/clients/vscode.js +++ b/cli/src/clients/vscode.js @@ -1,20 +1,15 @@ -// VS Code (GitHub Copilot agent mode): user-profile mcp.json, `servers`, `type: "http"`. -// VS Code runs the OAuth sign-in itself. Docs: +// VS Code (GitHub Copilot agent mode): user-profile mcp.json, `servers`, `type: "http"`, written by +// add-mcp. VS Code runs the OAuth sign-in itself. Docs: // https://code.visualstudio.com/docs/agents/reference/mcp-configuration // Detection uses the user-data folder, not `code` on PATH: Cursor also installs a `code` shim. import path from 'node:path' -import { jsonClient } from './json-client.js' +import { addMcpClient } from './add-mcp-client.js' -const userDir = (ctx) => path.join(ctx.appData, 'Code', 'User') - -export default jsonClient({ +export default addMcpClient({ id: 'vscode', + agent: 'vscode', name: 'VS Code', - auth: 'oauth', aliases: ['code', 'copilot'], - file: (ctx) => path.join(userDir(ctx), 'mcp.json'), - container: ['servers'], - entry: (ctx) => ({ type: 'http', url: ctx.url }), - dirs: (ctx) => [userDir(ctx)], + dirs: (ctx) => [path.join(ctx.appData, 'Code', 'User')], next: (ctx) => [`In VS Code, open the Command Palette -> MCP: List Servers -> ${ctx.name} -> Start, then allow the sign-in and click Approve.`], }) diff --git a/cli/src/config-writer.js b/cli/src/config-writer.js new file mode 100644 index 0000000..36ea3cd --- /dev/null +++ b/cli/src/config-writer.js @@ -0,0 +1,62 @@ +// Our interface to add-mcp (https://github.com/neon-solutions/add-mcp, Apache-2.0), which knows +// where each client keeps its MCP servers and in what shape. The version is pinned exactly in +// package.json; bump it only after the install matrix passes. If add-mcp is ever replaced, this +// file is the only one that changes. +// +// add-mcp runs in a child process (add-mcp-worker.js) with the context's environment, because it +// reads HOME and the client's env overrides once, at import. That keeps tests in their temporary +// HOME and keeps the paths add-mcp writes the same ones the rest of the CLI reads. +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { KEY_ENV } from './constants.js' + +// add-mcp needs no secret: keep personal keys out of the worker's environment. +const SECRET_ENV = [KEY_ENV, 'MCP_BOX_API_KEY'] + +const WORKER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'add-mcp-worker.js') + +/** add-mcp reports failures as messages; keep permission problems recognisable for the CLI. */ +export function writeError(message, file) { + const err = new Error(message) + if (/\bEACCES\b|\bEPERM\b|permission denied|access is denied|operation not permitted/i.test(message)) { + err.code = /\bEPERM\b|operation not permitted/i.test(message) ? 'EPERM' : 'EACCES' + err.path = file + } + return err +} + +function call(ctx, agent, name, ops) { + const res = spawnSync(process.execPath, [WORKER], { + env: Object.fromEntries(Object.entries(ctx.env).filter(([k]) => !SECRET_ENV.includes(k))), + input: JSON.stringify({ agent, name, url: ctx.url, ops }), + encoding: 'utf8', + timeout: 30000, + windowsHide: true, + }) + let out + try { + out = JSON.parse(res.stdout) + } catch { + throw new Error(`add-mcp failed: ${(res.stderr || String(res.error || `exit ${res.status}`)).trim().split('\n').pop()}`) + } + if (out.error) throw writeError(out.error) + return out.results +} + +/** + * Where `agent` keeps its servers and what add-mcp would write for box there: + * {path, format, configKey, entry, servers: [{name, configKey, config}]}. Reads only. + */ +export function inspect(ctx, agent, name = ctx.name) { + return call(ctx, agent, name, [{ op: 'inspect' }])[0] +} + +/** Write box under `name` and remove `drop` (names the caller has checked are ours). */ +export function apply(ctx, agent, { name = ctx.name, upsert = true, drop = [] } = {}) { + const ops = [...(upsert ? [{ op: 'upsert', name }] : []), ...drop.map((n) => ({ op: 'remove', name: n }))] + if (!ops.length) return + for (const r of call(ctx, agent, name, ops)) { + if (r.success === false) throw writeError(`could not update ${r.path || agent}: ${r.error}`, r.path) + } +} diff --git a/cli/src/formats.js b/cli/src/formats.js index dc91eb7..8f7826d 100644 --- a/cli/src/formats.js +++ b/cli/src/formats.js @@ -1,11 +1,9 @@ -// Format-preserving edits. We change only the keys we own and keep comments, ordering and -// every other server the user has. +// Parsing and the format-preserving edits add-mcp does not cover for us (Codex TOML, Hermes YAML, +// .env files). We change only the keys we own and keep comments, ordering and every other server. import * as jsonc from 'jsonc-parser' import * as TOML from 'smol-toml' import YAML from 'yaml' -const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' } } - /** Parse JSON or JSONC. Throws on a file we cannot parse: never overwrite what we can't read. */ export function parseJsonc(text, file = 'config') { if (text === null || text.trim() === '') return {} @@ -21,17 +19,6 @@ export function parseJsonc(text, file = 'config') { return value } -/** Set (or with value undefined, delete) the value at `jsonPath`, keeping the rest of the text. */ -export function setJsoncPath(text, jsonPath, value, file) { - const base = text === null || text.trim() === '' ? '{}\n' : text - parseJsonc(base, file) - const eol = /\r\n/.test(base) ? '\r\n' : '\n' - const edits = jsonc.modify(base, jsonPath, value, { formattingOptions: { ...FORMAT.formattingOptions, eol } }) - let out = jsonc.applyEdits(base, edits) - if (!out.endsWith('\n')) out += eol - return out -} - export function getPath(obj, jsonPath) { let cur = obj for (const key of jsonPath) { @@ -54,34 +41,6 @@ function sortKeys(v) { return v } -/** - * Upsert `servers[name] = entry` under `containerPath` in a JSON/JSONC document and delete legacy - * names. Returns the new text, or the old text when nothing changes. - */ -export function upsertJsonServer(text, containerPath, name, entry, { legacy = [], file } = {}) { - const doc = parseJsonc(text, file) - let out = text - const current = getPath(doc, [...containerPath, name]) - if (!sameValue(current, entry)) out = setJsoncPath(out, [...containerPath, name], entry, file) - for (const old of legacy) { - if (getPath(parseJsonc(out, file), [...containerPath, old]) !== undefined) { - out = setJsoncPath(out, [...containerPath, old], undefined, file) - } - } - return out -} - -export function removeJsonServer(text, containerPath, names, { file } = {}) { - if (text === null) return null - let out = text - for (const name of names) { - if (getPath(parseJsonc(out, file), [...containerPath, name]) !== undefined) { - out = setJsoncPath(out, [...containerPath, name], undefined, file) - } - } - return out -} - // ---- dotenv files (KEY=value lines) ---- const envLine = (name) => new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=.*$`, 'm') diff --git a/cli/src/fsutil.js b/cli/src/fsutil.js index 15e007f..b921dc9 100644 --- a/cli/src/fsutil.js +++ b/cli/src/fsutil.js @@ -36,6 +36,19 @@ export function backup(file, now = new Date()) { return target } +/** + * Keep `text` (a file's content before someone else changed it) as `file.box-backup-`, + * with the file's mode. Used when the change itself is made by add-mcp. + */ +export function saveBackup(file, text, now = new Date()) { + const real = exists(file) ? fs.realpathSync(file) : file + const target = `${real}.box-backup-${now.toISOString().replace(/[:.]/g, '-')}` + const mode = exists(real) ? fs.statSync(real).mode & 0o777 : 0o600 + fs.writeFileSync(target, text, { mode }) + fs.chmodSync(target, mode) + return target +} + /** * Write atomically (temp file + rename) so a crash never leaves a half-written config. * Keeps the existing file's mode; `mode` applies to new files (0600 for files that hold a key). diff --git a/cli/test/cli.test.js b/cli/test/cli.test.js index ee8bbb4..c827ddd 100644 --- a/cli/test/cli.test.js +++ b/cli/test/cli.test.js @@ -79,7 +79,9 @@ test('real CLI: connect --client cursor,codex --yes in a temp HOME, then remove' const sb = sandbox() const res = run(['connect', '--client', 'cursor,codex', '--yes'], sb) assert.equal(res.status, 0, res.stderr + res.stdout) - assert.match(res.stdout, /codex mcp login box/) + // No `codex` on PATH (the Codex app alone): the hint points at the app's Settings. + assert.match(res.stdout, /Settings -> MCP servers and click Authenticate next to box/) + assert.ok(!res.stdout.includes('codex mcp login'), 'does not assume the codex CLI') assert.match(res.stdout, /start a new chat \(or restart the app\)/) for (const e of EXAMPLE_PROMPTS) assert.ok(res.stdout.includes(e), `shows the example prompt: ${e}`) assert.deepEqual(JSON.parse(sb.read('.cursor/mcp.json')), { mcpServers: { box: { url: DEFAULT_MCP_URL } } }) @@ -127,3 +129,19 @@ test('real CLI: example prompts only after something was set up', () => { const doctor = run(['doctor', '--client', 'cursor'], sb) assert.ok(!doctor.stdout.includes(EXAMPLE_PROMPTS[0]), 'doctor shows no example prompts') }) + +test('real CLI: a config it may not write gets a clear message (and the Codex app steps)', { skip: (process.platform === 'win32' || process.getuid?.() === 0) && 'needs POSIX permissions and a non-root user' }, () => { + const sb = sandbox() + sb.write('.codex/config.toml', 'model = "gpt-5"\n') + fs.chmodSync(path.join(sb.home, '.codex'), 0o500) + try { + const res = run(['connect', '--client', 'codex', '--yes'], sb) + assert.notEqual(res.status, 0) + const out = res.stdout + res.stderr + assert.match(out, /permission denied/) + assert.match(out, /Settings -> MCP servers -> Add server, name it box, choose Streamable HTTP, paste https:\/\/relay\.simular\.cloud\/mcp/) + assert.match(out, /click Authenticate next to box/) + } finally { + fs.chmodSync(path.join(sb.home, '.codex'), 0o700) + } +}) diff --git a/cli/test/clients.test.js b/cli/test/clients.test.js index 643a368..45edf5d 100644 --- a/cli/test/clients.test.js +++ b/cli/test/clients.test.js @@ -7,6 +7,7 @@ import YAML from 'yaml' import * as jsonc from 'jsonc-parser' import { findClient } from '../src/clients/index.js' import { DEFAULT_MCP_URL } from '../src/constants.js' +import { createContext } from '../src/context.js' import { FAKE_CLAUDE, FAKE_OPENCLAW, fixture, sandbox } from './helpers.js' const KEY = 'cb_test_fake_key_123' @@ -104,16 +105,67 @@ test('opencode: prefers opencode.jsonc, writes a remote entry, keeps comments', assert.equal(sb.read('.config/opencode/opencode.json'), null) }) +// add-mcp picks the platform's own folder (Application Support, %APPDATA%, XDG), so ask it where. +const rel = (sb, id) => path.relative(sb.home, client(id).configLocation(sb.ctx)) + test('vscode: writes servers.box with type http next to other servers', () => { const sb = sandbox() - sb.write('.config/Code/User/mcp.json', fixture('vscode.before.json')) + const file = rel(sb, 'vscode') + sb.write(file, fixture('vscode.before.json')) client('vscode').connect(sb.ctx) - const doc = JSON.parse(sb.read('.config/Code/User/mcp.json')) + const doc = JSON.parse(sb.read(file)) assert.deepEqual(doc.servers.box, { type: 'http', url: DEFAULT_MCP_URL }) assert.ok(doc.servers.playwright) assert.deepEqual(doc.inputs, []) }) +test('opencode: stops when XDG_CONFIG_HOME points elsewhere instead of writing a file OpenCode never reads', () => { + const sb = sandbox() + const ctx = createContext({ env: { ...sb.env, XDG_CONFIG_HOME: path.join(sb.home, 'xdg') }, platform: 'linux' }) + assert.throws(() => client('opencode').connect(ctx), /Add box by hand/) + assert.deepEqual(fs.readdirSync(sb.home).sort(), ['.bin']) +}) + +test('opencode: an empty mcp.servers map (newer layout) gets box there', () => { + const sb = sandbox() + sb.write('.config/opencode/opencode.json', '{ "mcp": { "servers": {} } }\n') + client('opencode').connect(sb.ctx) + const doc = jsonc.parse(sb.read('.config/opencode/opencode.json')) + assert.equal(doc.mcp.servers.box.url, DEFAULT_MCP_URL) + assert.equal(doc.mcp.box, undefined) + assert.deepEqual(client('opencode').connect(sb.ctx).actions, []) +}) + +test('add-mcp failing part-way restores the file and keeps no stray backup', { skip: (WIN || process.getuid?.() === 0) && 'needs POSIX permissions and a non-root user' }, () => { + const sb = sandbox() + const original = '{ "mcpServers": { "other": { "url": "https://example.com/mcp" } } }\n' + const file = sb.write('.cursor/mcp.json', original) + fs.chmodSync(file, 0o400) + try { + assert.throws(() => client('cursor').connect(sb.ctx), (e) => e.code === 'EACCES') + } finally { + fs.chmodSync(file, 0o600) + } + assert.equal(sb.read('.cursor/mcp.json'), original) + assert.deepEqual(backups(path.dirname(file)), []) +}) + +test('gemini-cli: writes mcpServers.box as an http server and removes it', () => { + const sb = sandbox() + sb.write('.gemini/settings.json', '{\n // mine\n "theme": "Default",\n "mcpServers": { "other": { "httpUrl": "https://example.com/mcp" } }\n}\n') + const res = client('gemini').connect(sb.ctx) + assert.match(res.next[0], /\/mcp auth box/) + const text = sb.read('.gemini/settings.json') + assert.match(text, /\/\/ mine/) + const doc = jsonc.parse(text) + assert.deepEqual(doc.mcpServers.box, { type: 'http', url: DEFAULT_MCP_URL }) + assert.equal(doc.theme, 'Default') + assert.deepEqual(client('gemini').connect(sb.ctx).actions, []) + assert.equal(client('gemini-cli').status(sb.ctx).entries[0].url, DEFAULT_MCP_URL) + client('gemini-cli').remove(sb.ctx) + assert.deepEqual(Object.keys(jsonc.parse(sb.read('.gemini/settings.json')).mcpServers), ['other']) +}) + test('hermes: OAuth entry keeps comments; key mode puts the key only in .env (0600)', () => { const sb = sandbox() sb.write('.hermes/config.yaml', fixture('hermes.before.yaml')) @@ -208,7 +260,7 @@ test('claude-desktop: writes nothing, prints connector steps', () => { test('dry run writes nothing for file-based clients', () => { const sb = sandbox() const ctx = { ...sb.ctx, dryRun: true } - for (const id of ['cursor', 'codex', 'opencode', 'vscode', 'hermes']) { + for (const id of ['cursor', 'codex', 'opencode', 'vscode', 'gemini-cli', 'hermes']) { const res = client(id).connect(ctx, { mode: 'oauth' }) assert.ok(res.actions[0].startsWith('would'), id) } @@ -221,12 +273,14 @@ const BOX_COM = 'https://mcp.box.com' test('json clients: a foreign box blocks connect, survives remove, and --name installs next to it', () => { const cases = [ - ['cursor', '.cursor/mcp.json', ['mcpServers']], - ['opencode', '.config/opencode/opencode.json', ['mcp']], - ['vscode', '.config/Code/User/mcp.json', ['servers']], + ['cursor', ['mcpServers']], + ['opencode', ['mcp']], + ['vscode', ['servers']], + ['gemini-cli', ['mcpServers']], ] - for (const [id, rel, container] of cases) { + for (const [id, container] of cases) { const sb = sandbox() + const rel = path.relative(sb.home, client(id).configLocation(sb.ctx)) const foreign = { url: BOX_COM, headers: { Authorization: 'Bearer box-com-token' } } const original = JSON.stringify(container.reduceRight((acc, k) => ({ [k]: acc }), { box: foreign }), null, 2) sb.write(rel, original)