agentcage 0.1.0 → 0.1.1
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/README.md +29 -29
- package/bin/agentcage.js +32 -32
- package/package.json +1 -1
- package/src/{box.js → container.js} +27 -22
- package/src/git.js +2 -2
- package/src/opencode.js +8 -8
- package/src/wizard.js +22 -22
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# agentcage
|
|
2
2
|
|
|
3
|
-
Isolated dev containers for AI coding agents. Each project gets its own Linux container
|
|
4
|
-
in a Docker volume, not in a folder mounted from your laptop**, so agents in the
|
|
3
|
+
Isolated dev containers for AI coding agents. Each project gets its own Linux container. **The project source lives
|
|
4
|
+
in a Docker volume, not in a folder mounted from your laptop**, so agents in the container can't see your other files. Each container can
|
|
5
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
|
|
6
|
+
call (bash, read, edit) runs in the container. Claude Code can be installed in the container too.
|
|
7
7
|
|
|
8
8
|
Works on Windows, macOS and Linux, in any terminal (PowerShell, Terminal, bash).
|
|
9
9
|
|
|
@@ -17,21 +17,21 @@ npm install -g agentcage
|
|
|
17
17
|
agentcage doctor # checks everything above
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
## Create a
|
|
20
|
+
## Create a container
|
|
21
21
|
|
|
22
22
|
Run `agentcage` and follow the dialog. It asks for:
|
|
23
23
|
|
|
24
24
|
1. **Where the project is:** a folder on this computer (copied once, including untracked files such as `.env.local`)
|
|
25
25
|
or a git URL (cloned).
|
|
26
|
-
2. **A name** for the
|
|
26
|
+
2. **A name** for the container.
|
|
27
27
|
3. **Git push access** (if the project has a remote). See [Git push access](#git-push-access).
|
|
28
28
|
4. **AI agents:** OpenCode (on by default) and/or Claude Code.
|
|
29
29
|
5. **Extra dev tools:** Python, GitHub CLI, pnpm. Node 24 and uv are always included.
|
|
30
30
|
6. **Model providers for OpenCode** (Anthropic, OpenAI, OpenRouter, Google, Groq). After the build, OpenCode asks for each
|
|
31
31
|
API key in your terminal.
|
|
32
|
-
7. **An IDE** to open the
|
|
32
|
+
7. **An IDE** to open the container in when it's ready (Cursor, VS Code, Antigravity).
|
|
33
33
|
|
|
34
|
-
Then it builds the
|
|
34
|
+
Then it builds the container, adds it to OpenCode Desktop as server **ac-<name>** with the project
|
|
35
35
|
`/workspaces/<name>` already open, and opens the IDE.
|
|
36
36
|
|
|
37
37
|
The same works without the dialog:
|
|
@@ -44,53 +44,53 @@ AGENTCAGE_GIT_TOKEN=github_pat_... agentcage new https://github.com/me/repo.git
|
|
|
44
44
|
## Commands
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
agentcage # menu: create a
|
|
48
|
-
agentcage list #
|
|
47
|
+
agentcage # menu: create a container, or start/open/shell/upgrade/remove an existing one
|
|
48
|
+
agentcage list # containers, state, port, OpenCode version
|
|
49
49
|
agentcage start <name> # start after a reboot, print the OpenCode server URL and password
|
|
50
|
-
agentcage shell <name> # shell in the
|
|
51
|
-
agentcage open <name> [ide] # Cursor (default), VS Code, or another VS Code-based IDE attached to the
|
|
52
|
-
agentcage provider <name> # add model provider API keys to OpenCode in the
|
|
53
|
-
agentcage connect <name> # (re)register the
|
|
50
|
+
agentcage shell <name> # shell in the container (opencode, claude, git ... work here)
|
|
51
|
+
agentcage open <name> [ide] # Cursor (default), VS Code, or another VS Code-based IDE attached to the container
|
|
52
|
+
agentcage provider <name> # add model provider API keys to OpenCode in the container
|
|
53
|
+
agentcage connect <name> # (re)register the container in OpenCode Desktop
|
|
54
54
|
agentcage upgrade <name> # rebuild with the Desktop app's OpenCode version (volumes kept)
|
|
55
55
|
agentcage stop <name>
|
|
56
56
|
agentcage remove <name> # delete container, volumes and config
|
|
57
57
|
agentcage doctor
|
|
58
|
-
agentcage adopt <name> # import a
|
|
58
|
+
agentcage adopt <name> # import a container made by the old bash scripts in legacy/
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
`.next` are not copied. Instead the
|
|
61
|
+
Container config lives in `~/.agentcage/<name>/` (`container.json` and the generated `.devcontainer/`). `node_modules`, `.venv` and
|
|
62
|
+
`.next` are not copied. Instead the container runs `npm ci` / `uv sync` for every lockfile it finds.
|
|
63
63
|
|
|
64
64
|
## Git push access
|
|
65
65
|
|
|
66
|
-
The copy in the
|
|
66
|
+
The copy in the container is separate from your laptop's, so changes leave the container via **git push**. The dialog offers two ways:
|
|
67
67
|
|
|
68
68
|
- **A token for this repo only (recommended).** The dialog shows how to create one: a fine-grained GitHub token limited to
|
|
69
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
|
|
70
|
+
is stored with git's credential store in the container's home volume and used for this repo only. The repo's git config clears
|
|
71
71
|
any other credential helper, including the one an attached IDE forwards.
|
|
72
72
|
Cursor and VS Code still forward your **SSH agent**. To close that path too, set
|
|
73
73
|
`"dev.containers.copyGitConfig": false` and `"dev.containers.gitCredentialHelperConfigLocation": "none"` in the IDE's
|
|
74
74
|
settings and turn off SSH agent forwarding.
|
|
75
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
|
|
77
|
-
|
|
76
|
+
SSH agent are forwarded into the container, so pushing from the IDE just works. **While the IDE is attached, any agent in the
|
|
77
|
+
container can use them to push to every repository you have access to.** When no IDE is attached, the container can't push at all.
|
|
78
78
|
|
|
79
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
|
|
80
|
+
own git credentials, then copies the repo in. Both work with private repos. Your git name and email are copied into the container.
|
|
81
81
|
|
|
82
82
|
## OpenCode Desktop
|
|
83
83
|
|
|
84
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
|
|
86
|
-
updated since; `agentcage upgrade <name>` rebuilds the
|
|
85
|
+
version in the container, because Desktop only talks to matching servers. `list`, `start` and `doctor` warn when the app has
|
|
86
|
+
updated since; `agentcage upgrade <name>` rebuilds the container with the new version and keeps your code, logins and sessions.
|
|
87
87
|
|
|
88
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
89
|
app's data folder, backed up as `drafts.sqlite.agentcage-backup`). **The app must be closed for this.** The dialog asks you
|
|
90
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
91
|
break it. `agentcage start <name>` always prints the URL, username and password for adding the server by hand.
|
|
92
92
|
|
|
93
|
-
Provider API keys are typed into OpenCode's own prompt inside the
|
|
93
|
+
Provider API keys are typed into OpenCode's own prompt inside the container. They are stored only in the container's home volume,
|
|
94
94
|
never on your laptop, and survive rebuilds.
|
|
95
95
|
|
|
96
96
|
⚠️ Only sessions on the **agentcage server** are isolated. Sessions on the app's built-in **Local** server run on your laptop.
|
|
@@ -99,11 +99,11 @@ never on your laptop, and survive rebuilds.
|
|
|
99
99
|
|
|
100
100
|
- ✅ No host folders mounted, no Docker socket, not privileged. The OpenCode server is published only on `127.0.0.1` and
|
|
101
101
|
requires a random password.
|
|
102
|
-
- ⚠️ The
|
|
102
|
+
- ⚠️ The container has internet access and can reach services on your laptop via `host.docker.internal`.
|
|
103
103
|
- ⚠️ Secrets you copy in (for example `.env.local`) and provider keys can be read by agents. Use dev and test keys.
|
|
104
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
|
|
106
|
-
OpenCode or Claude Code in the
|
|
105
|
+
- ⚠️ Use the IDE for editing, diffs and git. Its built-in AI agent runs outside the container's isolation, so run agents through
|
|
106
|
+
OpenCode or Claude Code in the container.
|
|
107
107
|
|
|
108
108
|
## Platform notes
|
|
109
109
|
|
|
@@ -144,11 +144,11 @@ The setup dialog should let the user choose the backend: **plain Docker containe
|
|
|
144
144
|
Windows 11 with Hypervisor Platform.
|
|
145
145
|
6. OpenCode API keys: `opencode auth login` only accepts keys interactively, but with `sbx secret` this is not needed.
|
|
146
146
|
|
|
147
|
-
**Plan:** add a `backend: docker | sbx` choice to the dialog and `
|
|
147
|
+
**Plan:** add a `backend: docker | sbx` choice to the dialog and `container.json`. The `sbx` backend maps our steps to
|
|
148
148
|
`sbx create --clone -p 127.0.0.1:<port>:4096 opencode <repo>`, `sbx secret set <provider|github> --sandbox <name>`, and
|
|
149
149
|
an OpenCode kit that pins the Desktop version and starts `opencode serve` at boot. The wizard, version detection and Desktop
|
|
150
150
|
registration stay the same.
|
|
151
151
|
|
|
152
152
|
## Legacy
|
|
153
153
|
|
|
154
|
-
`legacy/` holds the original bash scripts this tool replaces. `agentcage adopt <name>` takes over a
|
|
154
|
+
`legacy/` holds the original bash scripts this tool replaces. `agentcage adopt <name>` takes over a container they created.
|
package/bin/agentcage.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import * as p from '@clack/prompts';
|
|
4
|
-
import {
|
|
4
|
+
import { adoptContainer, listContainers, loadContainer, removeContainer, saveContainer, state, up, writeDevcontainer, openIde, ws } from '../src/container.js';
|
|
5
5
|
import { ask, connectionInfo, newWizard, providerFlow, registerFlow } from '../src/wizard.js';
|
|
6
6
|
import { detectDesktop, latestVersion } from '../src/opencode.js';
|
|
7
7
|
import { ok, run } from '../src/run.js';
|
|
@@ -10,20 +10,20 @@ process.removeAllListeners('warning'); // hide node:sqlite ExperimentalWarning
|
|
|
10
10
|
|
|
11
11
|
const HELP = `agentcage: isolated dev containers for AI coding agents
|
|
12
12
|
|
|
13
|
-
agentcage interactive menu (create or manage
|
|
14
|
-
agentcage new [folder|git-url] create a
|
|
13
|
+
agentcage interactive menu (create or manage containers)
|
|
14
|
+
agentcage new [folder|git-url] create a container [--name x] [--tools opencode,claude-code,python,gh,pnpm]
|
|
15
15
|
[--git token|forward] [--providers anthropic,...] [--ide cursor|code|none] [--yes]
|
|
16
|
-
agentcage list
|
|
17
|
-
agentcage start <name> start a
|
|
16
|
+
agentcage list containers, state, port, OpenCode version
|
|
17
|
+
agentcage start <name> start a container and print OpenCode connection details
|
|
18
18
|
agentcage stop <name>
|
|
19
|
-
agentcage shell <name> shell in the
|
|
20
|
-
agentcage open <name> [ide] open Cursor (default), VS Code or another VS Code-based IDE attached to the
|
|
21
|
-
agentcage connect <name> (re)register the
|
|
22
|
-
agentcage provider <name> add model provider API keys to OpenCode in the
|
|
19
|
+
agentcage shell <name> shell in the container (run opencode, claude, git ... here)
|
|
20
|
+
agentcage open <name> [ide] open Cursor (default), VS Code or another VS Code-based IDE attached to the container
|
|
21
|
+
agentcage connect <name> (re)register the container in OpenCode Desktop (app must be closed)
|
|
22
|
+
agentcage provider <name> add model provider API keys to OpenCode in the container
|
|
23
23
|
agentcage upgrade <name> rebuild with the Desktop app's OpenCode version (volumes kept)
|
|
24
24
|
agentcage remove <name> delete container, volumes and config
|
|
25
25
|
agentcage doctor check Docker, Node and OpenCode Desktop
|
|
26
|
-
agentcage adopt <name> import a
|
|
26
|
+
agentcage adopt <name> import a container made by the old devbox bash scripts
|
|
27
27
|
|
|
28
28
|
In token mode, a token can also be passed in the AGENTCAGE_GIT_TOKEN environment variable.`;
|
|
29
29
|
|
|
@@ -33,12 +33,12 @@ let { values: f, positionals: [cmd, arg, arg2] } = parseArgs({
|
|
|
33
33
|
ide: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, help: { type: 'boolean', short: 'h' } },
|
|
34
34
|
});
|
|
35
35
|
const csv = s => s?.split(',').map(x => x.trim()).filter(Boolean);
|
|
36
|
-
const
|
|
36
|
+
const container = () => loadContainer(arg ?? (() => { throw new Error(`Usage: agentcage ${cmd} <name>`); })());
|
|
37
37
|
|
|
38
38
|
function versionWarning(b) {
|
|
39
39
|
const v = detectDesktop().version;
|
|
40
40
|
if (b.tools.includes('opencode') && v && b.opencodeVersion !== v)
|
|
41
|
-
p.log.warn(`OpenCode Desktop is ${v},
|
|
41
|
+
p.log.warn(`OpenCode Desktop is ${v}, container '${b.name}' has ${b.opencodeVersion}. If Desktop can't connect: agentcage upgrade ${b.name}`);
|
|
42
42
|
}
|
|
43
43
|
async function start(b) {
|
|
44
44
|
// devcontainer up (not docker start) so postStartCommand starts the OpenCode server
|
|
@@ -49,33 +49,33 @@ async function start(b) {
|
|
|
49
49
|
const commands = {
|
|
50
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
51
|
list() {
|
|
52
|
-
const rows =
|
|
53
|
-
if (!rows.length) return console.log('No
|
|
52
|
+
const rows = listContainers().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 containers yet. Create one with: agentcage new');
|
|
54
54
|
const all = [['NAME', 'STATE', 'PORT', 'OPENCODE', 'GIT'], ...rows], w = all[0].map((_, i) => Math.max(...all.map(r => r[i].length)));
|
|
55
55
|
console.log(all.map(r => r.map((c, i) => c.padEnd(w[i])).join(' ')).join('\n'));
|
|
56
56
|
const v = detectDesktop().version;
|
|
57
57
|
if (v) console.log(`\nOpenCode Desktop: ${v}`);
|
|
58
58
|
},
|
|
59
|
-
async start() { const b =
|
|
60
|
-
async stop() { await run('docker', ['stop',
|
|
61
|
-
async shell() { const b =
|
|
59
|
+
async start() { const b = container(); await start(b); console.log(`Container '${b.name}' is running.\n${b.tools.includes('opencode') ? connectionInfo(b) : ''}`); },
|
|
60
|
+
async stop() { await run('docker', ['stop', container().container]); },
|
|
61
|
+
async shell() { const b = container(); await start(b); await run('docker', ['exec', '-it', '-u', 'node', '-w', ws(b), b.container, 'bash', '-l'], { tty: true }).catch(() => {}); },
|
|
62
62
|
async open() {
|
|
63
|
-
const b =
|
|
64
|
-
if (b.git === 'token') p.log.warn('The IDE forwards your SSH agent into the
|
|
63
|
+
const b = container(); 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 container. Turn it off in the IDE settings to keep pushes limited to the repo token (see README).');
|
|
65
65
|
},
|
|
66
|
-
async connect() { await registerFlow(
|
|
67
|
-
async provider() { const b =
|
|
66
|
+
async connect() { await registerFlow(container()); },
|
|
67
|
+
async provider() { const b = container(); await start(b); await providerFlow(b, csv(f.providers)); },
|
|
68
68
|
async upgrade() {
|
|
69
|
-
const b =
|
|
69
|
+
const b = container(), v = detectDesktop().version ?? await latestVersion();
|
|
70
70
|
const s = p.spinner(); s.start(`Rebuilding '${b.name}' with OpenCode ${v} (volumes are kept)`);
|
|
71
71
|
b.opencodeVersion = v; writeDevcontainer(b);
|
|
72
72
|
await up(b, true).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
|
|
73
|
-
|
|
73
|
+
saveContainer(b);
|
|
74
74
|
},
|
|
75
75
|
async remove() {
|
|
76
|
-
const b =
|
|
77
|
-
if (!f.yes && !(await ask(p.confirm({ message: `Delete
|
|
78
|
-
await
|
|
76
|
+
const b = container();
|
|
77
|
+
if (!f.yes && !(await ask(p.confirm({ message: `Delete container '${b.name}' with its volumes? Unpushed work in the container is lost.`, initialValue: false })))) return;
|
|
78
|
+
await removeContainer(b); console.log(`Removed '${b.name}'.`);
|
|
79
79
|
},
|
|
80
80
|
doctor() {
|
|
81
81
|
const d = detectDesktop(), [maj, min] = process.versions.node.split('.').map(Number);
|
|
@@ -84,19 +84,19 @@ const commands = {
|
|
|
84
84
|
[ok('docker', ['info']), 'Docker is running', 'Install/start Docker Desktop (Windows, macOS) or Docker Engine (Linux)'],
|
|
85
85
|
[ok('git', ['--version']), 'git installed', 'Install git'],
|
|
86
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:
|
|
87
|
+
[!!d.version, `OpenCode Desktop version: ${d.version ?? 'unknown'}${d.from ? ` (from ${d.from})` : ''}`, 'Version not found: containers use the newest OpenCode'],
|
|
88
88
|
];
|
|
89
89
|
for (const [good, msg, fix] of checks) good ? p.log.success(msg) : p.log.warn(`${msg}\n${fix}`);
|
|
90
|
-
for (const b of
|
|
90
|
+
for (const b of listContainers()) if (b.tools.includes('opencode') && d.version && b.opencodeVersion !== d.version) p.log.warn(`Container '${b.name}' has OpenCode ${b.opencodeVersion}: agentcage upgrade ${b.name}`);
|
|
91
91
|
},
|
|
92
|
-
adopt() { const b =
|
|
92
|
+
adopt() { const b = adoptContainer(arg); console.log(`Adopted '${b.name}' (${b.container}).`); },
|
|
93
93
|
|
|
94
94
|
// No command: menu
|
|
95
95
|
async menu() {
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
96
|
+
const containers = listContainers();
|
|
97
|
+
if (!containers.length) return newWizard();
|
|
98
98
|
p.intro('agentcage');
|
|
99
|
-
const pick = await ask(p.select({ message: 'What do you want to do?', options: [{ value: '', label: 'Create a new
|
|
99
|
+
const pick = await ask(p.select({ message: 'What do you want to do?', options: [{ value: '', label: 'Create a new container' }, ...containers.map(b => ({ value: b.name, label: b.name, hint: state(b) }))] }));
|
|
100
100
|
if (!pick) return newWizard();
|
|
101
101
|
const action = await ask(p.select({ message: pick, options: [
|
|
102
102
|
{ value: 'start', label: 'Start and show connection details' }, { value: 'open', label: 'Open in IDE' },
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
// Container state (~/.agentcage/<name>/container.json), devcontainer.json generation and the container lifecycle.
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -11,29 +11,34 @@ import { copyIdentity, isGitUrl, parseRemote, seed, setupToken } from './git.js'
|
|
|
11
11
|
|
|
12
12
|
export const ROOT = join(homedir(), '.agentcage');
|
|
13
13
|
const TEMPLATE = fileURLToPath(new URL('../template/', import.meta.url));
|
|
14
|
-
|
|
14
|
+
// Configs from before the box -> container rename are moved to container.json on first access
|
|
15
|
+
const cfgFile = name => {
|
|
16
|
+
const f = join(ROOT, name, 'container.json'), old = join(ROOT, name, 'box.json');
|
|
17
|
+
if (!existsSync(f) && existsSync(old)) renameSync(old, f);
|
|
18
|
+
return f;
|
|
19
|
+
};
|
|
15
20
|
export const ws = b => `/workspaces/${b.name}`;
|
|
16
21
|
|
|
17
|
-
export const
|
|
18
|
-
export function
|
|
19
|
-
if (!
|
|
20
|
-
return JSON.parse(readFileSync(
|
|
22
|
+
export const hasContainer = name => existsSync(cfgFile(name));
|
|
23
|
+
export function loadContainer(name) {
|
|
24
|
+
if (!hasContainer(name)) throw new Error(`No container named '${name}'. See: agentcage list`);
|
|
25
|
+
return JSON.parse(readFileSync(cfgFile(name), 'utf8'));
|
|
21
26
|
}
|
|
22
|
-
export function
|
|
27
|
+
export function saveContainer(b) {
|
|
23
28
|
mkdirSync(join(ROOT, b.name), { recursive: true });
|
|
24
|
-
writeFileSync(
|
|
29
|
+
writeFileSync(cfgFile(b.name), JSON.stringify(b, null, 2));
|
|
25
30
|
}
|
|
26
|
-
export const
|
|
31
|
+
export const listContainers = () => (existsSync(ROOT) ? readdirSync(ROOT).filter(hasContainer).map(loadContainer) : []);
|
|
27
32
|
|
|
28
33
|
export const cleanName = s => s.replace(/\.git\/?$/, '').replace(/[\\/:]+$/, '').replace(/.*[:\\/]/, '')
|
|
29
34
|
.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
30
|
-
export const nameTaken = n =>
|
|
35
|
+
export const nameTaken = n => hasContainer(n) || ok('docker', ['inspect', `ac-${n}`]) || ok('docker', ['volume', 'inspect', `ac-${n}-src`]);
|
|
31
36
|
|
|
32
37
|
const bindable = port => new Promise(r => {
|
|
33
38
|
const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
|
|
34
39
|
});
|
|
35
40
|
async function freePort() {
|
|
36
|
-
const used = new Set(
|
|
41
|
+
const used = new Set(listContainers().map(b => b.port));
|
|
37
42
|
for (const m of runSync('docker', ['ps', '-a', '--format', '{{.Ports}}']).matchAll(/:(\d+)->/g)) used.add(+m[1]);
|
|
38
43
|
for (let p = 4100; ; p++) if (!used.has(p) && (await bindable(p))) return p;
|
|
39
44
|
}
|
|
@@ -66,18 +71,18 @@ export const up = (b, rebuild) => devcontainer(['up', '--workspace-folder', b.di
|
|
|
66
71
|
export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}', b.container]) || 'missing';
|
|
67
72
|
|
|
68
73
|
// o: { src, name, tools, git: 'token'|'forward'|'none', token, opencodeVersion }. step(label, fn) shows progress.
|
|
69
|
-
export async function
|
|
74
|
+
export async function createContainer(o, step = (_, f) => f()) {
|
|
70
75
|
const { name } = o, src = isGitUrl(o.src) ? o.src : resolve(o.src);
|
|
71
76
|
const b = {
|
|
72
|
-
name, dir: join(ROOT, name), container: `
|
|
73
|
-
volumes: { src: `
|
|
77
|
+
name, dir: join(ROOT, name), container: `ac-${name}`,
|
|
78
|
+
volumes: { src: `ac-${name}-src`, home: `ac-${name}-home` },
|
|
74
79
|
port: await freePort(), password: randomBytes(16).toString('hex'),
|
|
75
80
|
source: src, git: o.git, tools: o.tools, opencodeVersion: o.opencodeVersion, created: new Date().toISOString(),
|
|
76
81
|
};
|
|
77
|
-
|
|
82
|
+
saveContainer(b);
|
|
78
83
|
writeDevcontainer(b);
|
|
79
84
|
await run('docker', ['volume', 'create', b.volumes.src]);
|
|
80
|
-
await step('Copying the source into the
|
|
85
|
+
await step('Copying the source into the container', () => seed(b.volumes.src, { ...o, src }));
|
|
81
86
|
await step('Building and starting the container (the first build takes a few minutes)', () => up(b));
|
|
82
87
|
await step('Setting up git', async () => {
|
|
83
88
|
await copyIdentity(b.container);
|
|
@@ -86,7 +91,7 @@ export async function createBox(o, step = (_, f) => f()) {
|
|
|
86
91
|
return b;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
export async function
|
|
94
|
+
export async function removeContainer(b) {
|
|
90
95
|
await run('docker', ['rm', '-f', b.container]).catch(() => {});
|
|
91
96
|
for (const v of Object.values(b.volumes)) await run('docker', ['volume', 'rm', v]).catch(() => {});
|
|
92
97
|
rmSync(b.dir, { recursive: true, force: true });
|
|
@@ -99,10 +104,10 @@ export function openIde(b, ide) {
|
|
|
99
104
|
launch(ide, ['--folder-uri', `vscode-remote://attached-container+${hex}${ws(b)}`]);
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
// Import a
|
|
103
|
-
export function
|
|
107
|
+
// Import a container made by the old bash kit (~/devboxes/<name>, devbox-<name> container and volumes)
|
|
108
|
+
export function adoptContainer(name) {
|
|
104
109
|
const dir = join(homedir(), 'devboxes', name), cfg = join(dir, '.devcontainer', 'devcontainer.json');
|
|
105
|
-
if (!existsSync(cfg)) throw new Error(`No legacy
|
|
110
|
+
if (!existsSync(cfg)) throw new Error(`No legacy container at ${dir}`);
|
|
106
111
|
const text = readFileSync(cfg, 'utf8'), docker = readFileSync(join(dir, '.devcontainer', 'Dockerfile'), 'utf8');
|
|
107
112
|
const b = {
|
|
108
113
|
name, dir, container: `devbox-${name}`, volumes: { src: `devbox-${name}-src`, home: `devbox-${name}-home` },
|
|
@@ -110,6 +115,6 @@ export function adoptBox(name) {
|
|
|
110
115
|
source: 'legacy devbox', git: 'forward', tools: ['opencode'], opencodeVersion: docker.match(/OPENCODE_VERSION=(\S+)/)?.[1],
|
|
111
116
|
created: new Date().toISOString(), legacy: true,
|
|
112
117
|
};
|
|
113
|
-
|
|
118
|
+
saveContainer(b);
|
|
114
119
|
return b;
|
|
115
120
|
}
|
package/src/git.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Getting the source into the
|
|
1
|
+
// Getting the source into the container, and git push credentials inside it.
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -43,7 +43,7 @@ export async function copyIdentity(container) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Repo-scoped token: stored with git's credential store in the
|
|
46
|
+
// Repo-scoped token: stored with git's credential store in the container's home volume, used for this repo only.
|
|
47
47
|
// The empty credential.helper entry clears helpers set earlier (such as the one an attached IDE forwards).
|
|
48
48
|
const TOKEN_SETUP = `IFS= read -r T; umask 077
|
|
49
49
|
printf 'https://%s:%s@%s/%s.git\\n' "$1" "$T" "$2" "$3" > ~/.git-credentials
|
package/src/opencode.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// OpenCode Desktop on the host: where it lives, its version, whether it runs, and registering
|
|
1
|
+
// OpenCode Desktop on the host: where it lives, its version, whether it runs, and registering containers in it.
|
|
2
2
|
import { copyFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -33,17 +33,17 @@ export const desktopRunning = () => P === 'win32'
|
|
|
33
33
|
export const latestVersion = async () => (await (await fetch('https://registry.npmjs.org/@opencode/cli/latest')).json()).version;
|
|
34
34
|
|
|
35
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(
|
|
36
|
+
export async function connectDesktop(container) {
|
|
37
37
|
const { db, hasDb } = detectDesktop();
|
|
38
38
|
if (!hasDb) throw new Error(`OpenCode Desktop state not found at ${db}. Open the app once first.`);
|
|
39
39
|
if (desktopRunning()) throw new Error('OpenCode Desktop is running. Close it first.');
|
|
40
40
|
copyFileSync(db, db + '.agentcage-backup');
|
|
41
41
|
const { DatabaseSync } = await import('node:sqlite');
|
|
42
|
-
const d = new DatabaseSync(db), url = `http://127.0.0.1:${
|
|
42
|
+
const d = new DatabaseSync(db), url = `http://127.0.0.1:${container.port}`, dir = `/workspaces/${container.name}`;
|
|
43
43
|
const row = d.prepare("select value from state where name='opencode.global.dat' and key='server'").get();
|
|
44
44
|
const s = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {}, ...(row && JSON.parse(row.value)) };
|
|
45
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:
|
|
46
|
+
s.list = s.list.filter(x => urlOf(x) !== url).concat({ type: 'http', http: { url, password: container.password }, displayName: `ac-${container.name}` });
|
|
47
47
|
s.projects[url] = [{ worktree: dir, expanded: true }];
|
|
48
48
|
s.lastProject[url] = dir;
|
|
49
49
|
d.prepare(`insert into state(name, key, value, updated_at) values ('opencode.global.dat', 'server', ?, ?)
|
|
@@ -51,7 +51,7 @@ export async function connectDesktop(box) {
|
|
|
51
51
|
d.close();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// OpenCode only reads API keys from an interactive terminal, so hand the terminal to its own prompt inside the
|
|
55
|
-
// The key goes straight into the
|
|
56
|
-
export const loginProvider = (
|
|
57
|
-
run('docker', ['exec', '-it', '-u', 'node', '-w', '/tmp',
|
|
54
|
+
// OpenCode only reads API keys from an interactive terminal, so hand the terminal to its own prompt inside the container.
|
|
55
|
+
// The key goes straight into the container's home volume and never passes through agentcage or the host.
|
|
56
|
+
export const loginProvider = (container, provider) =>
|
|
57
|
+
run('docker', ['exec', '-it', '-u', 'node', '-w', '/tmp', container.container, 'opencode', 'auth', 'login', provider, '--method', 'key'], { tty: true });
|
package/src/wizard.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Interactive dialogs (@clack/prompts) on top of the
|
|
1
|
+
// Interactive dialogs (@clack/prompts) on top of the container functions. Flags pre-fill answers; --yes takes defaults.
|
|
2
2
|
import * as p from '@clack/prompts';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { AGENTS, DEV_TOOLS, PROVIDERS, hostInfo } from './catalog.js';
|
|
6
|
-
import { cleanName,
|
|
6
|
+
import { cleanName, createContainer, nameTaken, openIde, ws } from './container.js';
|
|
7
7
|
import { isGitUrl, localRemote, parseRemote } from './git.js';
|
|
8
8
|
import { connectDesktop, desktopRunning, detectDesktop, latestVersion, loginProvider } from './opencode.js';
|
|
9
9
|
import { ok, runSync } from './run.js';
|
|
@@ -22,10 +22,10 @@ const step = async (label, fn) => {
|
|
|
22
22
|
export const connectionInfo = b =>
|
|
23
23
|
`Server http://127.0.0.1:${b.port}\nUsername opencode\nPassword ${b.password}\nDirectory ${ws(b)}`;
|
|
24
24
|
|
|
25
|
-
const FORWARD_WARNING = `While Cursor or VS Code is attached to the
|
|
26
|
-
and SSH agent are forwarded into the container. Any agent running in the
|
|
25
|
+
const FORWARD_WARNING = `While Cursor or VS Code is attached to the container, your laptop's git credentials
|
|
26
|
+
and SSH agent are forwarded into the container. Any agent running in the container at
|
|
27
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
|
|
28
|
+
this one. When no IDE is attached, the container cannot push at all.`;
|
|
29
29
|
|
|
30
30
|
// IDEs that can attach to a container, found on PATH or in their usual install folder
|
|
31
31
|
function findIdes() {
|
|
@@ -42,21 +42,21 @@ async function waitForDocker() {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Registers the
|
|
45
|
+
// Registers the container in OpenCode Desktop, asking the user to close the app if needed
|
|
46
46
|
export async function registerFlow(b, interactive = true) {
|
|
47
47
|
if (!detectDesktop().hasDb) return p.note(connectionInfo(b), 'OpenCode Desktop not found. Add this server by hand');
|
|
48
48
|
while (desktopRunning()) {
|
|
49
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
|
|
50
|
+
const go = await ask(p.confirm({ message: 'Close OpenCode Desktop so the container can be added to it.', active: 'It is closed now', inactive: 'Skip for now' }));
|
|
51
51
|
if (!go) return p.log.info(`Later: agentcage connect ${b.name}`);
|
|
52
52
|
}
|
|
53
53
|
await connectDesktop(b);
|
|
54
|
-
p.log.success(`OpenCode Desktop: added server "
|
|
54
|
+
p.log.success(`OpenCode Desktop: added server "ac-${b.name}" with project ${ws(b)}`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// Hands the terminal to OpenCode's own key prompt for each provider
|
|
58
58
|
export async function providerFlow(b, ids) {
|
|
59
|
-
ids ??= await ask(p.multiselect({ message: 'Which providers should OpenCode use in this
|
|
59
|
+
ids ??= await ask(p.multiselect({ message: 'Which providers should OpenCode use in this container?', options: PROVIDERS.map(x => ({ value: x.id, label: x.label, hint: x.hint })) }));
|
|
60
60
|
for (const id of ids) {
|
|
61
61
|
const pr = PROVIDERS.find(x => x.id === id);
|
|
62
62
|
p.log.step(`${pr.label}: paste your API key when OpenCode asks for it (get one at ${pr.hint}).`);
|
|
@@ -66,7 +66,7 @@ export async function providerFlow(b, ids) {
|
|
|
66
66
|
|
|
67
67
|
export async function newWizard(f = {}) {
|
|
68
68
|
const pick = (flag, def, prompt) => (flag !== undefined ? flag : f.yes ? def : prompt());
|
|
69
|
-
p.intro('agentcage · new
|
|
69
|
+
p.intro('agentcage · new container');
|
|
70
70
|
await waitForDocker();
|
|
71
71
|
|
|
72
72
|
const src = f.src ?? await ask((await ask(p.select({ message: 'Where is the project?', options: [
|
|
@@ -77,15 +77,15 @@ export async function newWizard(f = {}) {
|
|
|
77
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
78
|
if (!isGitUrl(src) && !existsSync(src)) throw new Error(`Folder not found: ${src}`);
|
|
79
79
|
|
|
80
|
-
const nameOk = v => (!/^[a-z0-9][a-z0-9-]*$/.test(v ?? '') ? 'Use lowercase letters, digits and -' : nameTaken(v) ? `A
|
|
81
|
-
const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: '
|
|
80
|
+
const nameOk = v => (!/^[a-z0-9][a-z0-9-]*$/.test(v ?? '') ? 'Use lowercase letters, digits and -' : nameTaken(v) ? `A container named '${v}' already exists` : undefined);
|
|
81
|
+
const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: 'Container name (the Docker container and the OpenCode server get the prefix "ac-")', initialValue: cleanName(src), validate: nameOk })));
|
|
82
82
|
if (nameOk(name)) throw new Error(nameOk(name));
|
|
83
83
|
|
|
84
84
|
// Git push access
|
|
85
85
|
const remoteUrl = isGitUrl(src) ? src : localRemote(src), remote = parseRemote(remoteUrl);
|
|
86
86
|
let git = 'none', token;
|
|
87
87
|
if (remote) {
|
|
88
|
-
git = await pick(f.git, 'forward', () => ask(p.select({ message: `How should the
|
|
88
|
+
git = await pick(f.git, 'forward', () => ask(p.select({ message: `How should the container push to ${remote.host}/${remote.path}?`, options: [
|
|
89
89
|
{ value: 'token', label: 'A token for this repo only', hint: 'recommended: agents can only push here' },
|
|
90
90
|
{ value: 'forward', label: "Skip: use my IDE's forwarded credentials", hint: 'access to all your repos while the IDE is attached' },
|
|
91
91
|
] })));
|
|
@@ -107,13 +107,13 @@ export async function newWizard(f = {}) {
|
|
|
107
107
|
let opencodeVersion, providers = [];
|
|
108
108
|
if (oc) {
|
|
109
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
|
|
110
|
+
opencodeVersion = d.version ?? await pick(undefined, await latestVersion(), async () => ask(p.text({ message: 'OpenCode Desktop not found. OpenCode version for the container', initialValue: await latestVersion() })));
|
|
111
111
|
if (d.version) p.log.info(`OpenCode ${d.version} (matches your Desktop app)`);
|
|
112
|
-
providers = await pick(f.providers, [], () => ask(p.multiselect({ message: '
|
|
112
|
+
providers = await pick(f.providers, [], () => ask(p.multiselect({ message: 'Additional OpenCode 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
113
|
}
|
|
114
114
|
|
|
115
115
|
const ides = findIdes();
|
|
116
|
-
const ide = await pick(f.ide, 'none', () => ides.length ? ask(p.select({ message: 'Open the
|
|
116
|
+
const ide = await pick(f.ide, 'none', () => ides.length ? ask(p.select({ message: 'Open the container in an IDE when done?', options: [...ides, { value: 'none', label: 'No' }] })) : 'none');
|
|
117
117
|
|
|
118
118
|
p.note([
|
|
119
119
|
`Source ${src}`, `Name ${name}`,
|
|
@@ -121,22 +121,22 @@ export async function newWizard(f = {}) {
|
|
|
121
121
|
`Tools ${tools.join(', ') || 'none'}${oc ? ` (OpenCode ${opencodeVersion})` : ''}`,
|
|
122
122
|
...(providers.length ? [`Providers ${providers.join(', ')}`] : []),
|
|
123
123
|
].join('\n'), 'Summary');
|
|
124
|
-
if (!f.yes && !(await ask(p.confirm({ message: 'Create the
|
|
124
|
+
if (!f.yes && !(await ask(p.confirm({ message: 'Create the container?' })))) return p.cancel('Nothing created.');
|
|
125
125
|
|
|
126
126
|
let b;
|
|
127
127
|
try {
|
|
128
|
-
b = await
|
|
128
|
+
b = await createContainer({ src, name, tools, git, token, remote: remoteUrl, opencodeVersion }, step);
|
|
129
129
|
} catch (e) {
|
|
130
130
|
p.log.error(e.message);
|
|
131
|
-
p.outro(`
|
|
131
|
+
p.outro(`Container creation failed. Fix the problem, then clean up with: agentcage remove ${name}`);
|
|
132
132
|
process.exit(1);
|
|
133
133
|
}
|
|
134
134
|
if (providers.length) await providerFlow(b, providers);
|
|
135
135
|
if (oc) await registerFlow(b, !f.yes);
|
|
136
|
-
if (ide !== 'none') { openIde(b, ide); p.log.success('Opening the IDE attached to the
|
|
136
|
+
if (ide !== 'none') { openIde(b, ide); p.log.success('Opening the IDE attached to the container'); }
|
|
137
137
|
p.outro([
|
|
138
|
-
`
|
|
139
|
-
oc && `OpenCode Desktop: select "
|
|
138
|
+
`Container '${name}' is ready.`,
|
|
139
|
+
oc && `OpenCode Desktop: select "ac-${name}" and start a session. Only sessions on that server are isolated.`,
|
|
140
140
|
tools.includes('claude-code') && `Claude Code: agentcage shell ${name}, then run claude to log in.`,
|
|
141
141
|
`Shell: agentcage shell ${name} IDE: agentcage open ${name}`,
|
|
142
142
|
].filter(Boolean).join('\n'));
|