Skip to content
Merged
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

[box](https://box.simular.ai) gives your AI agent a computer of its own: a cloud Linux desktop with Chrome and a shell that it drives over MCP, while your own computer stays free. You can watch it live and take over at https://box.simular.ai/box.

Most agents need no plugin: add the MCP server `https://relay.simular.cloud/mcp` and sign in once (see https://box.simular.ai/install). This repository holds a package for each agent that installs box its own way. Each folder stands alone and is released through that agent's channel.
Most agents need no plugin: add the MCP server `https://mcp.box.simular.ai/mcp` and sign in once (see https://box.simular.ai/install). This repository holds a package for each agent that installs box its own way. Each folder stands alone and is released through that agent's channel.

| Agent | Folder | Sign-in | Install |
| --- | --- | --- | --- |
| 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

Add a folder named after the agent with its own manifest, MCP config and, where the agent supports skills, a copy of `claude/skills/box/SKILL.md`. Point it at `https://relay.simular.cloud/mcp`. Use OAuth sign-in where the agent supports it; otherwise read a personal key from an environment variable or the agent's own credentials store, never from a file in the package. Add a row above.
Add a folder named after the agent with its own manifest, MCP config and, where the agent supports skills, a copy of `claude/skills/box/SKILL.md`. Point it at `https://mcp.box.simular.ai/mcp`. Use OAuth sign-in where the agent supports it; otherwise read a personal key from an environment variable or the agent's own credentials store, never from a file in the package. Add a row above.

## Releasing

- `claude/`: bump `version` in `claude/.claude-plugin/plugin.json`; Claude Code picks it up from the marketplace.
- `claude/`: bump `version` in `claude/.claude-plugin/plugin.json`. Claude Code doesn't auto-update third-party marketplaces: users get it with `/plugin marketplace update simular-box` and `/plugin update box@simular-box`, or with `npx @simular-ai/box connect`.
- `dsh/`: bump `version` in `dsh/package.json`, then push a tag `dsh-vX.Y.Z`. The release workflow attaches `dsh-box.tgz`.
- `hermes/`: bump `version` in `hermes/plugin.json`. Users copy the folder by hand until box is in the Hermes plugin catalog.
- `cli/`: bump `version` in `cli/package.json`, then push a tag `cli-vX.Y.Z`. The `publish-cli.yml` workflow tests it and publishes `@simular-ai/box` to npm through trusted publishing (OIDC), with provenance; the one-time npm setup is in [cli/README.md](cli/README.md#releasing). When the box endpoint moves, change `DEFAULT_MCP_URL` in `cli/src/constants.js` and release.
- `cli/`: bump `version` in `cli/package.json`, then push a tag `cli-vX.Y.Z`. The `publish-cli.yml` workflow tests it and publishes `@simular-ai/box` to npm through trusted publishing (OIDC), with provenance; the one-time npm setup is in [cli/README.md](cli/README.md#releasing). When the box endpoint moves, change `DEFAULT_MCP_URL` in `cli/src/constants.js`, add the old host to `OLD_HOSTS` there, change the URL in every plugin folder, and release each one.
2 changes: 1 addition & 1 deletion claude/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "box",
"displayName": "box by Simular",
"version": "0.1.0",
"version": "0.1.1",
"description": "Give Claude a computer of its own: a cloud Linux desktop with Chrome and a shell that it drives over MCP, while your own computer stays free. You sign in once in the browser; no key to paste.",
"author": {
"name": "Simular",
Expand Down
2 changes: 1 addition & 1 deletion claude/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"mcpServers": {
"box": {
"type": "http",
"url": "https://relay.simular.cloud/mcp"
"url": "https://mcp.box.simular.ai/mcp"
}
}
}
13 changes: 7 additions & 6 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Connect [box](https://box.simular.ai) to your AI apps in one command. box gives
npx @simular-ai/box connect
```

The CLI finds the AI apps on this computer, asks which ones should use box, and adds the box MCP server (`https://relay.simular.cloud/mcp`) to each one in its own format. Other servers and settings in those files are left alone, each changed file gets a backup next to it (`*.box-backup-<time>`), and running it again changes nothing.
The CLI finds the AI apps on this computer, asks which ones should use box, and adds the box MCP server (`https://mcp.box.simular.ai/mcp`) to each one in its own format. Other servers and settings in those files are left alone, each changed file gets a backup next to it (`*.box-backup-<time>`), and running it again changes nothing.

```bash
npx @simular-ai/box doctor # is box set up right in each app? is the endpoint up? is the key valid?
Expand All @@ -20,8 +20,9 @@ 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 |
| 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)` | 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` |
Expand All @@ -30,7 +31,7 @@ The command is `simular-box` (`box` belongs to box.com's CLI), so after `npm i -

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 (`mcp.box.simular.ai`, the old `relay.simular.cloud`, its staging host, 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 All @@ -49,15 +50,15 @@ Without `BOX_TOKEN`, an interactive run asks for the key with hidden input. The

`doctor` checks, for each app it finds:

- box is configured, points at the current URL, and no old `box-control` server is left
- the endpoint answers an unauthenticated request with `401` and a `WWW-Authenticate` link to its sign-in metadata
- box is configured, points at the current URL (not the old `relay.simular.cloud`), and no old `box-control` server is left
- the endpoint answers an unauthenticated request with `401` and a `WWW-Authenticate` link to its sign-in metadata, and that metadata names the same URL
- a configured personal key is accepted (the key is sent to the box endpoint only, never printed)

It prints a fix for each problem. `--json` prints a machine-readable report; the exit code is 1 when something needs fixing. OAuth sign-ins are stored by each app, so doctor can't see whether you've signed in.

## Other endpoints

`BOX_MCP_URL` points everything at another endpoint (for example staging): `BOX_MCP_URL=https://… npx @simular-ai/box connect`. When box's endpoint moves, a new CLI version changes the default and `connect` updates each app.
`BOX_MCP_URL` points everything at another endpoint (for example staging): `BOX_MCP_URL=https://… npx @simular-ai/box connect`. When box's endpoint moves, a new CLI version changes the default and `connect` updates each app: it rewrites a `box` entry that points at the old address and, for Claude Code, updates the `box@simular-box` plugin. Apps may then ask you to sign in to box again. box moved from `relay.simular.cloud` to `mcp.box.simular.ai` on 2026-11-04; `doctor` flags an app still on the old address.

## Development

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
86 changes: 73 additions & 13 deletions cli/e2e/install-matrix.mjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
// Layer 1: run the real CLI against a real, installed client in an isolated HOME.
// node e2e/install-matrix.mjs <client>
// Seeds an unrelated server, runs `connect --client <client> --yes`, checks with the client's own
// listing command that box is there, re-runs connect (must change nothing), runs `doctor`, then
// `remove`, and checks that the unrelated server survived. No secrets, no sign-in.
// Seeds an unrelated server and a box set up at box's old address (relay.simular.cloud), runs
// `connect --client <client> --yes`, checks that it moved box to the current URL and said so, checks
// with the client's own listing command that box is there, re-runs connect (must change nothing),
// runs `doctor`, then `remove`, and checks that the unrelated server survived. No secrets, no sign-in.
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import spawn from 'cross-spawn'
import { isolatedEnv } from './isolated-env.mjs'
import { createContext } from '../src/context.js'
import { findClient } from '../src/clients/index.js'

const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'simular-box.js')
const URL_RE = /relay\.simular\.cloud\/mcp/
const URL_RE = /mcp\.box\.simular\.ai\/mcp/
const OLD_URL = 'https://relay.simular.cloud/mcp'
const REPO = path.join(path.dirname(CLI), '..', '..')
const id = process.argv[2]
const client = findClient(id)
if (!client) throw new Error(`unknown client ${id}`)
Expand Down Expand Up @@ -41,6 +44,39 @@ const writeJson = (file, value) => {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, JSON.stringify(value, null, 2))
}

// Claude Code: install the plugin as it was before the move from a git marketplace built from this
// checkout (so the job tests this branch's plugin, not main's), then push this branch's plugin to
// it, as a merge to main would. connect must then update the marketplace and the plugin. A fake
// https URL is mapped to the local repo with git's url.<base>.insteadOf, because Claude Code adds
// git marketplaces only from https URLs.
const FAKE_MARKETPLACE = 'https://box-plugin.invalid/box-plugin.git'
function seedOldClaudePlugin() {
const work = path.join(home, 'box-plugin-market')
const bare = path.join(home, 'box-plugin-market.git')
const git = (...args) => sh('git', ['-C', work, '-c', 'user.name=box e2e', '-c', 'user.email=e2e@box.invalid', '-c', 'commit.gpgsign=false', ...args])
const copy = () => {
for (const d of ['.claude-plugin', 'claude']) fs.cpSync(path.join(REPO, d), path.join(work, d), { recursive: true })
}
copy()
const mcp = path.join(work, 'claude', '.mcp.json')
fs.writeFileSync(mcp, fs.readFileSync(mcp, 'utf8').replace(URL_RE, 'relay.simular.cloud/mcp'))
const manifest = path.join(work, 'claude', '.claude-plugin', 'plugin.json')
fs.writeFileSync(manifest, JSON.stringify({ ...JSON.parse(fs.readFileSync(manifest, 'utf8')), version: '0.0.1' }, null, 2))
sh('git', ['init', '--quiet', '--initial-branch', 'main', work])
git('add', '-A')
git('commit', '--quiet', '-m', 'before the move')
sh('git', ['clone', '--quiet', '--bare', work, bare])
Object.assign(env, { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `url.${pathToFileURL(bare).href}.insteadOf`, GIT_CONFIG_VALUE_0: FAKE_MARKETPLACE })
sh('claude', ['plugin', 'marketplace', 'add', FAKE_MARKETPLACE])
sh('claude', ['plugin', 'install', 'box@simular-box', '--scope', 'user'])
assert.match(sh('claude', ['plugin', 'list', '--json']), /relay\.simular\.cloud\/mcp/, 'the old plugin is installed')
copy()
git('add', '-A')
git('commit', '--quiet', '-m', 'this branch')
git('push', '--quiet', bare, 'HEAD:main')
}

function backups() {
const found = []
const walk = (dir) => {
Expand All @@ -57,31 +93,45 @@ function backups() {
const ADAPTERS = {
'claude-code': {
bin: 'claude',
seed: () => sh('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'other', 'https://example.com/mcp']),
seed: () => {
sh('claude', ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'other', 'https://example.com/mcp'])
seedOldClaudePlugin()
},
list: () => sh('claude', ['mcp', 'list']),
present: (out) => /plugin:box:box/.test(out) && URL_RE.test(out),
needsAuth: /needs authentication/i,
otherKept: (out) => /\bother\b/.test(out),
},
codex: {
bin: 'codex',
seed: () => sh('codex', ['mcp', 'add', 'other', '--', 'npx', '-y', 'other-mcp']),
seed: () => {
sh('codex', ['mcp', 'add', 'other', '--', 'npx', '-y', 'other-mcp'])
// `codex mcp add --url` would start the sign-in, so the old entry is written by hand.
fs.appendFileSync(client.configLocation(ctx), `\n[mcp_servers.box]\nurl = "${OLD_URL}"\n`)
},
list: () => sh('codex', ['mcp', 'list']),
present: (out) => /^box\s+https:\/\/relay\.simular\.cloud\/mcp/m.test(out),
present: (out) => /^box\s+https:\/\/mcp\.box\.simular\.ai\/mcp/m.test(out),
needsAuth: /not logged in/i,
otherKept: (out) => /^other\s/m.test(out),
},
opencode: {
bin: 'opencode',
seed: () => writeJson(client.configLocation(ctx), { $schema: 'https://opencode.ai/config.json', mcp: { other: { type: 'remote', url: 'https://example.com/mcp', enabled: false } } }),
seed: () =>
writeJson(client.configLocation(ctx), {
$schema: 'https://opencode.ai/config.json',
mcp: { other: { type: 'remote', url: 'https://example.com/mcp', enabled: false }, box: { type: 'remote', url: OLD_URL, enabled: true } },
}),
list: () => sh('opencode', ['mcp', 'list']),
present: (out) => /\bbox\b/.test(out) && URL_RE.test(out),
needsAuth: /needs authentication/i,
otherKept: (out) => /\bother\b/.test(out),
},
openclaw: {
bin: 'openclaw',
seed: () => sh('openclaw', ['mcp', 'set', 'other', JSON.stringify({ command: 'npx', args: ['-y', 'other-mcp'] })]),
seed: () => {
sh('openclaw', ['mcp', 'set', 'other', JSON.stringify({ command: 'npx', args: ['-y', 'other-mcp'] })])
sh('openclaw', ['mcp', 'set', 'box', JSON.stringify({ url: OLD_URL, transport: 'streamable-http', auth: 'oauth' })])
},
list: () => sh('openclaw', ['mcp', 'list']) + sh('openclaw', ['mcp', 'show', 'box', '--json'], { allowFail: true }),
present: (out) => /-\s+box\b/.test(out) && /streamable-http/.test(out) && URL_RE.test(out),
otherKept: (out) => /-\s+other\b/.test(out),
Expand All @@ -91,15 +141,24 @@ const ADAPTERS = {
seed: () => {
const file = client.configLocation(ctx)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, '# seeded by the e2e test\nmcp_servers:\n other:\n command: npx\n args: [-y, other-mcp]\n enabled: false\n')
fs.writeFileSync(file, `# seeded by the e2e test\nmcp_servers:\n other:\n command: npx\n args: [-y, other-mcp]\n enabled: false\n box:\n url: ${OLD_URL}\n auth: oauth\n`)
},
list: () => sh('hermes', ['mcp', 'list']),
present: (out) => /\bbox\s+https:\/\/relay\.simular/.test(out),
present: (out) => /\bbox\s+https:\/\/mcp\.box\.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' }, box: { type: 'http', url: OLD_URL } } }),
// 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:\/\/mcp\.box\.simular\.ai\/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' } } }),
seed: () => writeJson(client.configLocation(ctx), { mcpServers: { other: { url: 'https://example.com/mcp' }, box: { url: OLD_URL } } }),
list: () => sh('cursor-agent', ['mcp', 'list']),
present: (out) => /^box:/m.test(out),
otherKept: (out) => /^other:/m.test(out),
Expand All @@ -113,6 +172,7 @@ if (!ctx.which(a.bin)) throw new Error(`${a.bin} is not on PATH; install the cli
a.seed()
let out = box('connect', '--client', client.id, '--yes')
assert.match(out, new RegExp(`${client.name}: `), 'connect reports the client')
assert.match(out, new RegExp(`box moved from ${OLD_URL.replace(/[.]/g, '\\.')} to https://mcp\\.box\\.simular\\.ai/mcp\\. ${client.name} may ask you to sign in to box again`), 'connect moved box off the old address and said so')

out = a.list()
assert.ok(a.present(out), `${a.bin}'s own listing shows box at the box URL`)
Expand Down
Loading
Loading