agentcage 0.1.4 → 0.1.6
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 +19 -8
- package/bin/agentcage.js +8 -4
- package/package.json +1 -1
- package/src/container.js +38 -5
- package/src/icon.js +95 -0
- package/src/opencode.js +39 -13
- package/src/wizard.js +35 -9
- package/template/Dockerfile +5 -0
- package/template/start.sh +12 -0
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
|
|
@@ -60,8 +60,9 @@ Run `agentcage` . The wizard will ask you for this:
|
|
|
60
60
|
API key in your terminal. Additional Providers can be added later.
|
|
61
61
|
7. **An IDE** to open the container in when it's ready (Cursor, VS Code, Antigravity).
|
|
62
62
|
|
|
63
|
-
Then it builds the container, adds it to OpenCode Desktop as server **ac-<name>** (a green project
|
|
64
|
-
`/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.
|
|
65
66
|
|
|
66
67
|
The same works without the dialog:
|
|
67
68
|
|
|
@@ -81,12 +82,12 @@ agentcage start <name> # start after a reboot, print the OpenCode serve
|
|
|
81
82
|
agentcage shell <name> # shell in the container (opencode, claude, git ... work here)
|
|
82
83
|
agentcage open <name> [ide] # Cursor (default), VS Code, or another VS Code-based IDE attached to the container
|
|
83
84
|
agentcage provider <name> # add model provider API keys to OpenCode in the container
|
|
84
|
-
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
|
|
85
86
|
agentcage upgrade <name> # rebuild with the Desktop app's OpenCode version (volumes kept)
|
|
86
87
|
agentcage stop <name>
|
|
87
88
|
agentcage remove <name> # delete container, volumes and config
|
|
88
89
|
agentcage version # installed version and the latest on npm
|
|
89
|
-
agentcage settings # project color
|
|
90
|
+
agentcage settings # project color, name prefixes, clean up dead containers
|
|
90
91
|
agentcage doctor
|
|
91
92
|
agentcage adopt <name> # import a container made by the old bash scripts in legacy/
|
|
92
93
|
```
|
|
@@ -106,6 +107,10 @@ created afterwards.
|
|
|
106
107
|
| OpenCode server prefix | `ac-` | the server's name in OpenCode Desktop |
|
|
107
108
|
| OpenCode project prefix | `ac-` | the project's name in OpenCode (the folder stays `/workspaces/<name>`) |
|
|
108
109
|
|
|
110
|
+
**Clean up dead containers** (also in Settings) lists leftovers of containers that no longer exist in Docker: agentcage
|
|
111
|
+
configs and OpenCode Desktop servers. Stopped containers are kept. A config whose volumes still hold code is not
|
|
112
|
+
preselected, because removing it deletes those volumes.
|
|
113
|
+
|
|
109
114
|
## Git push access
|
|
110
115
|
|
|
111
116
|
The copy in the container is separate from your laptop's, so changes leave the container via **git push**. The dialog offers two ways:
|
|
@@ -130,6 +135,12 @@ agentcage reads the Desktop app's version (from `opencode-cli.version` in its in
|
|
|
130
135
|
version in the container, because Desktop only talks to matching servers. `list`, `start` and `doctor` warn when the app has
|
|
131
136
|
updated since; `agentcage upgrade <name>` rebuilds the container with the new version and keeps your code, logins and sessions.
|
|
132
137
|
|
|
138
|
+
The OpenCode server is the container's main process, so it runs after every start: `agentcage start`, Docker Desktop, or an
|
|
139
|
+
IDE starting the container. Containers made by older agentcage versions get this after one `agentcage upgrade <name>`.
|
|
140
|
+
|
|
141
|
+
Cursor and VS Code open `/workspaces/<name>` however you attach (agentcage, Remote Explorer, Docker extension, recent list):
|
|
142
|
+
agentcage sets the folder in the IDE's attached-container config for the container.
|
|
143
|
+
|
|
133
144
|
Desktop has no API or deep link for adding a server, so agentcage writes the app's own state DB (`drafts.sqlite` in the
|
|
134
145
|
app's data folder, backed up as `drafts.sqlite.agentcage-backup`). **The app must be closed for this.** The dialog asks you
|
|
135
146
|
to close it. This relies on the app's internal storage format (written against Desktop 2.0.23), so a future app update may
|
package/bin/agentcage.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
6
|
import { ask, connectionInfo, newWizard, providerFlow, registerFlow, settingsWizard } from '../src/wizard.js';
|
|
7
|
-
import { detectDesktop, latestVersion } from '../src/opencode.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,11 +19,11 @@ 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 settings project color
|
|
26
|
+
agentcage settings project color, name prefixes, clean up dead containers
|
|
27
27
|
agentcage version | -v installed version, and the latest on npm (update: npm i -g agentcage@latest)
|
|
28
28
|
agentcage doctor check Docker, Node and OpenCode Desktop
|
|
29
29
|
agentcage adopt <name> import a container made by the old devbox bash scripts
|
|
@@ -73,7 +73,11 @@ const commands = {
|
|
|
73
73
|
const b = container(); await start(b); openIde(b, arg2 ?? f.ide ?? 'cursor');
|
|
74
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).');
|
|
75
75
|
},
|
|
76
|
-
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
|
+
},
|
|
77
81
|
async provider() { const b = container(); await start(b); await providerFlow(b, csv(f.providers)); },
|
|
78
82
|
async upgrade() {
|
|
79
83
|
const b = container(), v = detectDesktop().version ?? await latestVersion();
|
package/package.json
CHANGED
package/src/container.js
CHANGED
|
@@ -41,9 +41,14 @@ export function nameTaken(n) {
|
|
|
41
41
|
const bindable = port => new Promise(r => {
|
|
42
42
|
const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
|
|
43
43
|
});
|
|
44
|
+
// Host ports published by any Docker container, running or stopped (docker ps shows none for stopped ones)
|
|
45
|
+
export function publishedPorts() {
|
|
46
|
+
const ids = runSync('docker', ['ps', '-aq']).split('\n').filter(Boolean);
|
|
47
|
+
const out = ids.length ? runSync('docker', ['inspect', '-f', '{{json .HostConfig.PortBindings}}', ...ids]) : '';
|
|
48
|
+
return new Set([...out.matchAll(/"HostPort":"(\d+)"/g)].map(m => +m[1]));
|
|
49
|
+
}
|
|
44
50
|
async function freePort() {
|
|
45
|
-
const used = new Set(listContainers().map(b => b.port));
|
|
46
|
-
for (const m of runSync('docker', ['ps', '-a', '--format', '{{.Ports}}']).matchAll(/:(\d+)->/g)) used.add(+m[1]);
|
|
51
|
+
const used = new Set([...listContainers().map(b => b.port), ...publishedPorts()]);
|
|
47
52
|
for (let p = 4100; ; p++) if (!used.has(p) && (await bindable(p))) return p;
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -60,18 +65,21 @@ export function writeDevcontainer(b) {
|
|
|
60
65
|
workspaceFolder: ws(b),
|
|
61
66
|
mounts: [`source=${b.volumes.home},target=/home/node,type=volume`],
|
|
62
67
|
runArgs: [`--name=${b.container}`, `--label=agentcage.name=${b.name}`, ...(oc ? ['-p', `127.0.0.1:${b.port}:4096`] : [])],
|
|
63
|
-
containerEnv: oc
|
|
68
|
+
containerEnv: { AGENTCAGE_WORKSPACE: ws(b), ...(oc && { OPENCODE_SERVER_PASSWORD: b.password }) },
|
|
64
69
|
features: Object.fromEntries(tools.filter(t => t.feature).map(t => [t.feature, {}])),
|
|
65
70
|
remoteUser: 'node',
|
|
66
71
|
init: true,
|
|
72
|
+
overrideCommand: false, // run the image's CMD (agentcage-start) on every start, not only on devcontainer up
|
|
67
73
|
forwardPorts: [3000],
|
|
68
74
|
postCreateCommand: 'agentcage-post-create',
|
|
69
|
-
...(oc && { postStartCommand: 'setsid nohup opencode serve --hostname 0.0.0.0 --port 4096 > /tmp/opencode-serve.log 2>&1 &' }),
|
|
70
75
|
};
|
|
71
76
|
writeFileSync(join(dir, 'devcontainer.json'), JSON.stringify(cfg, null, 2) + '\n');
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
export
|
|
79
|
+
export async function up(b, rebuild) {
|
|
80
|
+
await devcontainer(['up', '--workspace-folder', b.dir, ...(rebuild ? ['--remove-existing-container'] : [])]);
|
|
81
|
+
pinIdeFolder(b);
|
|
82
|
+
}
|
|
75
83
|
export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}', b.container]) || 'missing';
|
|
76
84
|
|
|
77
85
|
// o: { src, name, tools, git: 'token'|'forward'|'none', token, opencodeVersion }. step(label, fn) shows progress.
|
|
@@ -100,10 +108,35 @@ export async function removeContainer(b) {
|
|
|
100
108
|
for (const v of Object.values(b.volumes)) await run('docker', ['volume', 'rm', v]).catch(() => {});
|
|
101
109
|
rmSync(b.dir, { recursive: true, force: true });
|
|
102
110
|
rmSync(join(ROOT, b.name), { recursive: true, force: true });
|
|
111
|
+
for (const d of ideConfigDirs()) rmSync(join(d, 'nameConfigs', `${b.container}.json`), { force: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// VS Code-based IDEs keep an attached-container config per container name (nameConfigs) and per image (imageConfigs).
|
|
115
|
+
// Its workspaceFolder decides the folder that opens on any attach (Remote Explorer, Docker extension, recent list),
|
|
116
|
+
// so point both at the code. Other keys in those files (e.g. extensions) are kept.
|
|
117
|
+
const CONFIG_HOME = P => P === 'win32' ? process.env.APPDATA ?? '' : P === 'darwin' ? join(homedir(), 'Library', 'Application Support')
|
|
118
|
+
: process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
119
|
+
const ideConfigDirs = (root = CONFIG_HOME(process.platform)) => (existsSync(root) ? readdirSync(root) : [])
|
|
120
|
+
.map(app => join(root, app, 'User', 'globalStorage')).filter(existsSync)
|
|
121
|
+
.flatMap(gs => readdirSync(gs).filter(d => d.endsWith('remote-containers')).map(d => join(gs, d)));
|
|
122
|
+
export function pinIdeFolder(b) {
|
|
123
|
+
const image = runSync('docker', ['inspect', '-f', '{{.Config.Image}}', b.container]);
|
|
124
|
+
for (const d of ideConfigDirs()) {
|
|
125
|
+
// image configs: only fix existing ones, they are shared by every container of that image
|
|
126
|
+
for (const f of [join(d, 'nameConfigs', `${b.container}.json`), ...(image ? [join(d, 'imageConfigs', `${image}.json`)].filter(existsSync) : [])]) {
|
|
127
|
+
try {
|
|
128
|
+
const cfg = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : {};
|
|
129
|
+
if (cfg.workspaceFolder === ws(b)) continue;
|
|
130
|
+
mkdirSync(join(f, '..'), { recursive: true });
|
|
131
|
+
writeFileSync(f, JSON.stringify({ ...cfg, workspaceFolder: ws(b) }, null, '\t'));
|
|
132
|
+
} catch {} // not plain JSON (hand-edited with comments): leave it alone
|
|
133
|
+
}
|
|
134
|
+
}
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
// Opens a VS Code-based IDE attached to the running container
|
|
106
138
|
export function openIde(b, ide) {
|
|
139
|
+
pinIdeFolder(b);
|
|
107
140
|
const hex = Buffer.from(JSON.stringify({ containerName: `/${b.container}` })).toString('hex');
|
|
108
141
|
launch(ide, ['--folder-uri', `vscode-remote://attached-container+${hex}${ws(b)}`]);
|
|
109
142
|
}
|
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,6 +2,7 @@
|
|
|
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';
|
|
6
7
|
import { loadSettings } from './settings.js';
|
|
7
8
|
|
|
@@ -33,29 +34,54 @@ export const desktopRunning = () => P === 'win32'
|
|
|
33
34
|
|
|
34
35
|
export const latestVersion = async () => (await (await fetch('https://registry.npmjs.org/@opencode/cli/latest')).json()).version;
|
|
35
36
|
|
|
36
|
-
// Desktop has no API or deep link for adding a server, so
|
|
37
|
-
|
|
37
|
+
// Desktop has no API or deep link for adding a server, so edit its state DB (app must be closed). A backup is kept.
|
|
38
|
+
const urlOf = x => (typeof x === 'string' ? x : x.http?.url ?? x.url);
|
|
39
|
+
async function serverState(edit) {
|
|
38
40
|
const { db, hasDb } = detectDesktop();
|
|
39
41
|
if (!hasDb) throw new Error(`OpenCode Desktop state not found at ${db}. Open the app once first.`);
|
|
40
|
-
if (desktopRunning()) throw new Error('OpenCode Desktop is running. Close it first.');
|
|
41
|
-
copyFileSync(db, db + '.agentcage-backup');
|
|
42
|
+
if (edit && desktopRunning()) throw new Error('OpenCode Desktop is running. Close it first.');
|
|
42
43
|
const { DatabaseSync } = await import('node:sqlite');
|
|
43
|
-
const d = new DatabaseSync(db
|
|
44
|
+
const d = new DatabaseSync(db, { readOnly: !edit });
|
|
44
45
|
const row = d.prepare("select value from state where name='opencode.global.dat' and key='server'").get();
|
|
45
46
|
const s = { list: [], hidden: {}, projects: {}, lastProject: {}, recentlyClosed: {}, ...(row && JSON.parse(row.value)) };
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
if (edit) {
|
|
48
|
+
copyFileSync(db, db + '.agentcage-backup');
|
|
49
|
+
edit(s, d);
|
|
50
|
+
d.prepare(`insert into state(name, key, value, updated_at) values ('opencode.global.dat', 'server', ?, ?)
|
|
51
|
+
on conflict(name, key) do update set value = excluded.value, updated_at = excluded.updated_at`).run(JSON.stringify(s), Date.now());
|
|
52
|
+
}
|
|
52
53
|
d.close();
|
|
54
|
+
return s;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
export const connectDesktop = c => serverState(s => {
|
|
58
|
+
const url = `http://127.0.0.1:${c.port}`, dir = `/workspaces/${c.name}`;
|
|
59
|
+
s.list = s.list.filter(x => urlOf(x) !== url).concat({ type: 'http', http: { url, password: c.password }, displayName: serverName(c) });
|
|
60
|
+
s.projects[url] = [{ worktree: dir, expanded: true }];
|
|
61
|
+
s.lastProject[url] = dir;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Servers on this machine (127.0.0.1:<port>) registered in Desktop: [{ url, port, name }]
|
|
65
|
+
export const desktopServers = async () => (await serverState()).list.map(x => ({ url: urlOf(x), name: x.displayName ?? urlOf(x) }))
|
|
66
|
+
.map(x => ({ ...x, port: +x.url?.match(/^https?:\/\/(?:127\.0\.0\.1|localhost):(\d+)/)?.[1] })).filter(x => x.port);
|
|
67
|
+
|
|
68
|
+
// Removes servers with everything Desktop keeps per server URL (UI history such as tabs is left to the app)
|
|
69
|
+
export const removeDesktopServers = urls => serverState((s, d) => {
|
|
70
|
+
s.list = s.list.filter(x => !urls.includes(urlOf(x)));
|
|
71
|
+
for (const k of ['hidden', 'projects', 'lastProject', 'recentlyClosed']) for (const u of urls) delete s[k][u];
|
|
72
|
+
const del = d.prepare("delete from state where name='opencode.global.dat' and key=?");
|
|
73
|
+
for (const u of urls) del.run(u);
|
|
74
|
+
const row = d.prepare("select value from state where name='opencode.global.dat' and key='home.servers'").get(), h = row && JSON.parse(row.value);
|
|
75
|
+
if (h?.collapsed) {
|
|
76
|
+
for (const u of urls) delete h.collapsed[u];
|
|
77
|
+
d.prepare("update state set value=? where name='opencode.global.dat' and key='home.servers'").run(JSON.stringify(h));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
55
81
|
// Names stored at creation; containers made before the settings existed use the current settings
|
|
56
82
|
export const serverName = c => c.serverName ?? loadSettings().serverPrefix + c.name;
|
|
57
83
|
|
|
58
|
-
// Sets the project's name and icon
|
|
84
|
+
// Sets the project's name and its generated icon in the container's OpenCode server. Asking about the folder creates the project first.
|
|
59
85
|
// The server starts in the background after `devcontainer up`, so retry for ~30 s.
|
|
60
86
|
export async function setupProject(c) {
|
|
61
87
|
const s = loadSettings(), name = c.projectName ?? s.projectPrefix + c.name, color = c.color ?? s.color;
|
|
@@ -66,7 +92,7 @@ export async function setupProject(c) {
|
|
|
66
92
|
try {
|
|
67
93
|
await api(`/vcs?location[directory]=${encodeURIComponent(dir)}`);
|
|
68
94
|
const { id } = (await api('/project')).find(x => x.canonical === dir);
|
|
69
|
-
return await api(`/project/${id}`, { method: 'PATCH', body: JSON.stringify({ name, icon: { color } }) });
|
|
95
|
+
return await api(`/project/${id}`, { method: 'PATCH', body: JSON.stringify({ name, icon: { color, override: projectIcon(c.name, s.projectPrefix) } }) });
|
|
70
96
|
} catch (e) { if (i >= 15) throw e; await new Promise(r => setTimeout(r, 2000)); }
|
|
71
97
|
}
|
|
72
98
|
}
|
package/src/wizard.js
CHANGED
|
@@ -3,9 +3,9 @@ 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, createContainer, nameTaken, openIde, ws } from './container.js';
|
|
6
|
+
import { cleanName, createContainer, listContainers, nameTaken, openIde, publishedPorts, removeContainer, state, ws } from './container.js';
|
|
7
7
|
import { isGitUrl, localRemote, parseRemote } from './git.js';
|
|
8
|
-
import { connectDesktop, desktopRunning, detectDesktop, latestVersion, loginProvider, serverName, setupProject } from './opencode.js';
|
|
8
|
+
import { connectDesktop, desktopRunning, desktopServers, detectDesktop, latestVersion, loginProvider, removeDesktopServers, serverName, setupProject } from './opencode.js';
|
|
9
9
|
import { ok, runSync } from './run.js';
|
|
10
10
|
import { COLORS, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
|
|
11
11
|
|
|
@@ -46,15 +46,39 @@ async function waitForDocker() {
|
|
|
46
46
|
// Registers the container in OpenCode Desktop, asking the user to close the app if needed
|
|
47
47
|
export async function registerFlow(b, interactive = true) {
|
|
48
48
|
if (!detectDesktop().hasDb) return p.note(connectionInfo(b), 'OpenCode Desktop not found. Add this server by hand');
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
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' }));
|
|
52
|
-
if (!go) return p.log.info(`Later: agentcage connect ${b.name}`);
|
|
53
|
-
}
|
|
49
|
+
if (!interactive && desktopRunning()) return p.log.warn(`OpenCode Desktop is open. Close it, then run: agentcage connect ${b.name}`);
|
|
50
|
+
if (!(await desktopClosed('Close OpenCode Desktop so the container can be added to it.'))) return p.log.info(`Later: agentcage connect ${b.name}`);
|
|
54
51
|
await connectDesktop(b);
|
|
55
52
|
p.log.success(`OpenCode Desktop: added server "${serverName(b)}" with project ${ws(b)}`);
|
|
56
53
|
}
|
|
57
54
|
|
|
55
|
+
// Desktop's state can only be written while the app is closed. Returns false if the user skips.
|
|
56
|
+
async function desktopClosed(message) {
|
|
57
|
+
while (desktopRunning()) if (!(await ask(p.confirm({ message, active: 'It is closed now', inactive: 'Skip for now' })))) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Leftovers of containers that no longer exist in Docker: agentcage configs (with their volumes) and OpenCode Desktop servers.
|
|
62
|
+
// Stopped containers still exist and are kept. Configs whose volumes still hold code are not preselected.
|
|
63
|
+
export async function cleanupFlow() {
|
|
64
|
+
const ports = publishedPorts(), dead = listContainers().filter(b => state(b) === 'missing');
|
|
65
|
+
const servers = detectDesktop().hasDb ? (await desktopServers()).filter(x => !ports.has(x.port)) : [];
|
|
66
|
+
const hasCode = b => ok('docker', ['volume', 'inspect', b.volumes.src]);
|
|
67
|
+
const options = [
|
|
68
|
+
...dead.map(b => ({ value: `c:${b.name}`, label: `agentcage config: ${b.name}`, hint: hasCode(b) ? 'also deletes its volumes and the code in them' : 'no volumes left' })),
|
|
69
|
+
...servers.map(x => ({ value: `s:${x.url}`, label: `OpenCode Desktop server: ${x.name}`, hint: x.url })),
|
|
70
|
+
];
|
|
71
|
+
if (!options.length) return p.log.success('Nothing to clean up: every agentcage config and OpenCode server has a Docker container.');
|
|
72
|
+
const picked = await ask(p.multiselect({ message: 'Remove leftovers of containers that no longer exist?', options, required: false,
|
|
73
|
+
initialValues: options.filter(o => !dead.some(b => o.value === `c:${b.name}` && hasCode(b))).map(o => o.value) }));
|
|
74
|
+
for (const b of dead.filter(b => picked.includes(`c:${b.name}`))) { await removeContainer(b); p.log.success(`Removed ${b.name}`); }
|
|
75
|
+
const urls = servers.filter(x => picked.includes(`s:${x.url}`)).map(x => x.url);
|
|
76
|
+
if (!urls.length) return;
|
|
77
|
+
if (!(await desktopClosed('Close OpenCode Desktop so its servers can be removed.'))) return p.log.info('OpenCode Desktop servers were not removed.');
|
|
78
|
+
await removeDesktopServers(urls);
|
|
79
|
+
p.log.success(`Removed ${urls.length} server${urls.length > 1 ? 's' : ''} from OpenCode Desktop`);
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
// Hands the terminal to OpenCode's own key prompt for each provider
|
|
59
83
|
export async function providerFlow(b, ids) {
|
|
60
84
|
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 })) }));
|
|
@@ -135,7 +159,7 @@ export async function newWizard(f = {}) {
|
|
|
135
159
|
process.exit(1);
|
|
136
160
|
}
|
|
137
161
|
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
|
|
162
|
+
if (oc) await setupProject(b).catch(e => p.log.warn(`Could not set the project name and icon in OpenCode: ${e.message}`));
|
|
139
163
|
if (oc) await registerFlow(b, !f.yes);
|
|
140
164
|
if (ide !== 'none') { openIde(b, ide); p.log.success('Opening the IDE attached to the container'); }
|
|
141
165
|
p.outro([
|
|
@@ -152,8 +176,10 @@ export async function settingsWizard() {
|
|
|
152
176
|
const s = loadSettings();
|
|
153
177
|
for (;;) {
|
|
154
178
|
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] })),
|
|
179
|
+
...Object.keys(DEFAULTS).map(k => ({ value: k, label: LABELS[k], hint: s[k] === '' ? 'none' : s[k] })),
|
|
180
|
+
{ value: '-cleanup', label: 'Clean up dead containers', hint: 'configs and OpenCode servers whose container is gone' }, { value: '', label: 'Done' }] }));
|
|
156
181
|
if (!key) break;
|
|
182
|
+
if (key === '-cleanup') { await cleanupFlow(); continue; }
|
|
157
183
|
s[key] = await ask(key === 'color'
|
|
158
184
|
? p.select({ message: LABELS.color, initialValue: s.color, options: COLORS.map(c => ({ value: c, label: c, hint: c === 'gray' ? "OpenCode's default" : undefined })) })
|
|
159
185
|
: 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 -') }));
|
package/template/Dockerfile
CHANGED
|
@@ -12,3 +12,8 @@ RUN if [ -n "$OPENCODE_VERSION" ]; then npm install -g @opencode/cli@${OPENCODE_
|
|
|
12
12
|
|
|
13
13
|
COPY post-create.sh /usr/local/bin/agentcage-post-create
|
|
14
14
|
RUN chmod +x /usr/local/bin/agentcage-post-create
|
|
15
|
+
|
|
16
|
+
# Main process: starts the OpenCode server on every container start (devcontainer.json sets overrideCommand: false)
|
|
17
|
+
COPY start.sh /usr/local/bin/agentcage-start
|
|
18
|
+
RUN chmod +x /usr/local/bin/agentcage-start
|
|
19
|
+
CMD ["agentcage-start"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Container main process. Keeps the OpenCode server running as the node user, from every container start
|
|
3
|
+
# (docker start, Docker Desktop restart, an IDE starting it), and restarts it if it exits.
|
|
4
|
+
if [ -n "$OPENCODE_SERVER_PASSWORD" ]; then
|
|
5
|
+
cd "${AGENTCAGE_WORKSPACE:-/tmp}"
|
|
6
|
+
while :; do
|
|
7
|
+
# node opens the log: root may not write a node-owned file in /tmp (fs.protected_regular)
|
|
8
|
+
runuser -u node -- sh -c 'exec opencode serve --hostname 0.0.0.0 --port 4096 >> /tmp/opencode-serve.log 2>&1'
|
|
9
|
+
sleep 2
|
|
10
|
+
done &
|
|
11
|
+
fi
|
|
12
|
+
exec sleep infinity
|