agentcage 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Frisk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # agentcage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Isolated dev containers for AI coding agents. Each project gets its own Linux container ("box"). **The project source lives
4
+ in a Docker volume, not in a folder mounted from your laptop**, so agents in the box can't see your other files. Each box can
5
+ run an **OpenCode server** that the OpenCode Desktop app on your laptop connects to. The app is only the UI: every agent tool
6
+ call (bash, read, edit) runs in the box. Claude Code can be installed in the box too.
7
+
8
+ Works on Windows, macOS and Linux, in any terminal (PowerShell, Terminal, bash).
9
+
10
+ ## Install
11
+
12
+ You need **Docker** (Docker Desktop on Windows/macOS, Docker Engine on Linux), **Node 22.13+**, **git**, and
13
+ **OpenCode Desktop** (open it once so its settings exist).
14
+
15
+ ```bash
16
+ npm install -g agentcage
17
+ agentcage doctor # checks everything above
18
+ ```
19
+
20
+ ## Create a box
21
+
22
+ Run `agentcage` and follow the dialog. It asks for:
23
+
24
+ 1. **Where the project is:** a folder on this computer (copied once, including untracked files such as `.env.local`)
25
+ or a git URL (cloned).
26
+ 2. **A name** for the box.
27
+ 3. **Git push access** (if the project has a remote). See [Git push access](#git-push-access).
28
+ 4. **AI agents:** OpenCode (on by default) and/or Claude Code.
29
+ 5. **Extra dev tools:** Python, GitHub CLI, pnpm. Node 24 and uv are always included.
30
+ 6. **Model providers for OpenCode** (Anthropic, OpenAI, OpenRouter, Google, Groq). After the build, OpenCode asks for each
31
+ API key in your terminal.
32
+ 7. **An IDE** to open the box in when it's ready (Cursor, VS Code, Antigravity).
33
+
34
+ Then it builds the box, adds it to OpenCode Desktop as server **agentcage: <name>** with the project
35
+ `/workspaces/<name>` already open, and opens the IDE.
36
+
37
+ The same works without the dialog:
38
+
39
+ ```bash
40
+ agentcage new ~/code/my-project --tools opencode,claude-code --git forward --ide cursor --yes
41
+ AGENTCAGE_GIT_TOKEN=github_pat_... agentcage new https://github.com/me/repo.git --git token --yes
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ ```bash
47
+ agentcage # menu: create a box, or start/open/shell/upgrade/remove an existing one
48
+ agentcage list # boxes, state, port, OpenCode version
49
+ agentcage start <name> # start after a reboot, print the OpenCode server URL and password
50
+ agentcage shell <name> # shell in the box (opencode, claude, git ... work here)
51
+ agentcage open <name> [ide] # Cursor (default), VS Code, or another VS Code-based IDE attached to the box
52
+ agentcage provider <name> # add model provider API keys to OpenCode in the box
53
+ agentcage connect <name> # (re)register the box in OpenCode Desktop
54
+ agentcage upgrade <name> # rebuild with the Desktop app's OpenCode version (volumes kept)
55
+ agentcage stop <name>
56
+ agentcage remove <name> # delete container, volumes and config
57
+ agentcage doctor
58
+ agentcage adopt <name> # import a box made by the old bash scripts in legacy/
59
+ ```
60
+
61
+ Box config lives in `~/.agentcage/<name>/` (`box.json` and the generated `.devcontainer/`). `node_modules`, `.venv` and
62
+ `.next` are not copied. Instead the box runs `npm ci` / `uv sync` for every lockfile it finds.
63
+
64
+ ## Git push access
65
+
66
+ The copy in the box is separate from your laptop's, so changes leave the box via **git push**. The dialog offers two ways:
67
+
68
+ - **A token for this repo only (recommended).** The dialog shows how to create one: a fine-grained GitHub token limited to
69
+ the repo with *Contents: read and write*, a GitLab project access token, or a Bitbucket repository access token. The token
70
+ is stored with git's credential store in the box's home volume and used for this repo only. The repo's git config clears
71
+ any other credential helper, including the one an attached IDE forwards.
72
+ Cursor and VS Code still forward your **SSH agent**. To close that path too, set
73
+ `"dev.containers.copyGitConfig": false` and `"dev.containers.gitCredentialHelperConfigLocation": "none"` in the IDE's
74
+ settings and turn off SSH agent forwarding.
75
+ - **Skip: use your IDE's forwarded credentials.** While Cursor or VS Code is attached, your laptop's git credentials and
76
+ SSH agent are forwarded into the box, so pushing from the IDE just works. **While the IDE is attached, any agent in the
77
+ box can use them to push to every repository you have access to.** When no IDE is attached, the box can't push at all.
78
+
79
+ For a git URL, token mode clones inside a throwaway container with the token. Forward mode clones on your laptop with your
80
+ own git credentials, then copies the repo in. Both work with private repos. Your git name and email are copied into the box.
81
+
82
+ ## OpenCode Desktop
83
+
84
+ agentcage reads the Desktop app's version (from `opencode-cli.version` in its install folder) and installs the same OpenCode
85
+ version in the box, because Desktop only talks to matching servers. `list`, `start` and `doctor` warn when the app has
86
+ updated since; `agentcage upgrade <name>` rebuilds the box with the new version and keeps your code, logins and sessions.
87
+
88
+ Desktop has no API or deep link for adding a server, so agentcage writes the app's own state DB (`drafts.sqlite` in the
89
+ app's data folder, backed up as `drafts.sqlite.agentcage-backup`). **The app must be closed for this.** The dialog asks you
90
+ to close it. This relies on the app's internal storage format (written against Desktop 2.0.23), so a future app update may
91
+ break it. `agentcage start <name>` always prints the URL, username and password for adding the server by hand.
92
+
93
+ Provider API keys are typed into OpenCode's own prompt inside the box. They are stored only in the box's home volume,
94
+ never on your laptop, and survive rebuilds.
95
+
96
+ ⚠️ Only sessions on the **agentcage server** are isolated. Sessions on the app's built-in **Local** server run on your laptop.
97
+
98
+ ## What is and isn't isolated
99
+
100
+ - ✅ No host folders mounted, no Docker socket, not privileged. The OpenCode server is published only on `127.0.0.1` and
101
+ requires a random password.
102
+ - ⚠️ The box has internet access and can reach services on your laptop via `host.docker.internal`.
103
+ - ⚠️ Secrets you copy in (for example `.env.local`) and provider keys can be read by agents. Use dev and test keys.
104
+ - ⚠️ An attached IDE forwards your SSH agent, and in forward mode your git credentials (see above).
105
+ - ⚠️ Use the IDE for editing, diffs and git. Its built-in AI agent runs outside the box's isolation, so run agents through
106
+ OpenCode or Claude Code in the box.
107
+
108
+ ## Platform notes
109
+
110
+ Tested on Windows 11 with Docker Desktop. On macOS and Linux, the OpenCode Desktop install and data folders are best
111
+ guesses (`/Applications/OpenCode.app`, `~/Library/Application Support/ai.opencode.desktop`, `~/.config/ai.opencode.desktop`).
112
+ `agentcage doctor` shows what it found. Please report the real paths if they differ.
113
+
114
+ ## Future work: Docker Sandboxes as an alternative backend
115
+
116
+ The setup dialog should let the user choose the backend: **plain Docker container (default)** or **Docker Sandboxes**
117
+ (`sbx`). We tested `sbx` v0.48.0 on Windows 11 with the `alversjo-maps` repo:
118
+
119
+ **What works, and is better than plain Docker**
120
+ - Each sandbox is a microVM with its own kernel. Installed without admin rights (`winget install Docker.sbx`), created in ~10 s.
121
+ - `sbx create --clone opencode <repo>` gives the agent a private clone. Commits made in the sandbox are fetched on the host
122
+ through a `sandbox-<name>` git remote, without touching the working tree. Only the clone is visible inside.
123
+ - Provider keys via `sbx secret set anthropic --sandbox <name>` (also openai, openrouter, google, groq, xai...). The sandbox
124
+ only sees `proxy-managed` placeholders; a host proxy injects the real key, so keys never enter the sandbox.
125
+ - A repo-scoped GitHub token works the same way: `sbx secret set github --sandbox <name>`. By default a sandbox has no GitHub
126
+ access.
127
+ - `-p 127.0.0.1:4200:4096` publishes the OpenCode server port. OpenCode 2.0.23 served the Desktop/web UI from the sandbox
128
+ with the password.
129
+ - Network policies (open / balanced / locked down) with a per-sandbox network log.
130
+ - The image already has Node 22, uv, Python 3, git, gh, sudo and a Docker daemon. IDEs connect over SSH (`sbx setup ssh`,
131
+ then Remote-SSH to `<name>.sbx`; not tested yet).
132
+
133
+ **What is missing for our use**
134
+ 1. **OpenCode version:** the image ships OpenCode 1.18.32 (`opencode-ai`, first on PATH). Desktop 2.0.23 needs a matching
135
+ server, so a kit or template must install `@opencode/cli@<desktop version>`.
136
+ 2. **Server lifetime:** the sandbox's agent is the OpenCode TUI and `sbx exec` has no detached mode. `opencode serve` started
137
+ with `nohup` dies when the exec session ends. It only stays up while a foreground `sbx exec ... opencode serve` runs on the
138
+ host. A kit must start the server at sandbox boot (to be tested).
139
+ 3. **Desktop registration** is still ours (the `drafts.sqlite` write), and the workspace path is the host path
140
+ (`/c/Users/...`), not `/workspaces/<name>`.
141
+ 4. **SSH agent forwarding is on by default:** the host's SSH keys were listed inside the sandbox. That is the cross-repo risk
142
+ we want to avoid, so it must be turned off or warned about.
143
+ 5. **Requirements:** Docker account sign-in for every user, macOS on Apple silicon only, Ubuntu 24.04+ with KVM only,
144
+ Windows 11 with Hypervisor Platform.
145
+ 6. OpenCode API keys: `opencode auth login` only accepts keys interactively, but with `sbx secret` this is not needed.
146
+
147
+ **Plan:** add a `backend: docker | sbx` choice to the dialog and `box.json`. The `sbx` backend maps our steps to
148
+ `sbx create --clone -p 127.0.0.1:<port>:4096 opencode <repo>`, `sbx secret set <provider|github> --sandbox <name>`, and
149
+ an OpenCode kit that pins the Desktop version and starts `opencode serve` at boot. The wizard, version detection and Desktop
150
+ registration stay the same.
151
+
152
+ ## Legacy
153
+
154
+ `legacy/` holds the original bash scripts this tool replaces. `agentcage adopt <name>` takes over a box they created.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import * as p from '@clack/prompts';
4
+ import { adoptBox, listBoxes, loadBox, removeBox, saveBox, state, up, writeDevcontainer, openIde, ws } from '../src/box.js';
5
+ import { ask, connectionInfo, newWizard, providerFlow, registerFlow } from '../src/wizard.js';
6
+ import { detectDesktop, latestVersion } from '../src/opencode.js';
7
+ import { ok, run } from '../src/run.js';
8
+
9
+ process.removeAllListeners('warning'); // hide node:sqlite ExperimentalWarning
10
+
11
+ const HELP = `agentcage: isolated dev containers for AI coding agents
12
+
13
+ agentcage interactive menu (create or manage boxes)
14
+ agentcage new [folder|git-url] create a box [--name x] [--tools opencode,claude-code,python,gh,pnpm]
15
+ [--git token|forward] [--providers anthropic,...] [--ide cursor|code|none] [--yes]
16
+ agentcage list boxes, state, port, OpenCode version
17
+ agentcage start <name> start a box and print OpenCode connection details
18
+ agentcage stop <name>
19
+ agentcage shell <name> shell in the box (run opencode, claude, git ... here)
20
+ agentcage open <name> [ide] open Cursor (default), VS Code or another VS Code-based IDE attached to the box
21
+ agentcage connect <name> (re)register the box in OpenCode Desktop (app must be closed)
22
+ agentcage provider <name> add model provider API keys to OpenCode in the box
23
+ agentcage upgrade <name> rebuild with the Desktop app's OpenCode version (volumes kept)
24
+ agentcage remove <name> delete container, volumes and config
25
+ agentcage doctor check Docker, Node and OpenCode Desktop
26
+ agentcage adopt <name> import a box made by the old devbox bash scripts
27
+
28
+ In token mode, a token can also be passed in the AGENTCAGE_GIT_TOKEN environment variable.`;
29
+
30
+ let { values: f, positionals: [cmd, arg, arg2] } = parseArgs({
31
+ allowPositionals: true,
32
+ options: { name: { type: 'string' }, tools: { type: 'string' }, git: { type: 'string' }, providers: { type: 'string' },
33
+ ide: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, help: { type: 'boolean', short: 'h' } },
34
+ });
35
+ const csv = s => s?.split(',').map(x => x.trim()).filter(Boolean);
36
+ const box = () => loadBox(arg ?? (() => { throw new Error(`Usage: agentcage ${cmd} <name>`); })());
37
+
38
+ function versionWarning(b) {
39
+ const v = detectDesktop().version;
40
+ if (b.tools.includes('opencode') && v && b.opencodeVersion !== v)
41
+ p.log.warn(`OpenCode Desktop is ${v}, box '${b.name}' has ${b.opencodeVersion}. If Desktop can't connect: agentcage upgrade ${b.name}`);
42
+ }
43
+ async function start(b) {
44
+ // devcontainer up (not docker start) so postStartCommand starts the OpenCode server
45
+ if (state(b) !== 'running') { const s = p.spinner(); s.start(`Starting '${b.name}'`); await up(b); s.stop(`Started '${b.name}'`); }
46
+ versionWarning(b);
47
+ }
48
+
49
+ const commands = {
50
+ async new() { await newWizard({ src: arg, name: f.name, tools: csv(f.tools), git: f.git, providers: csv(f.providers), ide: f.ide, yes: f.yes }); },
51
+ list() {
52
+ const rows = listBoxes().map(b => [b.name, state(b), b.tools.includes('opencode') ? String(b.port) : '-', b.opencodeVersion ?? '-', b.git]);
53
+ if (!rows.length) return console.log('No boxes yet. Create one with: agentcage new');
54
+ const all = [['NAME', 'STATE', 'PORT', 'OPENCODE', 'GIT'], ...rows], w = all[0].map((_, i) => Math.max(...all.map(r => r[i].length)));
55
+ console.log(all.map(r => r.map((c, i) => c.padEnd(w[i])).join(' ')).join('\n'));
56
+ const v = detectDesktop().version;
57
+ if (v) console.log(`\nOpenCode Desktop: ${v}`);
58
+ },
59
+ async start() { const b = box(); await start(b); console.log(`Box '${b.name}' is running.\n${b.tools.includes('opencode') ? connectionInfo(b) : ''}`); },
60
+ async stop() { await run('docker', ['stop', box().container]); },
61
+ async shell() { const b = box(); await start(b); await run('docker', ['exec', '-it', '-u', 'node', '-w', ws(b), b.container, 'bash', '-l'], { tty: true }).catch(() => {}); },
62
+ async open() {
63
+ const b = box(); await start(b); openIde(b, arg2 ?? f.ide ?? 'cursor');
64
+ if (b.git === 'token') p.log.warn('The IDE forwards your SSH agent into the box. Turn it off in the IDE settings to keep pushes limited to the repo token (see README).');
65
+ },
66
+ async connect() { await registerFlow(box()); },
67
+ async provider() { const b = box(); await start(b); await providerFlow(b, csv(f.providers)); },
68
+ async upgrade() {
69
+ const b = box(), v = detectDesktop().version ?? await latestVersion();
70
+ const s = p.spinner(); s.start(`Rebuilding '${b.name}' with OpenCode ${v} (volumes are kept)`);
71
+ b.opencodeVersion = v; writeDevcontainer(b);
72
+ await up(b, true).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
73
+ saveBox(b);
74
+ },
75
+ async remove() {
76
+ const b = box();
77
+ if (!f.yes && !(await ask(p.confirm({ message: `Delete box '${b.name}' with its volumes? Unpushed work in the box is lost.`, initialValue: false })))) return;
78
+ await removeBox(b); console.log(`Removed '${b.name}'.`);
79
+ },
80
+ doctor() {
81
+ const d = detectDesktop(), [maj, min] = process.versions.node.split('.').map(Number);
82
+ const checks = [
83
+ [maj > 22 || (maj === 22 && min >= 13), `Node ${process.versions.node}`, 'Install Node 22.13 or newer'],
84
+ [ok('docker', ['info']), 'Docker is running', 'Install/start Docker Desktop (Windows, macOS) or Docker Engine (Linux)'],
85
+ [ok('git', ['--version']), 'git installed', 'Install git'],
86
+ [d.hasDb, `OpenCode Desktop data: ${d.dataDir}`, 'Install OpenCode Desktop and open it once (or add servers by hand)'],
87
+ [!!d.version, `OpenCode Desktop version: ${d.version ?? 'unknown'}${d.from ? ` (from ${d.from})` : ''}`, 'Version not found: boxes use the newest OpenCode'],
88
+ ];
89
+ for (const [good, msg, fix] of checks) good ? p.log.success(msg) : p.log.warn(`${msg}\n${fix}`);
90
+ for (const b of listBoxes()) if (b.tools.includes('opencode') && d.version && b.opencodeVersion !== d.version) p.log.warn(`Box '${b.name}' has OpenCode ${b.opencodeVersion}: agentcage upgrade ${b.name}`);
91
+ },
92
+ adopt() { const b = adoptBox(arg); console.log(`Adopted '${b.name}' (${b.container}).`); },
93
+
94
+ // No command: menu
95
+ async menu() {
96
+ const boxes = listBoxes();
97
+ if (!boxes.length) return newWizard();
98
+ p.intro('agentcage');
99
+ const pick = await ask(p.select({ message: 'What do you want to do?', options: [{ value: '', label: 'Create a new box' }, ...boxes.map(b => ({ value: b.name, label: b.name, hint: state(b) }))] }));
100
+ if (!pick) return newWizard();
101
+ const action = await ask(p.select({ message: pick, options: [
102
+ { value: 'start', label: 'Start and show connection details' }, { value: 'open', label: 'Open in IDE' },
103
+ { value: 'shell', label: 'Shell' }, { value: 'provider', label: 'Add a model provider' },
104
+ { value: 'connect', label: 'Register in OpenCode Desktop' }, { value: 'upgrade', label: 'Upgrade OpenCode' },
105
+ { value: 'stop', label: 'Stop' }, { value: 'remove', label: 'Remove' },
106
+ ] }));
107
+ [cmd, arg] = [action, pick];
108
+ await commands[action]();
109
+ },
110
+ };
111
+
112
+ if (f.help || cmd === 'help') console.log(HELP);
113
+ else if (cmd && !commands[cmd]) { console.error(`Unknown command '${cmd}'.\n\n${HELP}`); process.exit(1); }
114
+ else await Promise.resolve().then(() => commands[cmd ?? 'menu']()).catch(e => { p.log.error(e.message); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "agentcage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Isolated dev containers for AI coding agents (OpenCode, Claude Code), connected to OpenCode Desktop",
5
+ "type": "module",
6
+ "bin": {
7
+ "agentcage": "bin/agentcage.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "template"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.13"
16
+ },
17
+ "keywords": [
18
+ "devcontainer",
19
+ "opencode",
20
+ "claude-code",
21
+ "ai-agents",
22
+ "sandbox",
23
+ "docker"
24
+ ],
25
+ "author": "Anton Frisk",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@clack/prompts": "^1.8.1",
29
+ "@devcontainers/cli": "^0.89.0"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/AntonFrisk/agentcage.git"
34
+ },
35
+ "homepage": "https://github.com/AntonFrisk/agentcage#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/AntonFrisk/agentcage/issues"
38
+ }
39
+ }
package/src/box.js ADDED
@@ -0,0 +1,115 @@
1
+ // Box state (~/.agentcage/<name>/box.json), devcontainer.json generation and the box lifecycle.
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { createServer } from 'node:net';
8
+ import { devcontainer, launch, ok, run, runSync } from './run.js';
9
+ import { TOOLS } from './catalog.js';
10
+ import { copyIdentity, isGitUrl, parseRemote, seed, setupToken } from './git.js';
11
+
12
+ export const ROOT = join(homedir(), '.agentcage');
13
+ const TEMPLATE = fileURLToPath(new URL('../template/', import.meta.url));
14
+ const boxFile = name => join(ROOT, name, 'box.json');
15
+ export const ws = b => `/workspaces/${b.name}`;
16
+
17
+ export const hasBox = name => existsSync(boxFile(name));
18
+ export function loadBox(name) {
19
+ if (!hasBox(name)) throw new Error(`No box named '${name}'. See: agentcage list`);
20
+ return JSON.parse(readFileSync(boxFile(name), 'utf8'));
21
+ }
22
+ export function saveBox(b) {
23
+ mkdirSync(join(ROOT, b.name), { recursive: true });
24
+ writeFileSync(boxFile(b.name), JSON.stringify(b, null, 2));
25
+ }
26
+ export const listBoxes = () => (existsSync(ROOT) ? readdirSync(ROOT).filter(hasBox).map(loadBox) : []);
27
+
28
+ export const cleanName = s => s.replace(/\.git\/?$/, '').replace(/[\\/:]+$/, '').replace(/.*[:\\/]/, '')
29
+ .toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
30
+ export const nameTaken = n => hasBox(n) || ok('docker', ['volume', 'inspect', `agentcage-${n}-src`]);
31
+
32
+ const bindable = port => new Promise(r => {
33
+ const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
34
+ });
35
+ async function freePort() {
36
+ const used = new Set(listBoxes().map(b => b.port));
37
+ for (const m of runSync('docker', ['ps', '-a', '--format', '{{.Ports}}']).matchAll(/:(\d+)->/g)) used.add(+m[1]);
38
+ for (let p = 4100; ; p++) if (!used.has(p) && (await bindable(p))) return p;
39
+ }
40
+
41
+ export function writeDevcontainer(b) {
42
+ const dir = join(b.dir, '.devcontainer'), oc = b.tools.includes('opencode');
43
+ const tools = TOOLS.filter(t => b.tools.includes(t.id));
44
+ mkdirSync(dir, { recursive: true });
45
+ cpSync(TEMPLATE, dir, { recursive: true });
46
+ // Isolation: the source lives in a Docker volume, not a bind mount. No host paths, no Docker socket, not privileged.
47
+ const cfg = {
48
+ name: b.name,
49
+ build: { dockerfile: 'Dockerfile', args: { OPENCODE_VERSION: oc ? b.opencodeVersion : '', NPM_GLOBALS: tools.flatMap(t => t.npm ?? []).join(' ') } },
50
+ workspaceMount: `source=${b.volumes.src},target=${ws(b)},type=volume`,
51
+ workspaceFolder: ws(b),
52
+ mounts: [`source=${b.volumes.home},target=/home/node,type=volume`],
53
+ runArgs: [`--name=${b.container}`, `--label=agentcage.name=${b.name}`, ...(oc ? ['-p', `127.0.0.1:${b.port}:4096`] : [])],
54
+ containerEnv: oc ? { OPENCODE_SERVER_PASSWORD: b.password } : {},
55
+ features: Object.fromEntries(tools.filter(t => t.feature).map(t => [t.feature, {}])),
56
+ remoteUser: 'node',
57
+ init: true,
58
+ forwardPorts: [3000],
59
+ postCreateCommand: 'agentcage-post-create',
60
+ ...(oc && { postStartCommand: 'setsid nohup opencode serve --hostname 0.0.0.0 --port 4096 > /tmp/opencode-serve.log 2>&1 &' }),
61
+ };
62
+ writeFileSync(join(dir, 'devcontainer.json'), JSON.stringify(cfg, null, 2) + '\n');
63
+ }
64
+
65
+ export const up = (b, rebuild) => devcontainer(['up', '--workspace-folder', b.dir, ...(rebuild ? ['--remove-existing-container'] : [])]);
66
+ export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}', b.container]) || 'missing';
67
+
68
+ // o: { src, name, tools, git: 'token'|'forward'|'none', token, opencodeVersion }. step(label, fn) shows progress.
69
+ export async function createBox(o, step = (_, f) => f()) {
70
+ const { name } = o, src = isGitUrl(o.src) ? o.src : resolve(o.src);
71
+ const b = {
72
+ name, dir: join(ROOT, name), container: `agentcage-${name}`,
73
+ volumes: { src: `agentcage-${name}-src`, home: `agentcage-${name}-home` },
74
+ port: await freePort(), password: randomBytes(16).toString('hex'),
75
+ source: src, git: o.git, tools: o.tools, opencodeVersion: o.opencodeVersion, created: new Date().toISOString(),
76
+ };
77
+ saveBox(b);
78
+ writeDevcontainer(b);
79
+ await run('docker', ['volume', 'create', b.volumes.src]);
80
+ await step('Copying the source into the box', () => seed(b.volumes.src, { ...o, src }));
81
+ await step('Building and starting the container (the first build takes a few minutes)', () => up(b));
82
+ await step('Setting up git', async () => {
83
+ await copyIdentity(b.container);
84
+ if (o.git === 'token') await setupToken(b.container, ws(b), parseRemote(o.remote ?? src), o.token);
85
+ });
86
+ return b;
87
+ }
88
+
89
+ export async function removeBox(b) {
90
+ await run('docker', ['rm', '-f', b.container]).catch(() => {});
91
+ for (const v of Object.values(b.volumes)) await run('docker', ['volume', 'rm', v]).catch(() => {});
92
+ rmSync(b.dir, { recursive: true, force: true });
93
+ rmSync(join(ROOT, b.name), { recursive: true, force: true });
94
+ }
95
+
96
+ // Opens a VS Code-based IDE attached to the running container
97
+ export function openIde(b, ide) {
98
+ const hex = Buffer.from(JSON.stringify({ containerName: `/${b.container}` })).toString('hex');
99
+ launch(ide, ['--folder-uri', `vscode-remote://attached-container+${hex}${ws(b)}`]);
100
+ }
101
+
102
+ // Import a box made by the old bash kit (~/devboxes/<name>, devbox-<name> container and volumes)
103
+ export function adoptBox(name) {
104
+ const dir = join(homedir(), 'devboxes', name), cfg = join(dir, '.devcontainer', 'devcontainer.json');
105
+ if (!existsSync(cfg)) throw new Error(`No legacy box at ${dir}`);
106
+ const text = readFileSync(cfg, 'utf8'), docker = readFileSync(join(dir, '.devcontainer', 'Dockerfile'), 'utf8');
107
+ const b = {
108
+ name, dir, container: `devbox-${name}`, volumes: { src: `devbox-${name}-src`, home: `devbox-${name}-home` },
109
+ port: +text.match(/127\.0\.0\.1:(\d+)/)[1], password: text.match(/"OPENCODE_SERVER_PASSWORD": "([^"]+)"/)[1],
110
+ source: 'legacy devbox', git: 'forward', tools: ['opencode'], opencodeVersion: docker.match(/OPENCODE_VERSION=(\S+)/)?.[1],
111
+ created: new Date().toISOString(), legacy: true,
112
+ };
113
+ saveBox(b);
114
+ return b;
115
+ }
package/src/catalog.js ADDED
@@ -0,0 +1,28 @@
1
+ // Tools offered in the setup dialog. feature = Dev Container Feature id, npm = global npm package installed in the image.
2
+ export const AGENTS = [
3
+ { id: 'opencode', label: 'OpenCode', hint: 'agent server for OpenCode Desktop (recommended)' },
4
+ { id: 'claude-code', label: 'Claude Code', hint: 'run `claude` in `agentcage shell <name>`', feature: 'ghcr.io/anthropics/devcontainer-features/claude-code:1' },
5
+ ];
6
+ export const DEV_TOOLS = [
7
+ { id: 'python', label: 'Python', hint: 'system Python (uv is always included)', feature: 'ghcr.io/devcontainers/features/python:1' },
8
+ { id: 'gh', label: 'GitHub CLI', hint: 'gh', feature: 'ghcr.io/devcontainers/features/github-cli:1' },
9
+ { id: 'pnpm', label: 'pnpm', hint: 'Node package manager', npm: 'pnpm' },
10
+ ];
11
+ export const TOOLS = [...AGENTS, ...DEV_TOOLS];
12
+
13
+ // OpenCode providers that log in with an API key (`opencode auth login <id> --method key`)
14
+ export const PROVIDERS = [
15
+ { id: 'anthropic', label: 'Anthropic', hint: 'console.anthropic.com/settings/keys' },
16
+ { id: 'openai', label: 'OpenAI', hint: 'platform.openai.com/api-keys' },
17
+ { id: 'openrouter', label: 'OpenRouter', hint: 'openrouter.ai/settings/keys' },
18
+ { id: 'google', label: 'Google Gemini', hint: 'aistudio.google.com/apikey' },
19
+ { id: 'groq', label: 'Groq', hint: 'console.groq.com/keys' },
20
+ ];
21
+
22
+ // How to make a token limited to one repo, per git host. user = username to send with the token over HTTPS.
23
+ const HOSTS = {
24
+ 'github.com': { user: 'x-access-token', help: p => `Create a fine-grained token: https://github.com/settings/personal-access-tokens/new\n Repository access: Only select repositories → ${p}\n Permissions: Contents → Read and write (add Pull requests if agents should open PRs)` },
25
+ 'gitlab.com': { user: 'oauth2', help: p => `Create a project access token: https://gitlab.com/${p}/-/settings/access_tokens\n Role: Developer. Scopes: read_repository, write_repository` },
26
+ 'bitbucket.org': { user: 'x-token-auth', help: p => `Create a repository access token: https://bitbucket.org/${p}/admin/access-tokens\n Permissions: Repositories → Write` },
27
+ };
28
+ export const hostInfo = host => HOSTS[host] ?? { user: 'git', help: p => `Create an access token on ${host} with read/write access to ${p} only.` };
package/src/git.js ADDED
@@ -0,0 +1,55 @@
1
+ // Getting the source into the box, and git push credentials inside it.
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { run, runSync } from './run.js';
6
+ import { hostInfo } from './catalog.js';
7
+
8
+ // https://host/owner/repo(.git), git@host:owner/repo(.git), ssh://git@host/owner/repo → { host, path, https }
9
+ export function parseRemote(url) {
10
+ const m = url?.match(/^(?:(?:https?|ssh|git):\/\/(?:[^@/]+@)?|[^@\s/]+@)([^/:]+)[:/](.+?)(?:\.git)?\/?$/);
11
+ return m && { host: m[1], path: m[2], https: `https://${m[1]}/${m[2]}.git` };
12
+ }
13
+ export const isGitUrl = s => /^(https?|ssh|git):\/\/|^[^@\s/]+@[^:]+:/.test(s);
14
+ export const localRemote = dir => runSync('git', ['-C', dir, 'remote', 'get-url', 'origin']) || null;
15
+
16
+ // Copy a host folder into the volume. The folder is mounted read-only into a throwaway container only.
17
+ const TAR = 'cd /src && tar -cf - --exclude=node_modules --exclude=.venv --exclude=.next . | tar -xf - -C /dst';
18
+ const seedFolder = (vol, dir) => run('docker', ['run', '--rm', '-v', `${vol}:/dst`, '-v', `${dir}:/src:ro`, 'alpine', 'sh', '-c', TAR]);
19
+
20
+ export async function seed(vol, { src, git, token }) {
21
+ if (!isGitUrl(src)) await seedFolder(vol, src);
22
+ else if (git === 'token') {
23
+ // Clone in a throwaway container; the token travels as an env var, never in the URL or on disk
24
+ const helper = '!f() { echo username=$GIT_USER; echo password=$GIT_TOKEN; }; f';
25
+ await run('docker', ['run', '--rm', '-e', 'GIT_USER', '-e', 'GIT_TOKEN', '-v', `${vol}:/dst`, 'alpine/git', '-c', `credential.helper=${helper}`, 'clone', src, '/dst'],
26
+ { env: { GIT_USER: hostInfo(parseRemote(src).host).user, GIT_TOKEN: token } });
27
+ } else {
28
+ // Clone on the host with the user's own git credentials (private repos work), then copy in
29
+ const tmp = mkdtempSync(join(tmpdir(), 'agentcage-'));
30
+ try {
31
+ await run('git', ['clone', src, join(tmp, 'src')], { env: { GIT_TERMINAL_PROMPT: '0' } });
32
+ await seedFolder(vol, join(tmp, 'src'));
33
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
34
+ }
35
+ await run('docker', ['run', '--rm', '-v', `${vol}:/dst`, 'alpine', 'chown', '-R', '1000:1000', '/dst']);
36
+ }
37
+
38
+ // Host git name/email → container
39
+ export async function copyIdentity(container) {
40
+ for (const k of ['user.name', 'user.email']) {
41
+ const v = runSync('git', ['config', '--global', k]);
42
+ if (v) await run('docker', ['exec', '-u', 'node', container, 'git', 'config', '--global', k, v]);
43
+ }
44
+ }
45
+
46
+ // Repo-scoped token: stored with git's credential store in the box's home volume, used for this repo only.
47
+ // The empty credential.helper entry clears helpers set earlier (such as the one an attached IDE forwards).
48
+ const TOKEN_SETUP = `IFS= read -r T; umask 077
49
+ printf 'https://%s:%s@%s/%s.git\\n' "$1" "$T" "$2" "$3" > ~/.git-credentials
50
+ git remote set-url origin "https://$2/$3.git"
51
+ git config --local --unset-all credential.helper
52
+ git config --local --add credential.helper '' && git config --local --add credential.helper store && git config --local credential.useHttpPath true`;
53
+
54
+ export const setupToken = (container, dir, remote, token) =>
55
+ run('docker', ['exec', '-i', '-u', 'node', '-w', dir, container, 'sh', '-c', TOKEN_SETUP, '_', hostInfo(remote.host).user, remote.host, remote.path], { input: token + '\n' });
@@ -0,0 +1,57 @@
1
+ // OpenCode Desktop on the host: where it lives, its version, whether it runs, and registering boxes in it.
2
+ import { copyFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ok, run, runSync } from './run.js';
6
+
7
+ const P = process.platform, H = homedir(), E = process.env;
8
+ const DATA_DIR = P === 'win32' ? join(E.APPDATA ?? '', 'ai.opencode.desktop')
9
+ : P === 'darwin' ? join(H, 'Library', 'Application Support', 'ai.opencode.desktop')
10
+ : join(E.XDG_CONFIG_HOME || join(H, '.config'), 'ai.opencode.desktop');
11
+ // Folders holding the app's bundled opencode-cli.version (Windows verified; macOS/Linux are best guesses)
12
+ const RESOURCE_DIRS = P === 'win32' ? [join(E.LOCALAPPDATA ?? '', 'Programs', '@opencodedesktop', 'resources')]
13
+ : P === 'darwin' ? ['/Applications/OpenCode.app/Contents/Resources', join(H, 'Applications/OpenCode.app/Contents/Resources')]
14
+ : ['/opt/OpenCode/resources', '/usr/lib/opencode-desktop/resources', '/usr/lib/OpenCode/resources', '/opt/opencode-desktop/resources'];
15
+
16
+ const SEMVER = /^\d+\.\d+\.\d+$/;
17
+ const cmp = (a, b) => a.split('.').reduce((r, x, i) => r || x - b.split('.')[i], 0);
18
+
19
+ export function detectDesktop() {
20
+ const db = join(DATA_DIR, 'drafts.sqlite');
21
+ const file = RESOURCE_DIRS.map(d => join(d, 'opencode-cli.version')).find(existsSync);
22
+ const cliDir = join(DATA_DIR, 'cli');
23
+ let version = file && readFileSync(file, 'utf8').trim(), from = file;
24
+ if (!version && existsSync(cliDir)) [version, from] = [readdirSync(cliDir).filter(v => SEMVER.test(v)).sort(cmp).at(-1), cliDir];
25
+ if (!version) [version, from] = [runSync('opencode', ['--version']).match(/\d+\.\d+\.\d+/)?.[0], 'opencode on PATH'];
26
+ return { dataDir: DATA_DIR, db, hasDb: existsSync(db), version, from };
27
+ }
28
+
29
+ export const desktopRunning = () => P === 'win32'
30
+ ? /^OpenCode\.exe/im.test(runSync('tasklist', ['/FI', 'IMAGENAME eq OpenCode.exe', '/NH']))
31
+ : ok('pgrep', ['-x', 'OpenCode']);
32
+
33
+ export const latestVersion = async () => (await (await fetch('https://registry.npmjs.org/@opencode/cli/latest')).json()).version;
34
+
35
+ // Desktop has no API or deep link for adding a server, so write its state DB (app must be closed). A backup is kept.
36
+ export async function connectDesktop(box) {
37
+ const { db, hasDb } = detectDesktop();
38
+ if (!hasDb) throw new Error(`OpenCode Desktop state not found at ${db}. Open the app once first.`);
39
+ if (desktopRunning()) throw new Error('OpenCode Desktop is running. Close it first.');
40
+ copyFileSync(db, db + '.agentcage-backup');
41
+ const { DatabaseSync } = await import('node:sqlite');
42
+ const d = new DatabaseSync(db), url = `http://127.0.0.1:${box.port}`, dir = `/workspaces/${box.name}`;
43
+ const row = d.prepare("select value from state where name='opencode.global.dat' and key='server'").get();
44
+ const s = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {}, ...(row && JSON.parse(row.value)) };
45
+ const urlOf = x => (typeof x === 'string' ? x : x.http?.url ?? x.url);
46
+ s.list = s.list.filter(x => urlOf(x) !== url).concat({ type: 'http', http: { url, password: box.password }, displayName: `agentcage: ${box.name}` });
47
+ s.projects[url] = [{ worktree: dir, expanded: true }];
48
+ s.lastProject[url] = dir;
49
+ d.prepare(`insert into state(name, key, value, updated_at) values ('opencode.global.dat', 'server', ?, ?)
50
+ on conflict(name, key) do update set value = excluded.value, updated_at = excluded.updated_at`).run(JSON.stringify(s), Date.now());
51
+ d.close();
52
+ }
53
+
54
+ // OpenCode only reads API keys from an interactive terminal, so hand the terminal to its own prompt inside the box.
55
+ // The key goes straight into the box's home volume and never passes through agentcage or the host.
56
+ export const loginProvider = (box, provider) =>
57
+ run('docker', ['exec', '-it', '-u', 'node', '-w', '/tmp', box.container, 'opencode', 'auth', 'login', provider, '--method', 'key'], { tty: true });
package/src/run.js ADDED
@@ -0,0 +1,37 @@
1
+ // Process helpers. Arguments are always passed as arrays (no shell), so paths never get mangled.
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ const fail = (cmd, args, code, err) =>
7
+ new Error(`${cmd} ${args.join(' ')} failed (exit ${code})${err ? ':\n' + err.trim().split('\n').slice(-15).join('\n') : ''}`);
8
+
9
+ // Quick commands: returns trimmed stdout ('' if the command is missing and check is off)
10
+ export function runSync(cmd, args = [], { check = false, input } = {}) {
11
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input });
12
+ if (check && r.status !== 0) throw fail(cmd, args, r.status, r.stderr || r.error?.message);
13
+ return (r.stdout || '').trim();
14
+ }
15
+ export const ok = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
16
+
17
+ // Long-running commands (keeps spinners animating). input: string sent on stdin; tty: hand over the terminal
18
+ export const run = (cmd, args = [], { input, env, tty } = {}) => new Promise((resolve, reject) => {
19
+ const p = spawn(cmd, args, { env: { ...process.env, ...env }, stdio: tty ? 'inherit' : 'pipe' });
20
+ let out = '', err = '';
21
+ p.stdout?.on('data', d => (out += d));
22
+ p.stderr?.on('data', d => (err += d));
23
+ p.on('error', e => reject(new Error(`${cmd}: ${e.message}`)));
24
+ p.on('close', code => (code === 0 ? resolve(out.trim()) : reject(fail(cmd, args, code, err || out))));
25
+ if (input !== undefined) p.stdin.end(input); else p.stdin?.end();
26
+ });
27
+
28
+ // Bundled @devcontainers/cli, so users don't need to install it
29
+ const DC = join(dirname(createRequire(import.meta.url).resolve('@devcontainers/cli/package.json')), 'devcontainer.js');
30
+ export const devcontainer = args => run(process.execPath, [DC, ...args]);
31
+
32
+ // IDE launchers (cursor, code) are .cmd shims on Windows and need a shell there
33
+ export function launch(cmd, args) {
34
+ const win = process.platform === 'win32';
35
+ const line = [cmd, ...args].map(a => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
36
+ return spawn(win ? line : cmd, win ? [] : args, { shell: win, stdio: 'ignore', detached: true }).unref();
37
+ }
package/src/wizard.js ADDED
@@ -0,0 +1,143 @@
1
+ // Interactive dialogs (@clack/prompts) on top of the box functions. Flags pre-fill answers; --yes takes defaults.
2
+ import * as p from '@clack/prompts';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { AGENTS, DEV_TOOLS, PROVIDERS, hostInfo } from './catalog.js';
6
+ import { cleanName, createBox, nameTaken, openIde, ws } from './box.js';
7
+ import { isGitUrl, localRemote, parseRemote } from './git.js';
8
+ import { connectDesktop, desktopRunning, detectDesktop, latestVersion, loginProvider } from './opencode.js';
9
+ import { ok, runSync } from './run.js';
10
+
11
+ export const ask = async q => {
12
+ const v = await q;
13
+ if (p.isCancel(v)) { p.cancel('Cancelled.'); process.exit(0); }
14
+ return v;
15
+ };
16
+ const step = async (label, fn) => {
17
+ const s = p.spinner();
18
+ s.start(label);
19
+ try { const r = await fn(); s.stop(label); return r; } catch (e) { s.error(label); throw e; }
20
+ };
21
+
22
+ export const connectionInfo = b =>
23
+ `Server http://127.0.0.1:${b.port}\nUsername opencode\nPassword ${b.password}\nDirectory ${ws(b)}`;
24
+
25
+ const FORWARD_WARNING = `While Cursor or VS Code is attached to the box, your laptop's git credentials
26
+ and SSH agent are forwarded into the container. Any agent running in the box at
27
+ that time can use them to push to EVERY repository you have access to, not only
28
+ this one. When no IDE is attached, the box cannot push at all.`;
29
+
30
+ // IDEs that can attach to a container, found on PATH or in their usual install folder
31
+ function findIdes() {
32
+ const found = cmd => !!runSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
33
+ const ag = process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Antigravity IDE', 'bin', 'antigravity-ide.cmd') : 'antigravity-ide';
34
+ return [['cursor', 'Cursor'], ['code', 'VS Code'], [ag, 'Antigravity']].filter(([c]) => existsSync(c) || found(c))
35
+ .map(([value, label]) => ({ value, label }));
36
+ }
37
+
38
+ async function waitForDocker() {
39
+ while (!ok('docker', ['info'])) {
40
+ p.log.warn('Docker is not running (or not installed). Start Docker Desktop / the Docker service.');
41
+ if (!(await ask(p.confirm({ message: 'Docker started. Check again?', active: 'Check again', inactive: 'Quit' })))) process.exit(1);
42
+ }
43
+ }
44
+
45
+ // Registers the box in OpenCode Desktop, asking the user to close the app if needed
46
+ export async function registerFlow(b, interactive = true) {
47
+ if (!detectDesktop().hasDb) return p.note(connectionInfo(b), 'OpenCode Desktop not found. Add this server by hand');
48
+ while (desktopRunning()) {
49
+ if (!interactive) return p.log.warn(`OpenCode Desktop is open. Close it, then run: agentcage connect ${b.name}`);
50
+ const go = await ask(p.confirm({ message: 'Close OpenCode Desktop so the box can be added to it.', active: 'It is closed now', inactive: 'Skip for now' }));
51
+ if (!go) return p.log.info(`Later: agentcage connect ${b.name}`);
52
+ }
53
+ await connectDesktop(b);
54
+ p.log.success(`OpenCode Desktop: added server "agentcage: ${b.name}" with project ${ws(b)}`);
55
+ }
56
+
57
+ // Hands the terminal to OpenCode's own key prompt for each provider
58
+ export async function providerFlow(b, ids) {
59
+ ids ??= await ask(p.multiselect({ message: 'Which providers should OpenCode use in this box?', options: PROVIDERS.map(x => ({ value: x.id, label: x.label, hint: x.hint })) }));
60
+ for (const id of ids) {
61
+ const pr = PROVIDERS.find(x => x.id === id);
62
+ p.log.step(`${pr.label}: paste your API key when OpenCode asks for it (get one at ${pr.hint}).`);
63
+ await loginProvider(b, id).catch(e => p.log.error(`${pr.label} was not added: ${e.message}`));
64
+ }
65
+ }
66
+
67
+ export async function newWizard(f = {}) {
68
+ const pick = (flag, def, prompt) => (flag !== undefined ? flag : f.yes ? def : prompt());
69
+ p.intro('agentcage · new box');
70
+ await waitForDocker();
71
+
72
+ const src = f.src ?? await ask((await ask(p.select({ message: 'Where is the project?', options: [
73
+ { value: 'folder', label: 'A folder on this computer', hint: 'copied once, incl. untracked files like .env.local' },
74
+ { value: 'git', label: 'A git repository', hint: 'cloned from a URL' },
75
+ ] }))) === 'folder'
76
+ ? p.path({ message: 'Project folder', directory: true, validate: v => (v && existsSync(v) ? undefined : 'Folder not found') })
77
+ : p.text({ message: 'Repository URL', placeholder: 'https://github.com/owner/repo.git', validate: v => (isGitUrl(v ?? '') ? undefined : 'Enter an https:// or git@ URL') }));
78
+ if (!isGitUrl(src) && !existsSync(src)) throw new Error(`Folder not found: ${src}`);
79
+
80
+ const nameOk = v => (!/^[a-z0-9][a-z0-9-]*$/.test(v ?? '') ? 'Use lowercase letters, digits and -' : nameTaken(v) ? `A box named '${v}' already exists` : undefined);
81
+ const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: 'Box name', initialValue: cleanName(src), validate: nameOk })));
82
+ if (nameOk(name)) throw new Error(nameOk(name));
83
+
84
+ // Git push access
85
+ const remoteUrl = isGitUrl(src) ? src : localRemote(src), remote = parseRemote(remoteUrl);
86
+ let git = 'none', token;
87
+ if (remote) {
88
+ git = await pick(f.git, 'forward', () => ask(p.select({ message: `How should the box push to ${remote.host}/${remote.path}?`, options: [
89
+ { value: 'token', label: 'A token for this repo only', hint: 'recommended: agents can only push here' },
90
+ { value: 'forward', label: "Skip: use my IDE's forwarded credentials", hint: 'access to all your repos while the IDE is attached' },
91
+ ] })));
92
+ if (git === 'token') {
93
+ p.note(hostInfo(remote.host).help(remote.path), 'Repo token');
94
+ token = process.env.AGENTCAGE_GIT_TOKEN ?? await ask(p.password({ message: 'Paste the token', validate: v => (v ? undefined : 'Required') }));
95
+ } else if (!f.yes) {
96
+ p.note(FORWARD_WARNING, 'Warning: cross-repo access');
97
+ if (!(await ask(p.confirm({ message: 'Continue with forwarded credentials?', initialValue: false })))) return newWizard({ ...f, src, name });
98
+ }
99
+ }
100
+
101
+ const tools = await pick(f.tools, ['opencode'], async () => [
102
+ ...await ask(p.multiselect({ message: 'AI agents to install', options: AGENTS.map(t => ({ value: t.id, label: t.label, hint: t.hint })), initialValues: ['opencode'], required: false })),
103
+ ...await ask(p.multiselect({ message: 'Extra dev tools (Node 24 and uv are always included)', options: DEV_TOOLS.map(t => ({ value: t.id, label: t.label, hint: t.hint })), required: false })),
104
+ ]);
105
+ const oc = tools.includes('opencode');
106
+
107
+ let opencodeVersion, providers = [];
108
+ if (oc) {
109
+ const d = detectDesktop();
110
+ opencodeVersion = d.version ?? await pick(undefined, await latestVersion(), async () => ask(p.text({ message: 'OpenCode Desktop not found. OpenCode version for the box', initialValue: await latestVersion() })));
111
+ if (d.version) p.log.info(`OpenCode ${d.version} (matches your Desktop app)`);
112
+ providers = await pick(f.providers, [], () => ask(p.multiselect({ message: 'Model providers to connect (you paste each API key after the build)', options: PROVIDERS.map(x => ({ value: x.id, label: x.label, hint: x.hint })), required: false })));
113
+ }
114
+
115
+ const ides = findIdes();
116
+ const ide = await pick(f.ide, 'none', () => ides.length ? ask(p.select({ message: 'Open the box in an IDE when done?', options: [...ides, { value: 'none', label: 'No' }] })) : 'none');
117
+
118
+ p.note([
119
+ `Source ${src}`, `Name ${name}`,
120
+ `Git push ${{ token: 'repo token', forward: 'forwarded IDE credentials', none: 'no remote' }[git]}`,
121
+ `Tools ${tools.join(', ') || 'none'}${oc ? ` (OpenCode ${opencodeVersion})` : ''}`,
122
+ ...(providers.length ? [`Providers ${providers.join(', ')}`] : []),
123
+ ].join('\n'), 'Summary');
124
+ if (!f.yes && !(await ask(p.confirm({ message: 'Create the box?' })))) return p.cancel('Nothing created.');
125
+
126
+ let b;
127
+ try {
128
+ b = await createBox({ src, name, tools, git, token, remote: remoteUrl, opencodeVersion }, step);
129
+ } catch (e) {
130
+ p.log.error(e.message);
131
+ p.outro(`Box creation failed. Fix the problem, then clean up with: agentcage remove ${name}`);
132
+ process.exit(1);
133
+ }
134
+ if (providers.length) await providerFlow(b, providers);
135
+ if (oc) await registerFlow(b, !f.yes);
136
+ if (ide !== 'none') { openIde(b, ide); p.log.success('Opening the IDE attached to the box'); }
137
+ p.outro([
138
+ `Box '${name}' is ready.`,
139
+ oc && `OpenCode Desktop: select "agentcage: ${name}" and start a session. Only sessions on that server are isolated.`,
140
+ tools.includes('claude-code') && `Claude Code: agentcage shell ${name}, then run claude to log in.`,
141
+ `Shell: agentcage shell ${name} IDE: agentcage open ${name}`,
142
+ ].filter(Boolean).join('\n'));
143
+ }
@@ -0,0 +1,14 @@
1
+ FROM mcr.microsoft.com/devcontainers/javascript-node:24-bookworm
2
+
3
+ # Set by agentcage: OpenCode version matching the Desktop app (empty = no OpenCode), extra global npm tools
4
+ ARG OPENCODE_VERSION=
5
+ ARG NPM_GLOBALS=
6
+
7
+ # uv manages Python versions + deps
8
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
9
+ RUN if [ -n "$OPENCODE_VERSION" ]; then npm install -g @opencode/cli@${OPENCODE_VERSION}; fi \
10
+ && if [ -n "$NPM_GLOBALS" ]; then npm install -g $NPM_GLOBALS; fi \
11
+ && mkdir -p /workspaces && chown node:node /workspaces
12
+
13
+ COPY post-create.sh /usr/local/bin/agentcage-post-create
14
+ RUN chmod +x /usr/local/bin/agentcage-post-create
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Runs in the workspace folder after the container is created.
3
+ set -e
4
+ git config --global --replace-all safe.directory "$PWD"
5
+ if [ -d .git ]; then
6
+ # Repo copied from Windows: use Linux git settings, convert CRLF back to LF where the repo stores LF,
7
+ # then clear files flagged modified that have no real content change (real changes are left untouched)
8
+ git config core.autocrlf input && git config core.symlinks true && git config core.ignorecase false
9
+ git ls-files --eol | awk -F'\t' '$1 ~ /^i\/lf +w\/crlf/ {print $2}' | xargs -r -d '\n' sed -i 's/\r$//'
10
+ comm -23 <(git ls-files -m | sort) <(git diff --name-only | sort) | xargs -r -d '\n' git add --
11
+ fi
12
+ # Install dependencies for every npm / uv project found
13
+ find . -name package-lock.json -not -path '*/node_modules/*' -execdir npm ci \;
14
+ find . -name uv.lock -not -path '*/.venv/*' -execdir uv sync \;