From 2a820dcbfd0cc56db4d2b155c6098b5c6d09d297 Mon Sep 17 00:00:00 2001 From: 011235813 <18132897+011235813@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:19:39 -0800 Subject: [PATCH 1/5] Claude plugin: rename box to simular-box for the plugin directory The directory already has a plugin named box. connect installs simular-box@simular-box and then uninstalls the old box@simular-box; remove and doctor know the old name. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- README.md | 6 ++-- claude/.claude-plugin/plugin.json | 4 +-- claude/skills/box-offload/SKILL.md | 2 +- cli/README.md | 6 ++-- cli/e2e/install-matrix.mjs | 14 +++++--- cli/src/clients/claude-code.js | 58 ++++++++++++++++++++++-------- cli/src/offload/commands.js | 2 +- cli/test/clients.test.js | 46 ++++++++++++++++++------ 9 files changed, 99 insertions(+), 41 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 00d5b33..9c31582 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ }, "plugins": [ { - "name": "box", + "name": "simular-box", "source": "./claude", "description": "box by Simular: a cloud computer your agent drives over MCP." } diff --git a/README.md b/README.md index ceba78c..6751097 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Most agents need no plugin: add the MCP server `https://mcp.box.simular.ai/mcp` | 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` | +| Claude Code and Cowork | [`claude/`](claude/) | OAuth, once in the browser | `/plugin marketplace add simular-ai/box-plugin`, then `/plugin install simular-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) | | OpenClaw | [`openclaw/`](openclaw/) | OAuth, once from the plugin's page in Settings | `openclaw plugins install @simular-ai/openclaw-box` | | 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`) | @@ -15,7 +15,7 @@ Most agents need no plugin: add the MCP server `https://mcp.box.simular.ai/mcp` ## Offload: move a coding task into your box and back -Going offline in the middle of a task? In Claude Code with the plugin, `/box:offload` hands the task to a fresh Claude Code (or Codex, Cursor) session in your box, which keeps working while you are away; `/box:back` brings its commits and its written hand-over home and merges them. Any other agent or a plain terminal uses `npx @simular-ai/box offload` and `npx @simular-ai/box back`. Setup and details: [cli/README.md](cli/README.md#offload). +Going offline in the middle of a task? In Claude Code with the plugin, `/simular-box:offload` hands the task to a fresh Claude Code (or Codex, Cursor) session in your box, which keeps working while you are away; `/simular-box:back` brings its commits and its written hand-over home and merges them. Any other agent or a plain terminal uses `npx @simular-ai/box offload` and `npx @simular-ai/box back`. Setup and details: [cli/README.md](cli/README.md#offload). ## Adding an agent @@ -23,7 +23,7 @@ Add a folder named after the agent with its own manifest, MCP config and, where ## Releasing -- `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`. +- `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 simular-box@simular-box`, or with `npx @simular-ai/box connect`. - `openclaw/`: bump `version` in `openclaw/package.json` and `openclaw/openclaw.plugin.json`, then `npm run build` and `clawhub package publish .` from `openclaw/`. - `dsh/`: bump `version` in `dsh/package.json`, then push a tag `dsh-vX.Y.Z`. The release workflow attaches `dsh-box.tgz`. - `openai/`: bump `version` in `openai/plugin.json` and `publication.release_notes`, run `openai/build-zip.sh`, upload the ZIP at platform.openai.com/plugins. diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 4901979..f56b1eb 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "box", + "name": "simular-box", "displayName": "box by Simular", - "version": "0.2.3", + "version": "0.3.0", "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", diff --git a/claude/skills/box-offload/SKILL.md b/claude/skills/box-offload/SKILL.md index 49e1215..97b9c11 100644 --- a/claude/skills/box-offload/SKILL.md +++ b/claude/skills/box-offload/SKILL.md @@ -33,7 +33,7 @@ when you pass one to the user, write it with `box` (the npx command above) inste - For a secret it warns about, ask the user; only they opt one in (`box offload secrets add file:.env`). Never opt in on your own. 4. When it prints `offloaded`, tell the user the box, that its agent runs with permission - prompts bypassed, and that `/box:back` returns the work. End your turn and make no more + prompts bypassed, and that `/simular-box:back` returns the work. End your turn and make no more changes to this repo. 5. On exit 1, report its last line. Nothing changed on this computer; do not retry by hand. diff --git a/cli/README.md b/cli/README.md index 664c5ae..01ef596 100644 --- a/cli/README.md +++ b/cli/README.md @@ -19,7 +19,7 @@ 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 | +| Claude Code | `claude-code` | Installs the [`claude/`](../claude/) plugin (box server + box skill) with `claude plugin install simular-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`, 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) | @@ -66,7 +66,7 @@ npx @simular-ai/box offload install-cli --kind claude # once per box: the npx @simular-ai/box offload sign-in --kind claude # once per box: Claude Code's own login, run in the box ``` -Then, in Claude Code with the `box` plugin, `/box:offload` and later `/box:back`. Elsewhere, write the hand-over to the path `offload handover-path` prints (or paste the request `offload --handover-prompt --kind codex` prints into your agent), then: +Then, in Claude Code with the `simular-box` plugin, `/simular-box:offload` and later `/simular-box:back`. Elsewhere, write the hand-over to the path `offload handover-path` prints (or paste the request `offload --handover-prompt --kind codex` prints into your agent), then: ```bash npx @simular-ai/box offload --kind claude # preflight, copy the repo, start the box agent from the hand-over @@ -85,7 +85,7 @@ How it works: ## Other endpoints -`BOX_MCP_URL` points everything at another endpoint (for example staging): `BOX_MCP_URL=https://… npx @simular-ai/box connect`. Offload, `login` and `rsh` use its origin, or `BOX_ENDPOINT` when set. 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. +`BOX_MCP_URL` points everything at another endpoint (for example staging): `BOX_MCP_URL=https://… npx @simular-ai/box connect`. Offload, `login` and `rsh` use its origin, or `BOX_ENDPOINT` when set. 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 `simular-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 diff --git a/cli/e2e/install-matrix.mjs b/cli/e2e/install-matrix.mjs index 5ad0a07..57bc0c7 100644 --- a/cli/e2e/install-matrix.mjs +++ b/cli/e2e/install-matrix.mjs @@ -45,9 +45,10 @@ const writeJson = (file, value) => { 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 +// Claude Code: install the plugin as it was before the move and the rename (named `box`, at the old +// URL) 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 +// uninstall box@simular-box, update the marketplace and install simular-box@simular-box. A fake // https URL is mapped to the local repo with git's url..insteadOf, because Claude Code adds // git marketplaces only from https URLs. const FAKE_MARKETPLACE = 'https://box-plugin.invalid/box-plugin.git' @@ -62,7 +63,10 @@ function seedOldClaudePlugin() { 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)) + fs.writeFileSync(manifest, JSON.stringify({ ...JSON.parse(fs.readFileSync(manifest, 'utf8')), name: 'box', version: '0.0.1' }, null, 2)) + const market = path.join(work, '.claude-plugin', 'marketplace.json') + const m = JSON.parse(fs.readFileSync(market, 'utf8')) + fs.writeFileSync(market, JSON.stringify({ ...m, plugins: m.plugins.map((p) => ({ ...p, name: 'box' })) }, null, 2)) sh('git', ['init', '--quiet', '--initial-branch', 'main', work]) git('add', '-A') git('commit', '--quiet', '-m', 'before the move') @@ -98,7 +102,7 @@ const ADAPTERS = { seedOldClaudePlugin() }, list: () => sh('claude', ['mcp', 'list']), - present: (out) => /plugin:box:box/.test(out) && URL_RE.test(out), + present: (out) => /plugin:simular-box:box/.test(out) && URL_RE.test(out), needsAuth: /needs authentication/i, otherKept: (out) => /\bother\b/.test(out), }, diff --git a/cli/src/clients/claude-code.js b/cli/src/clients/claude-code.js index 30a9a6a..4838f5b 100644 --- a/cli/src/clients/claude-code.js +++ b/cli/src/clients/claude-code.js @@ -9,7 +9,10 @@ import { DEFAULT_MCP_URL, ForeignServerError, LEGACY_SERVER_NAMES, SERVER_NAME } const MARKETPLACE_SOURCE = 'simular-ai/box-plugin' const MARKETPLACE = 'simular-box' -const PLUGIN = `box@${MARKETPLACE}` +const PLUGIN = `simular-box@${MARKETPLACE}` +// The plugin was called `box` until 2026-11; the Claude plugin directory already had a `box`. +// connect replaces an installed old plugin with the new one, and remove takes both. +const LEGACY_PLUGINS = [`box@${MARKETPLACE}`] function configDir(ctx) { return ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, '.claude') @@ -27,16 +30,28 @@ function readUserServers(ctx) { } } -function pluginState(ctx) { +function listPlugins(ctx) { const res = ctx.run('claude', ['plugin', 'list', '--json']) - if (res.status !== 0) return null + if (res.status !== 0) return [] try { - return JSON.parse(res.stdout).find((p) => p.id === PLUGIN) || null + return JSON.parse(res.stdout) } catch { - return null + return [] } } +function pluginState(ctx) { + return listPlugins(ctx).find((p) => p.id === PLUGIN) || null +} + +function legacyPlugins(ctx) { + return listPlugins(ctx).filter((p) => LEGACY_PLUGINS.includes(p.id)) +} + +function scopeArgs(plugin) { + return ['user', 'project', 'local'].includes(plugin.scope) ? ['--scope', plugin.scope] : [] +} + function must(res, what) { if (res.status !== 0) throw new Error(`${what} failed: ${(res.stderr || res.stdout).trim().split('\n').pop()}`) } @@ -59,17 +74,22 @@ export default { status(ctx) { const user = readUserServers(ctx) const mine = (n) => user[n] && ctx.ours(user[n].url) - const plugin = ctx.which('claude') ? pluginState(ctx) : null + const hasClaude = ctx.which('claude') + const plugin = hasClaude ? pluginState(ctx) : null + const old = hasClaude ? legacyPlugins(ctx) : [] const entries = [] // A disabled plugin next to a user-scope server is how connect sets up a BOX_MCP_URL override. if (plugin && (plugin.enabled || !mine(ctx.name))) { entries.push({ where: `plugin ${PLUGIN}${plugin.enabled ? '' : ' (disabled)'}`, url: plugin.mcpServers?.box?.url, enabled: plugin.enabled }) } + // An old-named plugin still serves box (and its URL tells connect whether box moved). + for (const p of old.filter((p) => p.enabled)) entries.push({ where: `plugin ${p.id}`, url: p.mcpServers?.box?.url, enabled: true }) if (mine(ctx.name)) entries.push({ where: `user scope in ${userConfigFile(ctx)}`, url: user[ctx.name].url, enabled: true }) - // The plugin's server is named plugin:box:box, so a foreign user-scope `box` only matters - // when connect would add a user-scope server itself (BOX_MCP_URL set). + // The plugin's server is named plugin:simular-box:box, so a foreign user-scope `box` only + // matters when connect would add a user-scope server itself (BOX_MCP_URL set). const foreign = user[ctx.name] && !mine(ctx.name) && ctx.url !== DEFAULT_MCP_URL ? { where: userConfigFile(ctx), url: user[ctx.name].url } : undefined - return { entries, legacy: LEGACY_SERVER_NAMES.filter(mine), keyConfigured: false, foreign } + const legacy = [...LEGACY_SERVER_NAMES.filter(mine), ...old.map((p) => `plugin ${p.id}`)] + return { entries, legacy, keyConfigured: false, foreign } }, connect(ctx) { @@ -95,6 +115,9 @@ export default { const list = ctx.run('claude', ['plugin', 'marketplace', 'list', '--json']) if (!list.stdout.includes(`"${MARKETPLACE}"`)) { must(ctx.run('claude', ['plugin', 'marketplace', 'add', MARKETPLACE_SOURCE], { timeoutMs: 180000 }), 'claude plugin marketplace add') + } else { + // A marketplace added before the rename lists only the old plugin name. + must(ctx.run('claude', ['plugin', 'marketplace', 'update', MARKETPLACE], { timeoutMs: 180000 }), 'claude plugin marketplace update') } must(ctx.run('claude', ['plugin', 'install', PLUGIN, '--scope', 'user'], { timeoutMs: 180000 }), 'claude plugin install') } @@ -106,8 +129,7 @@ export default { if (pinned && pinned !== DEFAULT_MCP_URL) { if (!ctx.dryRun) { must(ctx.run('claude', ['plugin', 'marketplace', 'update', MARKETPLACE], { timeoutMs: 180000 }), 'claude plugin marketplace update') - const scope = ['user', 'project', 'local'].includes(plugin.scope) ? ['--scope', plugin.scope] : [] - must(ctx.run('claude', ['plugin', 'update', PLUGIN, ...scope], { timeoutMs: 180000 }), 'claude plugin update') + must(ctx.run('claude', ['plugin', 'update', PLUGIN, ...scopeArgs(plugin)], { timeoutMs: 180000 }), 'claude plugin update') const now = pluginState(ctx)?.mcpServers?.box?.url if (now !== DEFAULT_MCP_URL) { throw new Error( @@ -144,7 +166,13 @@ export default { actions.push(`disabled plugin ${PLUGIN} (it points at the default URL)`) } } - const label = ctx.url === DEFAULT_MCP_URL ? 'plugin:box:box' : ctx.name + // The old-named plugin goes on either route, and only once box is set up the new way, so a + // failed install leaves the user with the old plugin rather than with no box at all. + for (const p of legacyPlugins(ctx)) { + if (!ctx.dryRun) must(ctx.run('claude', ['plugin', 'uninstall', p.id, ...scopeArgs(p)]), 'claude plugin uninstall') + actions.push(`${ctx.dryRun ? 'would uninstall' : 'uninstalled'} old plugin ${p.id}`) + } + const label = ctx.url === DEFAULT_MCP_URL ? 'plugin:simular-box:box' : ctx.name return { actions, next: [`${updated ? 'Restart Claude Code (a plugin update applies on restart), then run' : 'Start Claude Code, run'} /mcp, pick ${label} and choose Authenticate; sign in and click Approve in the browser.`], @@ -165,9 +193,9 @@ export default { if (!ctx.dryRun) ctx.run('claude', ['mcp', 'remove', '--scope', 'user', name]) actions.push(`${ctx.dryRun ? 'would remove' : 'removed'} user-scope server ${name}`) } - if (pluginState(ctx)) { - if (!ctx.dryRun) must(ctx.run('claude', ['plugin', 'uninstall', PLUGIN]), 'claude plugin uninstall') - actions.push(`${ctx.dryRun ? 'would uninstall' : 'uninstalled'} plugin ${PLUGIN}`) + for (const p of listPlugins(ctx).filter((p) => p.id === PLUGIN || LEGACY_PLUGINS.includes(p.id))) { + if (!ctx.dryRun) must(ctx.run('claude', ['plugin', 'uninstall', p.id, ...scopeArgs(p)]), 'claude plugin uninstall') + actions.push(`${ctx.dryRun ? 'would uninstall' : 'uninstalled'} plugin ${p.id}`) } return { actions, warnings, next: [] } }, diff --git a/cli/src/offload/commands.js b/cli/src/offload/commands.js index 833e215..27b2f04 100644 --- a/cli/src/offload/commands.js +++ b/cli/src/offload/commands.js @@ -258,7 +258,7 @@ export async function offload(ctx, flags, rest, io = {}) { stdout.write(` sent ${(outcome.sent / 1e6).toFixed(1)} MB; ${outcome.timings.map(([s, ms]) => `${s} ${(ms / 1000).toFixed(1)} s`).join(', ')}\n`) if (!outcome.accepted) stdout.write(` the box CLI has not taken the hand-over yet; watch it with \`${CMD} offload status\`\n`) stdout.write(` watch its terminal: ${CMD} rsh -t --box ${box.name} tmux attach -t ${tmuxSession(r)}\n`) - stdout.write(`Bring it back with /box:back or \`${CMD} back\`.\n`) + stdout.write(`Bring it back with /simular-box:back or \`${CMD} back\`.\n`) return 0 } finally { connection.close() diff --git a/cli/test/clients.test.js b/cli/test/clients.test.js index b94c8e9..faa6e51 100644 --- a/cli/test/clients.test.js +++ b/cli/test/clients.test.js @@ -209,7 +209,7 @@ test('claude-code: default URL installs the plugin from the marketplace; overrid sb.write('.claude.json', JSON.stringify({ mcpServers: { 'box-control': { url: 'https://relay.simular.cloud/control' }, box: { type: 'http', url: DEFAULT_MCP_URL }, other: { command: 'y' } } })) client('claude').connect(sb.ctx) const state = () => JSON.parse(sb.read('fake-claude-state.json')) - assert.deepEqual(state().plugins, { 'box@simular-box': { enabled: true, url: DEFAULT_MCP_URL } }) + assert.deepEqual(state().plugins, { 'simular-box@simular-box': { enabled: true, url: DEFAULT_MCP_URL } }) assert.deepEqual(Object.keys(JSON.parse(sb.read('.claude.json')).mcpServers), ['other']) assert.ok(state().calls.includes('plugin marketplace add simular-ai/box-plugin')) assert.deepEqual(client('claude').connect(sb.ctx).actions, []) @@ -217,7 +217,7 @@ test('claude-code: default URL installs the plugin from the marketplace; overrid const ctx = { ...sb.ctx, url: 'https://staging.example.test/mcp' } client('claude').connect(ctx) assert.deepEqual(JSON.parse(sb.read('.claude.json')).mcpServers.box, { type: 'http', url: 'https://staging.example.test/mcp' }) - assert.equal(state().plugins['box@simular-box'].enabled, false) + assert.equal(state().plugins['simular-box@simular-box'].enabled, false) assert.equal(client('claude').status(ctx).entries.length, 1) // remove runs with the same BOX_MCP_URL, so the staging host counts as ours. @@ -229,18 +229,18 @@ test('claude-code: default URL installs the plugin from the marketplace; overrid test('claude-code: a plugin installed before the move is updated to the new URL', { skip: NO_FAKE_BINS }, () => { const sb = sandbox({ bins: { claude: FAKE_CLAUDE } }) const old = 'https://relay.simular.cloud/mcp' - sb.write('fake-claude-state.json', JSON.stringify({ marketplaces: ['simular-box'], plugins: { 'box@simular-box': { enabled: true, url: old } }, calls: [] })) + sb.write('fake-claude-state.json', JSON.stringify({ marketplaces: ['simular-box'], plugins: { 'simular-box@simular-box': { enabled: true, url: old } }, calls: [] })) const state = () => JSON.parse(sb.read('fake-claude-state.json')) assert.equal(client('claude').status(sb.ctx).entries[0].url, old) const dry = client('claude').connect({ ...sb.ctx, dryRun: true }) - assert.match(dry.actions.join(' '), /would update plugin box@simular-box/) - assert.equal(state().plugins['box@simular-box'].url, old, 'a dry run updates nothing') + assert.match(dry.actions.join(' '), /would update plugin simular-box@simular-box/) + assert.equal(state().plugins['simular-box@simular-box'].url, old, 'a dry run updates nothing') const res = client('claude').connect(sb.ctx) - assert.match(res.actions.join(' '), /updated plugin box@simular-box/) + assert.match(res.actions.join(' '), /updated plugin simular-box@simular-box/) assert.match(res.next[0], /^Restart Claude Code/) assert.ok(state().calls.includes('plugin marketplace update simular-box')) - assert.ok(state().calls.includes('plugin update box@simular-box --scope user')) - assert.equal(state().plugins['box@simular-box'].url, DEFAULT_MCP_URL) + assert.ok(state().calls.includes('plugin update simular-box@simular-box --scope user')) + assert.equal(state().plugins['simular-box@simular-box'].url, DEFAULT_MCP_URL) assert.deepEqual(client('claude').connect(sb.ctx).actions, [], 'idempotent once updated') // Our own old user-scope box next to the plugin is removed; remove takes the plugin either way. sb.write('.claude.json', JSON.stringify({ mcpServers: { box: { type: 'http', url: old } } })) @@ -249,9 +249,35 @@ test('claude-code: a plugin installed before the move is updated to the new URL' assert.deepEqual(state().plugins, {}) }) +test('claude-code: the plugin installed under its old name box is replaced by simular-box', { skip: NO_FAKE_BINS }, () => { + const sb = sandbox({ bins: { claude: FAKE_CLAUDE } }) + sb.write('fake-claude-state.json', JSON.stringify({ marketplaces: ['simular-box'], plugins: { 'box@simular-box': { enabled: true, url: DEFAULT_MCP_URL } }, calls: [] })) + const state = () => JSON.parse(sb.read('fake-claude-state.json')) + const st = client('claude').status(sb.ctx) + assert.deepEqual(st.legacy, ['plugin box@simular-box']) + assert.equal(st.entries[0].where, 'plugin box@simular-box') + const dry = client('claude').connect({ ...sb.ctx, dryRun: true }) + assert.match(dry.actions.join(' '), /would uninstall old plugin box@simular-box/) + assert.ok(state().plugins['box@simular-box'], 'a dry run uninstalls nothing') + const res = client('claude').connect(sb.ctx) + assert.match(res.actions.join('; '), /installed plugin simular-box@simular-box \(box server \+ box skill\); uninstalled old plugin box@simular-box/) + assert.match(res.next[0], /plugin:simular-box:box/) + assert.ok(state().calls.includes('plugin marketplace update simular-box'), 'the marketplace is refreshed before the install') + assert.deepEqual(state().plugins, { 'simular-box@simular-box': { enabled: true, url: DEFAULT_MCP_URL } }) + assert.deepEqual(client('claude').status(sb.ctx).legacy, []) + assert.deepEqual(client('claude').connect(sb.ctx).actions, [], 'idempotent once replaced') +}) + +test('claude-code: remove takes the old-named plugin too', { skip: NO_FAKE_BINS }, () => { + const sb = sandbox({ bins: { claude: FAKE_CLAUDE } }) + sb.write('fake-claude-state.json', JSON.stringify({ marketplaces: ['simular-box'], plugins: { 'box@simular-box': { enabled: true, url: DEFAULT_MCP_URL } }, calls: [] })) + assert.match(client('claude').remove(sb.ctx).actions.join(' '), /uninstalled plugin box@simular-box/) + assert.deepEqual(JSON.parse(sb.read('fake-claude-state.json')).plugins, {}) +}) + test('claude-code: an update that leaves the old URL fails instead of claiming success', { skip: NO_FAKE_BINS }, () => { const sb = sandbox({ bins: { claude: FAKE_CLAUDE } }) - sb.write('fake-claude-state.json', JSON.stringify({ marketplaces: ['simular-box'], plugins: { 'box@simular-box': { enabled: true, url: 'https://relay.simular.cloud/mcp' } }, staleMarketplace: true, calls: [] })) + sb.write('fake-claude-state.json', JSON.stringify({ marketplaces: ['simular-box'], plugins: { 'simular-box@simular-box': { enabled: true, url: 'https://relay.simular.cloud/mcp' } }, staleMarketplace: true, calls: [] })) assert.throws(() => client('claude').connect(sb.ctx), /still points at https:\/\/relay\.simular\.cloud\/mcp after an update/) }) @@ -349,7 +375,7 @@ test('openclaw: a foreign box blocks connect and survives remove', { skip: NO_FA test('claude-code: a foreign user-scope box is never removed', { skip: NO_FAKE_BINS }, () => { const sb = sandbox({ bins: { claude: FAKE_CLAUDE } }) sb.write('.claude.json', JSON.stringify({ mcpServers: { box: { type: 'http', url: BOX_COM } } })) - client('claude').connect(sb.ctx) // plugin route: its server is plugin:box:box, no clash + client('claude').connect(sb.ctx) // plugin route: its server is plugin:simular-box:box, no clash assert.equal(JSON.parse(sb.read('.claude.json')).mcpServers.box.url, BOX_COM) const override = { ...sb.ctx, url: 'https://staging.relay.simular.cloud/mcp' } assert.throws(() => client('claude').connect(override), /--name/) From ef67155e39b2a50196bb9566a1a7ee12d6d5dc87 Mon Sep 17 00:00:00 2001 From: 011235813 <18132897+011235813@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:47:00 -0800 Subject: [PATCH 2/5] Claude plugin: README for the directory listing, and offload stages new files The directory reads only claude/ and shows its README. It copies the top-level README and adds what the plugin contains and what /simular-box:offload runs and sends: the pinned npx package, what is uploaded and what stays, where it goes, and how secrets and permissions are handled in the box. The offload skill stages the new files a task needs, since the CLI (box-cli#98) leaves untracked files on this computer, and drops cursor, which the box does not run. Co-Authored-By: Claude Opus 5.5 --- claude/README.md | 82 ++++++++++++++++++++++++++++++ claude/skills/box-offload/SKILL.md | 14 +++-- 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 claude/README.md diff --git a/claude/README.md b/claude/README.md new file mode 100644 index 0000000..bd104a7 --- /dev/null +++ b/claude/README.md @@ -0,0 +1,82 @@ +# box by Simular: plugins + +[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://mcp.box.simular.ai/mcp` and sign in once (see https://box.simular.ai/install). The [simular-ai/box-plugin](https://github.com/simular-ai/box-plugin) 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. This folder is the Claude Code and Cowork plugin. + +| Agent | Folder | Sign-in | Install | +| --- | --- | --- | --- | +| Claude Code and Cowork | [`claude/`](https://github.com/simular-ai/box-plugin/tree/main/claude) | OAuth, once in the browser | `/plugin marketplace add simular-ai/box-plugin`, then `/plugin install simular-box@simular-box` | +| DeepSeek Harness (dsh) | [`dsh/`](https://github.com/simular-ai/box-plugin/tree/main/dsh) | Personal key (`cb_...`), pasted on the plugin's page in Plugins | [dsh/README.md](https://github.com/simular-ai/box-plugin/blob/main/dsh/README.md) | +| OpenClaw | [`openclaw/`](https://github.com/simular-ai/box-plugin/tree/main/openclaw) | OAuth, once from the plugin's page in Settings | `openclaw plugins install @simular-ai/openclaw-box` | +| Any of Claude Code, Codex, Cursor, OpenCode, Gemini CLI, Hermes, OpenClaw, VS Code (and steps for Claude Desktop) | [`cli/`](https://github.com/simular-ai/box-plugin/tree/main/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/`](https://github.com/simular-ai/box-plugin/tree/main/hermes) | Personal key (`cb_...`) in `MCP_BOX_API_KEY`; OAuth optional | [hermes/README.md](https://github.com/simular-ai/box-plugin/blob/main/hermes/README.md) | +| ChatGPT and Codex (OpenAI plugin directory) | [`openai/`](https://github.com/simular-ai/box-plugin/tree/main/openai) | OAuth, once in the browser | Listed by OpenAI after review; build the upload with `openai/build-zip.sh` ([openai/README.md](https://github.com/simular-ai/box-plugin/blob/main/openai/README.md)) | + +## What this plugin contains + +- **The box MCP server** (`.mcp.json`): `https://mcp.box.simular.ai/mcp`, over HTTP. You sign in once with OAuth in the browser (`/mcp`, pick `plugin:simular-box:box`, Authenticate); there is no key to paste, and the plugin stores none. +- **The `box` skill** (`skills/box/SKILL.md`): when to use the box tools and which one fits. +- **Offload and back** (`commands/offload.md`, `commands/back.md`, `skills/box-offload/SKILL.md`): `/simular-box:offload` and `/simular-box:back`, below. They are the only part of the plugin that runs a program on your computer. + +## Offload: move a coding task into your box and back + +Going offline in the middle of a task? In Claude Code with the plugin, `/simular-box:offload` hands the task to a fresh Claude Code (or Codex) session in your box, which keeps working while you are away; `/simular-box:back` brings its commits and its written hand-over home and merges them. Any other agent or a plain terminal uses `npx @simular-ai/box@1.0.0 offload` and `npx @simular-ai/box@1.0.0 back`. Setup and details: [the CLI's README](https://www.npmjs.com/package/@simular-ai/box). + +Nothing leaves your computer until you run `/simular-box:offload` yourself. + +### What `/simular-box:offload` runs + +- **One npx package, pinned to an exact version:** `npx -y @simular-ai/box@1.0.0`, the `simular-box` CLI, built and released by Simular from [simular-ai/box-cli](https://github.com/simular-ai/box-cli) and published to npm as [`@simular-ai/box`](https://www.npmjs.com/package/@simular-ai/box). The pin is exact, never a range, so what runs is the version this plugin was reviewed with. +- **Claude's steps** (from `skills/box-offload/SKILL.md`): + 1. Write a hand-over to the file `npx -y @simular-ai/box@1.0.0 offload handover-path` prints, from a fixed template: goal, decisions, files touched, next steps, commands. Secrets go in by name only, never by value. + 2. Run `npx -y @simular-ai/box@1.0.0 offload --kind claude` in your repo. The CLI does every step that follows; the skill tells Claude never to copy files or secrets itself. +- **Checks before anything is sent.** The CLI stops, and sends nothing, when the folder is not a git repo, `rsync` is missing, this repo already has an offload in progress, the box's image is too old, or the task needs macOS. It warns, and goes on only after you say yes, when you have untracked files, a `.env` file stays behind, the hand-over names secrets you have not opted in, the code reads environment variables set here but not in the box, the repo uses Git LFS or submodules, it calls local services or VPN-only hosts, or the copy is over 500 MB. +- **Sign-in:** the CLI uses your box account (`npx -y @simular-ai/box@1.0.0 login`, in the browser). Its token is kept in the macOS Keychain, or on Linux in `~/.simular-box/credentials.json` with mode 0600. + +### What gets uploaded from your repo + +| What | Details | +| --- | --- | +| Tracked files | Every file git tracks (committed, or staged with `git add`), with its uncommitted changes. | +| Git history | A `git bundle` of your branches and tags that the box does not have yet, and `.git` without its object store. | +| The hand-over | `handover.md`, written by Claude as above. | +| `env.json` | Versions of the tools the repo uses on this computer (go, rustc, node, …), its package manager, and which toolchain files and lockfiles exist. No environment variable values. | +| Secrets you opted in | Nothing, unless you opted a secret in for this repo yourself (next section). | + +What stays on your computer: + +- Untracked files, the ones git does not know about. Stage a new file with `git add` to take it. +- Git-ignored files, such as `.env` and `node_modules`. +- Git LFS contents (the box copy holds their pointers) and submodule contents (the box copy holds their commits). +- Everything outside the repo, except the secrets you opted in. + +### Where it goes + +Into your own box, the cloud computer of the box account you signed in with, at `/workspace/`. It travels over box's API (`mcp.box.simular.ai`), authenticated as you: `rsync` and `git` run over the CLI's own remote shell to your box. Nothing is sent anywhere else. `/simular-box:back` brings the box's commits and its hand-over home, and merges them only when that leaves your work intact: a fast-forward, or a clean merge applied uncommitted; otherwise the box's work goes on a branch `box/back-