Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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

Expand Down
1 change: 1 addition & 0 deletions cli/e2e/clients.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
9 changes: 9 additions & 0 deletions cli/e2e/install-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } } }),
Expand Down
69 changes: 69 additions & 0 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand All @@ -45,6 +46,7 @@
"codex",
"cursor",
"opencode",
"gemini-cli",
"hermes",
"openclaw"
],
Expand Down
64 changes: 64 additions & 0 deletions cli/src/add-mcp-worker.js
Original file line number Diff line number Diff line change
@@ -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 }))
}
})
10 changes: 10 additions & 0 deletions cli/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
Expand Down
Loading
Loading