agentcage 0.1.3 → 0.1.5
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 +76 -34
- package/bin/agentcage.js +16 -6
- package/package.json +1 -1
- package/src/container.js +8 -4
- package/src/icon.js +95 -0
- package/src/opencode.js +22 -1
- package/src/settings.js +19 -0
- package/src/wizard.js +24 -4
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# agentcage
|
|
2
2
|
|
|
3
3
|
```
|
|
4
|
-
▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄▄ █
|
|
5
|
-
▄▄▄█ █ █ █▄▄▄ █ █ ▀█▀
|
|
6
|
-
█▄▄█ ▀▀▀█ █▄▄▄ █ █ █▄▄
|
|
7
|
-
▀▀▀▀
|
|
4
|
+
▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄▄ █ ▄▄▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄
|
|
5
|
+
▄▄▄█ █ █ █▄▄▄ █ █ ▀█▀ • █ ▄▄▄█ █ █ █▄▄▄
|
|
6
|
+
█▄▄█ ▀▀▀█ █▄▄▄ █ █ █▄▄ █▄▄▄ █▄▄█ ▀▀▀█ █▄▄▄
|
|
7
|
+
▀▀▀▀ ▀▀▀▀
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
Isolated dev containers for AI coding agents. Each project gets its own Linux container. **The project source lives
|
|
@@ -21,25 +21,48 @@ You need **Docker** (Docker Desktop on Windows/macOS, Docker Engine on Linux), *
|
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
npm install -g agentcage # also updates to the latest version
|
|
24
|
-
agentcage doctor # checks everything above
|
|
25
24
|
```
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
```bash
|
|
27
|
+
agentcage doctor # checks all the prerequisites.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Quickstart
|
|
33
|
+
|
|
34
|
+
Prerequisites:
|
|
35
|
+
|
|
36
|
+
- Node and Git installed
|
|
37
|
+
- Docker is running
|
|
38
|
+
- OpenCode installed
|
|
39
|
+
|
|
40
|
+
Run this in terminal:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
agentcage doctor # checks all the prerequisites.
|
|
44
|
+
agentcage # Starts the container-creation dialogue
|
|
45
|
+
```
|
|
28
46
|
|
|
29
|
-
|
|
47
|
+
Follow the steps
|
|
48
|
+
|
|
49
|
+
## Creating a container
|
|
50
|
+
|
|
51
|
+
Run `agentcage` . The wizard will ask you for this:
|
|
30
52
|
|
|
31
53
|
1. **Where the project is:** a folder on this computer (copied once, including untracked files such as `.env.local`)
|
|
32
|
-
|
|
33
|
-
2. **A name** for the container.
|
|
54
|
+
or a git URL (cloned).
|
|
55
|
+
2. **A name** for the container. The Docker container, OpenCode server and OpenCode project get the prefix `ac-` (see [Settings](#settings)).
|
|
34
56
|
3. **Git push access** (if the project has a remote). See [Git push access](#git-push-access).
|
|
35
57
|
4. **AI agents:** OpenCode (on by default) and/or Claude Code.
|
|
36
58
|
5. **Extra dev tools:** Python, GitHub CLI, pnpm. Node 24 and uv are always included.
|
|
37
59
|
6. **Model providers for OpenCode** (Anthropic, OpenAI, OpenRouter, Google, Groq). After the build, OpenCode asks for each
|
|
38
|
-
|
|
60
|
+
API key in your terminal. Additional Providers can be added later.
|
|
39
61
|
7. **An IDE** to open the container in when it's ready (Cursor, VS Code, Antigravity).
|
|
40
62
|
|
|
41
|
-
Then it builds the container, adds it to OpenCode Desktop as server **ac-<name>** with
|
|
42
|
-
`/workspaces/<name>` already
|
|
63
|
+
Then it builds the container, adds it to OpenCode Desktop as server **ac-<name>** (a green project with a generated
|
|
64
|
+
128x128 icon: `ac` in small letters over the first three letters of the name) with the project `/workspaces/<name>` already
|
|
65
|
+
open, and opens the IDE.
|
|
43
66
|
|
|
44
67
|
The same works without the dialog:
|
|
45
68
|
|
|
@@ -48,6 +71,8 @@ agentcage new ~/code/my-project --tools opencode,claude-code --git forward --ide
|
|
|
48
71
|
AGENTCAGE_GIT_TOKEN=github_pat_... agentcage new https://github.com/me/repo.git --git token --yes
|
|
49
72
|
```
|
|
50
73
|
|
|
74
|
+
|
|
75
|
+
|
|
51
76
|
## Commands
|
|
52
77
|
|
|
53
78
|
```bash
|
|
@@ -57,11 +82,12 @@ agentcage start <name> # start after a reboot, print the OpenCode serve
|
|
|
57
82
|
agentcage shell <name> # shell in the container (opencode, claude, git ... work here)
|
|
58
83
|
agentcage open <name> [ide] # Cursor (default), VS Code, or another VS Code-based IDE attached to the container
|
|
59
84
|
agentcage provider <name> # add model provider API keys to OpenCode in the container
|
|
60
|
-
agentcage connect <name> # (re)register the container in OpenCode Desktop
|
|
85
|
+
agentcage connect <name> # (re)register the container in OpenCode Desktop and refresh its project icon
|
|
61
86
|
agentcage upgrade <name> # rebuild with the Desktop app's OpenCode version (volumes kept)
|
|
62
87
|
agentcage stop <name>
|
|
63
88
|
agentcage remove <name> # delete container, volumes and config
|
|
64
89
|
agentcage version # installed version and the latest on npm
|
|
90
|
+
agentcage settings # project color and name prefixes for new containers
|
|
65
91
|
agentcage doctor
|
|
66
92
|
agentcage adopt <name> # import a container made by the old bash scripts in legacy/
|
|
67
93
|
```
|
|
@@ -69,20 +95,32 @@ agentcage adopt <name> # import a container made by the old bash script
|
|
|
69
95
|
Container config lives in `~/.agentcage/<name>/` (`container.json` and the generated `.devcontainer/`). `node_modules`, `.venv` and
|
|
70
96
|
`.next` are not copied. Instead the container runs `npm ci` / `uv sync` for every lockfile it finds.
|
|
71
97
|
|
|
98
|
+
## Settings
|
|
99
|
+
|
|
100
|
+
`agentcage settings` (or **Settings** in the menu) edits `~/.agentcage/settings.json`. Settings apply to containers
|
|
101
|
+
created afterwards.
|
|
102
|
+
|
|
103
|
+
| Setting | Default | |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| OpenCode project color | `green` | also gray (OpenCode's default), pink, blue, red, purple, yellow, orange, cyan |
|
|
106
|
+
| Docker container prefix | `ac-` | container and volume names, e.g. `ac-myproject`, `ac-myproject-src` |
|
|
107
|
+
| OpenCode server prefix | `ac-` | the server's name in OpenCode Desktop |
|
|
108
|
+
| OpenCode project prefix | `ac-` | the project's name in OpenCode (the folder stays `/workspaces/<name>`) |
|
|
109
|
+
|
|
72
110
|
## Git push access
|
|
73
111
|
|
|
74
112
|
The copy in the container is separate from your laptop's, so changes leave the container via **git push**. The dialog offers two ways:
|
|
75
113
|
|
|
76
114
|
- **A token for this repo only (recommended).** The dialog shows how to create one: a fine-grained GitHub token limited to
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
115
|
+
the repo with *Contents: read and write*, a GitLab project access token, or a Bitbucket repository access token. The token
|
|
116
|
+
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
|
|
117
|
+
any other credential helper, including the one an attached IDE forwards.
|
|
118
|
+
Cursor and VS Code still forward your **SSH agent**. To close that path too, set
|
|
119
|
+
`"dev.containers.copyGitConfig": false` and `"dev.containers.gitCredentialHelperConfigLocation": "none"` in the IDE's
|
|
120
|
+
settings and turn off SSH agent forwarding.
|
|
83
121
|
- **Skip: use your IDE's forwarded credentials.** While Cursor or VS Code is attached, your laptop's git credentials and
|
|
84
|
-
|
|
85
|
-
|
|
122
|
+
SSH agent are forwarded into the container, so pushing from the IDE just works. **While the IDE is attached, any agent in the
|
|
123
|
+
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.
|
|
86
124
|
|
|
87
125
|
For a git URL, token mode clones inside a throwaway container with the token. Forward mode clones on your laptop with your
|
|
88
126
|
own git credentials, then copies the repo in. Both work with private repos. Your git name and email are copied into the container.
|
|
@@ -106,12 +144,14 @@ never on your laptop, and survive rebuilds.
|
|
|
106
144
|
## What is and isn't isolated
|
|
107
145
|
|
|
108
146
|
- ✅ No host folders mounted, no Docker socket, not privileged. The OpenCode server is published only on `127.0.0.1` and
|
|
109
|
-
|
|
147
|
+
requires a random password.
|
|
110
148
|
- ⚠️ The container has internet access and can reach services on your laptop via `host.docker.internal`.
|
|
111
149
|
- ⚠️ Secrets you copy in (for example `.env.local`) and provider keys can be read by agents. Use dev and test keys.
|
|
112
150
|
- ⚠️ An attached IDE forwards your SSH agent, and in forward mode your git credentials (see above).
|
|
113
151
|
- ⚠️ Use the IDE for editing, diffs and git. Its built-in AI agent runs outside the container's isolation, so run agents through
|
|
114
|
-
|
|
152
|
+
OpenCode or Claude Code in the container.
|
|
153
|
+
|
|
154
|
+
|
|
115
155
|
|
|
116
156
|
## Platform notes
|
|
117
157
|
|
|
@@ -125,31 +165,33 @@ The setup dialog should let the user choose the backend: **plain Docker containe
|
|
|
125
165
|
(`sbx`). We tested `sbx` v0.48.0 on Windows 11 with the `alversjo-maps` repo:
|
|
126
166
|
|
|
127
167
|
**What works, and is better than plain Docker**
|
|
168
|
+
|
|
128
169
|
- Each sandbox is a microVM with its own kernel. Installed without admin rights (`winget install Docker.sbx`), created in ~10 s.
|
|
129
170
|
- `sbx create --clone opencode <repo>` gives the agent a private clone. Commits made in the sandbox are fetched on the host
|
|
130
|
-
|
|
171
|
+
through a `sandbox-<name>` git remote, without touching the working tree. Only the clone is visible inside.
|
|
131
172
|
- Provider keys via `sbx secret set anthropic --sandbox <name>` (also openai, openrouter, google, groq, xai...). The sandbox
|
|
132
|
-
|
|
173
|
+
only sees `proxy-managed` placeholders; a host proxy injects the real key, so keys never enter the sandbox.
|
|
133
174
|
- A repo-scoped GitHub token works the same way: `sbx secret set github --sandbox <name>`. By default a sandbox has no GitHub
|
|
134
|
-
|
|
175
|
+
access.
|
|
135
176
|
- `-p 127.0.0.1:4200:4096` publishes the OpenCode server port. OpenCode 2.0.23 served the Desktop/web UI from the sandbox
|
|
136
|
-
|
|
177
|
+
with the password.
|
|
137
178
|
- Network policies (open / balanced / locked down) with a per-sandbox network log.
|
|
138
179
|
- The image already has Node 22, uv, Python 3, git, gh, sudo and a Docker daemon. IDEs connect over SSH (`sbx setup ssh`,
|
|
139
|
-
|
|
180
|
+
then Remote-SSH to `<name>.sbx`; not tested yet).
|
|
140
181
|
|
|
141
182
|
**What is missing for our use**
|
|
183
|
+
|
|
142
184
|
1. **OpenCode version:** the image ships OpenCode 1.18.32 (`opencode-ai`, first on PATH). Desktop 2.0.23 needs a matching
|
|
143
|
-
|
|
185
|
+
server, so a kit or template must install `@opencode/cli@<desktop version>`.
|
|
144
186
|
2. **Server lifetime:** the sandbox's agent is the OpenCode TUI and `sbx exec` has no detached mode. `opencode serve` started
|
|
145
|
-
|
|
187
|
+
with `nohup` dies when the exec session ends. It only stays up while a foreground `sbx exec ... opencode serve` runs on the
|
|
146
188
|
host. A kit must start the server at sandbox boot (to be tested).
|
|
147
189
|
3. **Desktop registration** is still ours (the `drafts.sqlite` write), and the workspace path is the host path
|
|
148
|
-
|
|
190
|
+
(`/c/Users/...`), not `/workspaces/<name>`.
|
|
149
191
|
4. **SSH agent forwarding is on by default:** the host's SSH keys were listed inside the sandbox. That is the cross-repo risk
|
|
150
|
-
|
|
192
|
+
we want to avoid, so it must be turned off or warned about.
|
|
151
193
|
5. **Requirements:** Docker account sign-in for every user, macOS on Apple silicon only, Ubuntu 24.04+ with KVM only,
|
|
152
|
-
|
|
194
|
+
Windows 11 with Hypervisor Platform.
|
|
153
195
|
6. OpenCode API keys: `opencode auth login` only accepts keys interactively, but with `sbx secret` this is not needed.
|
|
154
196
|
|
|
155
197
|
**Plan:** add a `backend: docker | sbx` choice to the dialog and `container.json`. The `sbx` backend maps our steps to
|
|
@@ -159,4 +201,4 @@ registration stay the same.
|
|
|
159
201
|
|
|
160
202
|
## Legacy
|
|
161
203
|
|
|
162
|
-
`legacy/` holds the original bash scripts this tool replaces. `agentcage adopt <name>` takes over a container they created.
|
|
204
|
+
`legacy/` holds the original bash scripts this tool replaces. `agentcage adopt <name>` takes over a container they created.
|
package/bin/agentcage.js
CHANGED
|
@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import * as p from '@clack/prompts';
|
|
5
5
|
import { adoptContainer, listContainers, loadContainer, removeContainer, saveContainer, state, up, writeDevcontainer, openIde, ws } from '../src/container.js';
|
|
6
|
-
import { ask, connectionInfo, newWizard, providerFlow, registerFlow } from '../src/wizard.js';
|
|
7
|
-
import { detectDesktop, latestVersion } from '../src/opencode.js';
|
|
6
|
+
import { ask, connectionInfo, newWizard, providerFlow, registerFlow, settingsWizard } from '../src/wizard.js';
|
|
7
|
+
import { detectDesktop, latestVersion, setupProject } from '../src/opencode.js';
|
|
8
8
|
import { ok, run } from '../src/run.js';
|
|
9
9
|
|
|
10
10
|
process.removeAllListeners('warning'); // hide node:sqlite ExperimentalWarning
|
|
@@ -19,13 +19,15 @@ const HELP = `agentcage: isolated dev containers for AI coding agents
|
|
|
19
19
|
agentcage stop <name>
|
|
20
20
|
agentcage shell <name> shell in the container (run opencode, claude, git ... here)
|
|
21
21
|
agentcage open <name> [ide] open Cursor (default), VS Code or another VS Code-based IDE attached to the container
|
|
22
|
-
agentcage connect <name> (re)register the container in OpenCode Desktop (app must be closed)
|
|
22
|
+
agentcage connect <name> (re)register the container in OpenCode Desktop and refresh its project icon (app must be closed)
|
|
23
23
|
agentcage provider <name> add model provider API keys to OpenCode in the container
|
|
24
24
|
agentcage upgrade <name> rebuild with the Desktop app's OpenCode version (volumes kept)
|
|
25
25
|
agentcage remove <name> delete container, volumes and config
|
|
26
|
-
agentcage
|
|
26
|
+
agentcage settings project color and name prefixes for new containers
|
|
27
|
+
agentcage version | -v installed version, and the latest on npm (update: npm i -g agentcage@latest)
|
|
27
28
|
agentcage doctor check Docker, Node and OpenCode Desktop
|
|
28
29
|
agentcage adopt <name> import a container made by the old devbox bash scripts
|
|
30
|
+
agentcage help | -h this help
|
|
29
31
|
|
|
30
32
|
In token mode, a token can also be passed in the AGENTCAGE_GIT_TOKEN environment variable.`;
|
|
31
33
|
|
|
@@ -49,6 +51,7 @@ async function start(b) {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
const commands = {
|
|
54
|
+
settings: settingsWizard,
|
|
52
55
|
async version() {
|
|
53
56
|
const v = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
54
57
|
const latest = await fetch('https://registry.npmjs.org/agentcage/latest').then(r => r.json()).then(j => j.version, () => undefined);
|
|
@@ -70,7 +73,11 @@ const commands = {
|
|
|
70
73
|
const b = container(); await start(b); openIde(b, arg2 ?? f.ide ?? 'cursor');
|
|
71
74
|
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).');
|
|
72
75
|
},
|
|
73
|
-
async connect() {
|
|
76
|
+
async connect() {
|
|
77
|
+
const b = container();
|
|
78
|
+
await start(b).then(() => setupProject(b)).catch(e => p.log.warn(`Could not set the project name and icon in OpenCode: ${e.message}`));
|
|
79
|
+
await registerFlow(b);
|
|
80
|
+
},
|
|
74
81
|
async provider() { const b = container(); await start(b); await providerFlow(b, csv(f.providers)); },
|
|
75
82
|
async upgrade() {
|
|
76
83
|
const b = container(), v = detectDesktop().version ?? await latestVersion();
|
|
@@ -103,7 +110,10 @@ const commands = {
|
|
|
103
110
|
const containers = listContainers();
|
|
104
111
|
if (!containers.length) return newWizard();
|
|
105
112
|
p.intro('agentcage');
|
|
106
|
-
|
|
113
|
+
// '-settings' can't clash with a container name (names start with a letter or digit)
|
|
114
|
+
const pick = await ask(p.select({ message: 'What do you want to do?', options: [{ value: '', label: 'Create a new container' },
|
|
115
|
+
...containers.map(b => ({ value: b.name, label: b.name, hint: state(b) })), { value: '-settings', label: 'Settings' }] }));
|
|
116
|
+
if (pick === '-settings') return settingsWizard();
|
|
107
117
|
if (!pick) return newWizard();
|
|
108
118
|
const action = await ask(p.select({ message: pick, options: [
|
|
109
119
|
{ value: 'start', label: 'Start and show connection details' }, { value: 'open', label: 'Open in IDE' },
|
package/package.json
CHANGED
package/src/container.js
CHANGED
|
@@ -7,6 +7,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import { createServer } from 'node:net';
|
|
8
8
|
import { devcontainer, launch, ok, run, runSync } from './run.js';
|
|
9
9
|
import { TOOLS } from './catalog.js';
|
|
10
|
+
import { loadSettings } from './settings.js';
|
|
10
11
|
import { copyIdentity, isGitUrl, parseRemote, seed, setupToken } from './git.js';
|
|
11
12
|
|
|
12
13
|
export const ROOT = join(homedir(), '.agentcage');
|
|
@@ -32,7 +33,10 @@ export const listContainers = () => (existsSync(ROOT) ? readdirSync(ROOT).filter
|
|
|
32
33
|
|
|
33
34
|
export const cleanName = s => s.replace(/\.git\/?$/, '').replace(/[\\/:]+$/, '').replace(/.*[:\\/]/, '')
|
|
34
35
|
.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
35
|
-
export
|
|
36
|
+
export function nameTaken(n) {
|
|
37
|
+
const px = loadSettings().containerPrefix;
|
|
38
|
+
return hasContainer(n) || ok('docker', ['inspect', px + n]) || ok('docker', ['volume', 'inspect', `${px}${n}-src`]);
|
|
39
|
+
}
|
|
36
40
|
|
|
37
41
|
const bindable = port => new Promise(r => {
|
|
38
42
|
const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
|
|
@@ -72,10 +76,10 @@ export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}
|
|
|
72
76
|
|
|
73
77
|
// o: { src, name, tools, git: 'token'|'forward'|'none', token, opencodeVersion }. step(label, fn) shows progress.
|
|
74
78
|
export async function createContainer(o, step = (_, f) => f()) {
|
|
75
|
-
const { name } = o, src = isGitUrl(o.src) ? o.src : resolve(o.src);
|
|
79
|
+
const { name } = o, src = isGitUrl(o.src) ? o.src : resolve(o.src), s = loadSettings(), px = s.containerPrefix;
|
|
76
80
|
const b = {
|
|
77
|
-
name, dir: join(ROOT, name), container:
|
|
78
|
-
volumes: { src:
|
|
81
|
+
name, dir: join(ROOT, name), container: px + name, serverName: s.serverPrefix + name, projectName: s.projectPrefix + name, color: s.color,
|
|
82
|
+
volumes: { src: `${px}${name}-src`, home: `${px}${name}-home` },
|
|
79
83
|
port: await freePort(), password: randomBytes(16).toString('hex'),
|
|
80
84
|
source: src, git: o.git, tools: o.tools, opencodeVersion: o.opencodeVersion, created: new Date().toISOString(),
|
|
81
85
|
};
|
package/src/icon.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Generates the 128x128 project icon for OpenCode: dark green background, white text in two rows —
|
|
2
|
+
// the project prefix ("ac") in small letters on top, the first three letters of the container name below.
|
|
3
|
+
// A hand-rolled PNG encoder (zlib + CRC) with a 5x7 bitmap font keeps this dependency-free.
|
|
4
|
+
import { deflateSync } from 'node:zlib';
|
|
5
|
+
|
|
6
|
+
const SIZE = 128, SS = 4, BG = [20, 83, 45], FG = [255, 255, 255]; // SS: supersampling for smooth edges
|
|
7
|
+
|
|
8
|
+
// 5x7 pixel font ('#' = ink); lowercase letters, digits and - cover all names and prefixes
|
|
9
|
+
const FONT = {
|
|
10
|
+
a: ['.....', '.....', '.###.', '....#', '.####', '#...#', '.####'],
|
|
11
|
+
b: ['#....', '#....', '####.', '#...#', '#...#', '#...#', '####.'],
|
|
12
|
+
c: ['.....', '.....', '.####', '#....', '#....', '#....', '.####'],
|
|
13
|
+
d: ['....#', '....#', '.####', '#...#', '#...#', '#...#', '.####'],
|
|
14
|
+
e: ['.....', '.....', '.###.', '#...#', '#####', '#....', '.###.'],
|
|
15
|
+
f: ['..##.', '.#...', '####.', '.#...', '.#...', '.#...', '.#...'],
|
|
16
|
+
g: ['.....', '.....', '.###.', '#...#', '.####', '....#', '.###.'],
|
|
17
|
+
h: ['#....', '#....', '####.', '#...#', '#...#', '#...#', '#...#'],
|
|
18
|
+
i: ['..#..', '.....', '..#..', '..#..', '..#..', '..#..', '..#..'],
|
|
19
|
+
j: ['....#', '.....', '....#', '....#', '....#', '#...#', '.###.'],
|
|
20
|
+
k: ['#....', '#....', '#...#', '#..#.', '###..', '#..#.', '#...#'],
|
|
21
|
+
l: ['..#..', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
|
|
22
|
+
m: ['.....', '.....', '##.##', '#.#.#', '#.#.#', '#.#.#', '#.#.#'],
|
|
23
|
+
n: ['.....', '.....', '####.', '#...#', '#...#', '#...#', '#...#'],
|
|
24
|
+
o: ['.....', '.....', '.###.', '#...#', '#...#', '#...#', '.###.'],
|
|
25
|
+
p: ['.....', '.....', '####.', '#...#', '#...#', '####.', '#....'],
|
|
26
|
+
q: ['.....', '.....', '.####', '#...#', '#...#', '.####', '....#'],
|
|
27
|
+
r: ['.....', '.....', '#.##.', '##..#', '#....', '#....', '#....'],
|
|
28
|
+
s: ['.....', '.....', '.####', '#....', '.###.', '....#', '####.'],
|
|
29
|
+
t: ['.#...', '.#...', '###..', '.#...', '.#...', '.#...', '.##..'],
|
|
30
|
+
u: ['.....', '.....', '#...#', '#...#', '#...#', '#...#', '.###.'],
|
|
31
|
+
v: ['.....', '.....', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
|
|
32
|
+
w: ['.....', '.....', '#...#', '#...#', '#.#.#', '#.#.#', '.#.#.'],
|
|
33
|
+
x: ['.....', '.....', '#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
|
|
34
|
+
y: ['.....', '.....', '#...#', '#...#', '.#.#.', '..#..', '..#..'],
|
|
35
|
+
z: ['.....', '.....', '####.', '...#.', '..#..', '.#...', '####.'],
|
|
36
|
+
0: ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
|
|
37
|
+
1: ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
|
|
38
|
+
2: ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
|
|
39
|
+
3: ['.###.', '#...#', '....#', '..##.', '....#', '#...#', '.###.'],
|
|
40
|
+
4: ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
|
|
41
|
+
5: ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
|
|
42
|
+
6: ['.###.', '#....', '#....', '####.', '#...#', '#...#', '.###.'],
|
|
43
|
+
7: ['#####', '....#', '...#.', '..#..', '..#..', '..#..', '..#..'],
|
|
44
|
+
8: ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
|
|
45
|
+
9: ['.###.', '#...#', '#...#', '.####', '....#', '....#', '.###.'],
|
|
46
|
+
'-': ['.....', '.....', '.....', '.###.', '.....', '.....', '.....'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; return c; });
|
|
50
|
+
const crc32 = buf => { let c = -1; for (const b of buf) c = crcTable[(c ^ b) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; };
|
|
51
|
+
const chunk = (type, data) => { const t = Buffer.from(type), head = Buffer.alloc(8), crc = Buffer.alloc(4);
|
|
52
|
+
head.writeUInt32BE(data.length); t.copy(head, 4); crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
|
|
53
|
+
return Buffer.concat([head, data, crc]); };
|
|
54
|
+
|
|
55
|
+
const png = rgba => {
|
|
56
|
+
const ihdr = Buffer.alloc(13), stride = SIZE * 4, raw = Buffer.alloc((stride + 1) * SIZE);
|
|
57
|
+
ihdr.writeUInt32BE(SIZE); ihdr.writeUInt32BE(SIZE, 4); ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
|
|
58
|
+
for (let y = 0; y < SIZE; y++) rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
59
|
+
return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Draws one text row centered horizontally; s and y are in final-image pixels (rendered at SS x)
|
|
63
|
+
const draw = (img, dim, text, s, y) => {
|
|
64
|
+
let x = Math.round((SIZE - (text.length * 6 - 1) * s) / 2) * SS;
|
|
65
|
+
for (const ch of text) {
|
|
66
|
+
const g = FONT[ch];
|
|
67
|
+
if (g) for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '#')
|
|
68
|
+
for (let dy = 0; dy < s * SS; dy++) for (let dx = 0; dx < s * SS; dx++) {
|
|
69
|
+
const i = ((y * SS + r * s * SS + dy) * dim + x + c * s * SS + dx) * 3;
|
|
70
|
+
img[i] = FG[0]; img[i + 1] = FG[1]; img[i + 2] = FG[2];
|
|
71
|
+
}
|
|
72
|
+
x += 6 * s * SS;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The icon as a data URL for OpenCode's project icon override
|
|
77
|
+
export const projectIcon = (name, prefix = 'ac') => {
|
|
78
|
+
const dim = SIZE * SS, img = Buffer.alloc(dim * dim * 3);
|
|
79
|
+
for (let i = 0; i < dim * dim; i++) { img[i * 3] = BG[0]; img[i * 3 + 1] = BG[1]; img[i * 3 + 2] = BG[2]; }
|
|
80
|
+
const rows = [[prefix.replace(/[^a-z0-9]/g, ''), 3], [name.slice(0, 3), 6]].filter(([t]) => t);
|
|
81
|
+
const gap = 12, total = rows.reduce((n, [, s]) => n + 7 * s, 0) + gap * (rows.length - 1);
|
|
82
|
+
let y = Math.round((SIZE - total) / 2);
|
|
83
|
+
for (const [t, s] of rows) { draw(img, dim, t, s, y); y += 7 * s + gap; }
|
|
84
|
+
const out = Buffer.alloc(SIZE * SIZE * 4); // downsample SS x SS blocks for smooth edges
|
|
85
|
+
for (let py = 0; py < SIZE; py++) for (let px = 0; px < SIZE; px++) {
|
|
86
|
+
let r = 0, g = 0, b = 0;
|
|
87
|
+
for (let dy = 0; dy < SS; dy++) for (let dx = 0; dx < SS; dx++) {
|
|
88
|
+
const i = ((py * SS + dy) * dim + px * SS + dx) * 3;
|
|
89
|
+
r += img[i]; g += img[i + 1]; b += img[i + 2];
|
|
90
|
+
}
|
|
91
|
+
const o = (py * SIZE + px) * 4, n = SS * SS;
|
|
92
|
+
out[o] = Math.round(r / n); out[o + 1] = Math.round(g / n); out[o + 2] = Math.round(b / n); out[o + 3] = 255;
|
|
93
|
+
}
|
|
94
|
+
return `data:image/png;base64,${png(out).toString('base64')}`;
|
|
95
|
+
};
|
package/src/opencode.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { copyFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { projectIcon } from './icon.js';
|
|
5
6
|
import { ok, run, runSync } from './run.js';
|
|
7
|
+
import { loadSettings } from './settings.js';
|
|
6
8
|
|
|
7
9
|
const P = process.platform, H = homedir(), E = process.env;
|
|
8
10
|
const DATA_DIR = P === 'win32' ? join(E.APPDATA ?? '', 'ai.opencode.desktop')
|
|
@@ -43,7 +45,7 @@ export async function connectDesktop(container) {
|
|
|
43
45
|
const row = d.prepare("select value from state where name='opencode.global.dat' and key='server'").get();
|
|
44
46
|
const s = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {}, ...(row && JSON.parse(row.value)) };
|
|
45
47
|
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: container.password }, displayName:
|
|
48
|
+
s.list = s.list.filter(x => urlOf(x) !== url).concat({ type: 'http', http: { url, password: container.password }, displayName: serverName(container) });
|
|
47
49
|
s.projects[url] = [{ worktree: dir, expanded: true }];
|
|
48
50
|
s.lastProject[url] = dir;
|
|
49
51
|
d.prepare(`insert into state(name, key, value, updated_at) values ('opencode.global.dat', 'server', ?, ?)
|
|
@@ -51,6 +53,25 @@ export async function connectDesktop(container) {
|
|
|
51
53
|
d.close();
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// Names stored at creation; containers made before the settings existed use the current settings
|
|
57
|
+
export const serverName = c => c.serverName ?? loadSettings().serverPrefix + c.name;
|
|
58
|
+
|
|
59
|
+
// Sets the project's name and its generated icon in the container's OpenCode server. Asking about the folder creates the project first.
|
|
60
|
+
// The server starts in the background after `devcontainer up`, so retry for ~30 s.
|
|
61
|
+
export async function setupProject(c) {
|
|
62
|
+
const s = loadSettings(), name = c.projectName ?? s.projectPrefix + c.name, color = c.color ?? s.color;
|
|
63
|
+
const base = `http://127.0.0.1:${c.port}/api`, dir = `/workspaces/${c.name}`;
|
|
64
|
+
const api = (path, init) => fetch(base + path, { ...init, headers: { authorization: `Basic ${Buffer.from(`opencode:${c.password}`).toString('base64')}`, 'content-type': 'application/json' } })
|
|
65
|
+
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`OpenCode API ${path}: HTTP ${r.status}`))));
|
|
66
|
+
for (let i = 0; ; i++) {
|
|
67
|
+
try {
|
|
68
|
+
await api(`/vcs?location[directory]=${encodeURIComponent(dir)}`);
|
|
69
|
+
const { id } = (await api('/project')).find(x => x.canonical === dir);
|
|
70
|
+
return await api(`/project/${id}`, { method: 'PATCH', body: JSON.stringify({ name, icon: { color, override: projectIcon(c.name, s.projectPrefix) } }) });
|
|
71
|
+
} catch (e) { if (i >= 15) throw e; await new Promise(r => setTimeout(r, 2000)); }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
54
75
|
// OpenCode only reads API keys from an interactive terminal, so hand the terminal to its own prompt inside the container.
|
|
55
76
|
// The key goes straight into the container's home volume and never passes through agentcage or the host.
|
|
56
77
|
export const loginProvider = (container, provider) =>
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// User settings (~/.agentcage/settings.json). Missing keys fall back to the defaults.
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const FILE = join(homedir(), '.agentcage', 'settings.json');
|
|
7
|
+
// Project icon colors OpenCode Desktop can show (others render as gray). green first: our default, gray: OpenCode's.
|
|
8
|
+
export const COLORS = ['green', 'gray', 'pink', 'blue', 'red', 'purple', 'yellow', 'orange', 'cyan'];
|
|
9
|
+
export const DEFAULTS = { color: 'green', containerPrefix: 'ac-', serverPrefix: 'ac-', projectPrefix: 'ac-' };
|
|
10
|
+
export const LABELS = {
|
|
11
|
+
color: 'OpenCode project color', containerPrefix: 'Docker container prefix',
|
|
12
|
+
serverPrefix: 'OpenCode server prefix', projectPrefix: 'OpenCode project prefix',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const loadSettings = () => ({ ...DEFAULTS, ...(existsSync(FILE) && JSON.parse(readFileSync(FILE, 'utf8'))) });
|
|
16
|
+
export function saveSettings(s) {
|
|
17
|
+
mkdirSync(join(FILE, '..'), { recursive: true });
|
|
18
|
+
writeFileSync(FILE, JSON.stringify(s, null, 2) + '\n');
|
|
19
|
+
}
|
package/src/wizard.js
CHANGED
|
@@ -5,8 +5,9 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { AGENTS, DEV_TOOLS, PROVIDERS, hostInfo } from './catalog.js';
|
|
6
6
|
import { cleanName, createContainer, nameTaken, openIde, ws } from './container.js';
|
|
7
7
|
import { isGitUrl, localRemote, parseRemote } from './git.js';
|
|
8
|
-
import { connectDesktop, desktopRunning, detectDesktop, latestVersion, loginProvider } from './opencode.js';
|
|
8
|
+
import { connectDesktop, desktopRunning, detectDesktop, latestVersion, loginProvider, serverName, setupProject } from './opencode.js';
|
|
9
9
|
import { ok, runSync } from './run.js';
|
|
10
|
+
import { COLORS, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
|
|
10
11
|
|
|
11
12
|
export const ask = async q => {
|
|
12
13
|
const v = await q;
|
|
@@ -51,7 +52,7 @@ export async function registerFlow(b, interactive = true) {
|
|
|
51
52
|
if (!go) return p.log.info(`Later: agentcage connect ${b.name}`);
|
|
52
53
|
}
|
|
53
54
|
await connectDesktop(b);
|
|
54
|
-
p.log.success(`OpenCode Desktop: added server "
|
|
55
|
+
p.log.success(`OpenCode Desktop: added server "${serverName(b)}" with project ${ws(b)}`);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
// Hands the terminal to OpenCode's own key prompt for each provider
|
|
@@ -77,8 +78,10 @@ export async function newWizard(f = {}) {
|
|
|
77
78
|
: p.text({ message: 'Repository URL', placeholder: 'https://github.com/owner/repo.git', validate: v => (isGitUrl(v ?? '') ? undefined : 'Enter an https:// or git@ URL') }));
|
|
78
79
|
if (!isGitUrl(src) && !existsSync(src)) throw new Error(`Folder not found: ${src}`);
|
|
79
80
|
|
|
81
|
+
const s = loadSettings(), px = v => (v ? `"${v}"` : 'none');
|
|
82
|
+
if (!f.yes) p.log.info(`Name prefixes: Docker container ${px(s.containerPrefix)}, OpenCode server ${px(s.serverPrefix)}, OpenCode project ${px(s.projectPrefix)}. Change with: agentcage settings`);
|
|
80
83
|
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
|
|
84
|
+
const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: 'Container name', initialValue: cleanName(src), validate: nameOk })));
|
|
82
85
|
if (nameOk(name)) throw new Error(nameOk(name));
|
|
83
86
|
|
|
84
87
|
// Git push access
|
|
@@ -132,12 +135,29 @@ export async function newWizard(f = {}) {
|
|
|
132
135
|
process.exit(1);
|
|
133
136
|
}
|
|
134
137
|
if (providers.length) await providerFlow(b, providers);
|
|
138
|
+
if (oc) await setupProject(b).catch(e => p.log.warn(`Could not set the project name and icon in OpenCode: ${e.message}`));
|
|
135
139
|
if (oc) await registerFlow(b, !f.yes);
|
|
136
140
|
if (ide !== 'none') { openIde(b, ide); p.log.success('Opening the IDE attached to the container'); }
|
|
137
141
|
p.outro([
|
|
138
142
|
`Container '${name}' is ready.`,
|
|
139
|
-
oc && `OpenCode Desktop: select "
|
|
143
|
+
oc && `OpenCode Desktop: select "${serverName(b)}" and start a session. Only sessions on that server are isolated.`,
|
|
140
144
|
tools.includes('claude-code') && `Claude Code: agentcage shell ${name}, then run claude to log in.`,
|
|
141
145
|
`Shell: agentcage shell ${name} IDE: agentcage open ${name}`,
|
|
142
146
|
].filter(Boolean).join('\n'));
|
|
143
147
|
}
|
|
148
|
+
|
|
149
|
+
// Edit user settings one at a time until Done. They apply to containers created afterwards.
|
|
150
|
+
export async function settingsWizard() {
|
|
151
|
+
p.intro('agentcage · settings');
|
|
152
|
+
const s = loadSettings();
|
|
153
|
+
for (;;) {
|
|
154
|
+
const key = await ask(p.select({ message: 'Which setting? (applies to new containers)', options: [
|
|
155
|
+
...Object.keys(DEFAULTS).map(k => ({ value: k, label: LABELS[k], hint: s[k] === '' ? 'none' : s[k] })), { value: '', label: 'Done' }] }));
|
|
156
|
+
if (!key) break;
|
|
157
|
+
s[key] = await ask(key === 'color'
|
|
158
|
+
? p.select({ message: LABELS.color, initialValue: s.color, options: COLORS.map(c => ({ value: c, label: c, hint: c === 'gray' ? "OpenCode's default" : undefined })) })
|
|
159
|
+
: p.text({ message: `${LABELS[key]} (empty for none)`, initialValue: s[key], defaultValue: '', validate: v => (/^[a-z0-9-]*$/.test(v ?? '') ? undefined : 'Use lowercase letters, digits and -') }));
|
|
160
|
+
saveSettings(s);
|
|
161
|
+
}
|
|
162
|
+
p.outro('Settings saved.');
|
|
163
|
+
}
|