agentcage 0.1.7 → 0.1.8

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 CHANGED
@@ -46,6 +46,29 @@ agentcage # Starts the container-creation dialogue
46
46
 
47
47
  Follow the steps
48
48
 
49
+ ## The agentcage window (UI)
50
+
51
+ ```bash
52
+ agentcage ui # opens the agentcage window
53
+ ```
54
+
55
+ A local web UI with the same features as the terminal dialog:
56
+ - **Containers:** each container's status, and Start, Stop, Open in IDE, Add to OpenCode, OpenCode providers (gear button: add model provider API keys), Rebuild and Remove.
57
+ - **New container:** a dialog to create one from a folder, a git URL, or a repository picked from **GitHub** or **Azure Repos**.
58
+ - **Connections:** reuses a logged-in `gh` / `az` CLI. Otherwise you paste a personal access token, which is stored in
59
+ `~/.agentcage/connections.json`. These tokens are only used on your computer to list and clone repositories. They never go
60
+ into a container.
61
+ - **Settings** and **Help**.
62
+
63
+ The window opens as an app window (Edge or Chrome `--app`), or as a normal browser tab if neither is installed.
64
+
65
+ **Desktop icon:** the first `agentcage ui` offers to add an agentcage icon to the desktop and Start Menu (Windows) or app
66
+ menu (Linux). Clicking it starts the UI without a console window. `agentcage ui --shortcut` adds the icon later, and
67
+ `--no-shortcut` removes it.
68
+
69
+ **How it runs:** the UI is served by a small server on `127.0.0.1` only, protected by a random session token. It stops a
70
+ few seconds after you close the window, unless a container is still being created. `--no-open` only prints the URL.
71
+
49
72
  ## Creating a container
50
73
 
51
74
  Run `agentcage` . The wizard will ask you for this:
@@ -77,6 +100,7 @@ AGENTCAGE_GIT_TOKEN=github_pat_... agentcage new https://github.com/me/repo.git
77
100
 
78
101
  ```bash
79
102
  agentcage # menu: create a container, or start/open/shell/upgrade/remove an existing one
103
+ agentcage ui # the agentcage window: containers, create dialog, GitHub / Azure Repos, help
80
104
  agentcage list # containers, state, port, OpenCode version
81
105
  agentcage start <name> # start after a reboot, print the OpenCode server URL and password
82
106
  agentcage shell <name> # shell in the container (opencode, claude, git ... work here)
package/bin/agentcage.js CHANGED
@@ -2,16 +2,19 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
- import { adoptContainer, listContainers, loadContainer, removeContainer, saveContainer, state, up, writeDevcontainer, openIde, ws } from '../src/container.js';
5
+ import { adoptContainer, listContainers, loadContainer, removeContainer, state, up, upgradeContainer, openIde, ws } from '../src/container.js';
6
6
  import { ask, connectionInfo, newWizard, providerFlow, registerFlow, settingsWizard } from '../src/wizard.js';
7
7
  import { detectDesktop, latestVersion, setupProject } from '../src/opencode.js';
8
8
  import { ok, run } from '../src/run.js';
9
+ import { loadSettings, saveSettings } from '../src/settings.js';
9
10
 
10
11
  process.removeAllListeners('warning'); // hide node:sqlite ExperimentalWarning
11
12
 
12
13
  const HELP = `agentcage: isolated dev containers for AI coding agents
13
14
 
14
15
  agentcage interactive menu (create or manage containers)
16
+ agentcage ui open the agentcage window (containers, create dialog, GitHub / Azure Repos, help)
17
+ [--shortcut] add a desktop / menu icon [--no-shortcut] remove it [--no-open] only print the URL
15
18
  agentcage new [folder|git-url] create a container [--name x] [--tools opencode,claude-code,python,gh,pnpm]
16
19
  [--git token|forward] [--providers anthropic,...] [--ide cursor|code|none] [--yes]
17
20
  agentcage list containers, state, port, OpenCode version
@@ -34,7 +37,8 @@ const HELP = `agentcage: isolated dev containers for AI coding agents
34
37
  let { values: f, positionals: [cmd, arg, arg2] } = parseArgs({
35
38
  allowPositionals: true,
36
39
  options: { name: { type: 'string' }, tools: { type: 'string' }, git: { type: 'string' }, providers: { type: 'string' },
37
- ide: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' } },
40
+ ide: { type: 'string' }, yes: { type: 'boolean', short: 'y' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
41
+ shortcut: { type: 'boolean' }, 'no-shortcut': { type: 'boolean' }, detach: { type: 'boolean' }, 'no-open': { type: 'boolean' } },
38
42
  });
39
43
  const csv = s => s?.split(',').map(x => x.trim()).filter(Boolean);
40
44
  const container = () => loadContainer(arg ?? (() => { throw new Error(`Usage: agentcage ${cmd} <name>`); })());
@@ -51,6 +55,21 @@ async function start(b) {
51
55
  }
52
56
 
53
57
  const commands = {
58
+ // The UI. The desktop shortcut runs it with --detach: the server continues in the background without a console window.
59
+ async ui() {
60
+ const sc = await import('../src/shortcut.js');
61
+ if (f.shortcut || f['no-shortcut']) {
62
+ const files = f.shortcut ? sc.createShortcuts() : sc.removeShortcuts();
63
+ return console.log(files.length ? `${f.shortcut ? 'Created' : 'Removed'}:\n ${files.join('\n ')}` : 'No shortcuts found.');
64
+ }
65
+ if (f.detach) return sc.detach(!f['no-open']);
66
+ if (process.stdout.isTTY && !loadSettings().uiShortcutAsked && process.platform !== 'darwin') {
67
+ const add = await ask(p.confirm({ message: `Add an agentcage icon to ${sc.SHORTCUT_PLACES}?` }));
68
+ saveSettings({ ...loadSettings(), uiShortcutAsked: true });
69
+ if (add) p.log.success(`Added: ${sc.createShortcuts().join(', ')}`);
70
+ }
71
+ await (await import('../src/server.js')).serve({ open: !f['no-open'] });
72
+ },
54
73
  settings: settingsWizard,
55
74
  async version() {
56
75
  const v = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -82,9 +101,7 @@ const commands = {
82
101
  async upgrade() {
83
102
  const b = container(), v = detectDesktop().version ?? await latestVersion();
84
103
  const s = p.spinner(); s.start(`Rebuilding '${b.name}' with OpenCode ${v} (volumes are kept)`);
85
- b.opencodeVersion = v; writeDevcontainer(b);
86
- await up(b, true).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
87
- saveContainer(b);
104
+ await upgradeContainer(b, v).then(() => s.stop(`Rebuilt with OpenCode ${v}`), e => { s.error('Rebuild failed'); throw e; });
88
105
  if (b.tools.includes('opencode')) await setupProject(b).catch(e => p.log.warn(`Could not set the project name and icon in OpenCode: ${e.message}`));
89
106
  },
90
107
  async remove() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcage",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Isolated dev containers for AI coding agents (OpenCode, Claude Code), connected to OpenCode Desktop",
5
5
  "type": "module",
6
6
  "bin": {
package/src/catalog.js CHANGED
@@ -24,5 +24,7 @@ const HOSTS = {
24
24
  'github.com': { user: 'x-access-token', help: p => `Create a fine-grained token: https://github.com/settings/personal-access-tokens/new\n Repository access: Only select repositories → ${p}\n Permissions: Contents → Read and write (add Pull requests if agents should open PRs)` },
25
25
  'gitlab.com': { user: 'oauth2', help: p => `Create a project access token: https://gitlab.com/${p}/-/settings/access_tokens\n Role: Developer. Scopes: read_repository, write_repository` },
26
26
  'bitbucket.org': { user: 'x-token-auth', help: p => `Create a repository access token: https://bitbucket.org/${p}/admin/access-tokens\n Permissions: Repositories → Write` },
27
+ // Azure accepts any username with a PAT. A PAT can't be limited to one repo: it reaches every repo in the organization.
28
+ 'dev.azure.com': { user: 'pat', help: p => `Create a personal access token: https://dev.azure.com/${p.split('/')[0]}/_usersSettings/tokens\n Organization: ${p.split('/')[0]}. Scopes: Code → Read & write\n Note: Azure DevOps tokens cover every repo in the organization you can access, not only this one.` },
27
29
  };
28
30
  export const hostInfo = host => HOSTS[host] ?? { user: 'git', help: p => `Create an access token on ${host} with read/write access to ${p} only.` };
@@ -0,0 +1,100 @@
1
+ // GitHub and Azure DevOps connections for `agentcage ui`: list repos to create containers from, and clone them.
2
+ // Tokens come from a logged-in `gh` / `az` CLI first, else from a PAT saved in ~/.agentcage/connections.json.
3
+ // They are only used on this machine to list and clone; they never go into a container or to the browser.
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { parseRemote } from './git.js';
9
+ import { runSync } from './run.js';
10
+
11
+ const FILE = join(homedir(), '.agentcage', 'connections.json');
12
+ const AZ_RESOURCE = '499b84ac-1321-427f-aa17-267ca6975798'; // Azure DevOps
13
+ export const HOSTS = { github: 'github.com', azure: 'dev.azure.com' };
14
+
15
+ const load = () => (existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {});
16
+ function save(all) {
17
+ mkdirSync(join(FILE, '..'), { recursive: true });
18
+ writeFileSync(FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
19
+ chmodSync(FILE, 0o600); // mode only applies to new files. On Windows the user profile's ACL protects it.
20
+ }
21
+
22
+ // az is az.cmd on Windows, which Node only runs through a shell; the command line is constant
23
+ const azToken = () => spawnSync(`az account get-access-token --resource ${AZ_RESOURCE} --query accessToken -o tsv`,
24
+ { shell: true, encoding: 'utf8', windowsHide: true }).stdout?.trim() || null;
25
+
26
+ // { token, source: 'gh'|'az'|'pat', bearer } or null
27
+ export function token(id) {
28
+ const cli = id === 'github' ? runSync('gh', ['auth', 'token']) : azToken();
29
+ if (cli) return { token: cli, source: id === 'github' ? 'gh' : 'az', bearer: true };
30
+ const pat = load()[id]?.token;
31
+ return pat ? { token: pat, source: 'pat', bearer: id === 'github' } : null;
32
+ }
33
+
34
+ const auth = t => (t.bearer ? `Bearer ${t.token}` : `Basic ${Buffer.from(`:${t.token}`).toString('base64')}`);
35
+ async function get(url, t) {
36
+ const r = await fetch(url, { headers: { authorization: auth(t), accept: 'application/json', 'user-agent': 'agentcage' } });
37
+ // Azure DevOps answers a bad token with an HTML sign-in page (203), not an error status
38
+ if (r.status === 401 || r.status === 203 || (r.ok && !r.headers.get('content-type')?.includes('json')))
39
+ throw new Error(`${new URL(url).host} did not accept the token. Check that it is valid, not expired, and has the needed scopes.`);
40
+ if (!r.ok) throw new Error(`${new URL(url).host}: HTTP ${r.status}`);
41
+ return { body: await r.json(), link: r.headers.get('link') };
42
+ }
43
+
44
+ // Azure DevOps organizations the token can see. Needs a token for "all accessible organizations";
45
+ // orgs the user typed in are always included.
46
+ async function azureOrgs(t) {
47
+ const saved = load().azure?.orgs ?? [];
48
+ try {
49
+ const { body: me } = await get('https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1', t);
50
+ const { body } = await get(`https://app.vssps.visualstudio.com/_apis/accounts?memberId=${me.id}&api-version=7.1`, t);
51
+ return { user: me.emailAddress ?? me.displayName, orgs: [...new Set([...body.value.map(a => a.accountName), ...saved])] };
52
+ } catch { return { user: null, orgs: saved }; }
53
+ }
54
+
55
+ // Connection status for the UI, without tokens: { github: { source, user } | null, azure: { source, user, orgs } | null }
56
+ export async function status() {
57
+ const gh = token('github'), az = token('azure');
58
+ return {
59
+ github: gh && { source: gh.source, user: (await get('https://api.github.com/user', gh).catch(() => null))?.body.login ?? null },
60
+ azure: az && { source: az.source, ...(await azureOrgs(az)) },
61
+ };
62
+ }
63
+
64
+ // Checks a pasted PAT before saving it. Azure: org is needed when the PAT is limited to one organization.
65
+ export async function savePat(id, pat, org) {
66
+ const t = { token: pat, bearer: id === 'github' };
67
+ if (id === 'github') await get('https://api.github.com/user', t);
68
+ else await get(org ? `https://dev.azure.com/${encodeURIComponent(org)}/_apis/projects?api-version=7.1&$top=1`
69
+ : 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1', t);
70
+ const all = load();
71
+ all[id] = { token: pat, ...(org && { orgs: [org] }) };
72
+ save(all);
73
+ }
74
+ export function removePat(id) { const all = load(); delete all[id]; save(all); }
75
+
76
+ // [{ name, url, private }] with HTTPS clone URLs
77
+ export async function repos(id, org) {
78
+ const t = token(id);
79
+ if (!t) throw new Error(`Not connected to ${id === 'github' ? 'GitHub' : 'Azure DevOps'}`);
80
+ if (id === 'azure') {
81
+ if (!org) throw new Error('Pick an Azure DevOps organization');
82
+ const { body } = await get(`https://dev.azure.com/${encodeURIComponent(org)}/_apis/git/repositories?api-version=7.1`, t);
83
+ return body.value.map(r => ({ name: `${r.project.name}/${r.name}`, url: r.remoteUrl.replace(/\/\/[^@/]+@/, '//'), private: r.project.visibility !== 'public' }))
84
+ .sort((a, b) => a.name.localeCompare(b.name));
85
+ }
86
+ const list = [];
87
+ let url = 'https://api.github.com/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member';
88
+ for (let i = 0; url && i < 10; i++) {
89
+ const { body, link } = await get(url, t);
90
+ list.push(...body.map(r => ({ name: r.full_name, url: r.clone_url, private: r.private })));
91
+ url = link?.match(/<([^>]+)>;\s*rel="next"/)?.[1];
92
+ }
93
+ return list;
94
+ }
95
+
96
+ // Token to clone a URL with, if it is on a connected host: { token, header } (header: send as Bearer, for az tokens)
97
+ export function cloneToken(url) {
98
+ const id = Object.keys(HOSTS).find(k => HOSTS[k] === parseRemote(url)?.host), t = id && token(id);
99
+ return t ? { token: t.token, header: t.source === 'az' } : null;
100
+ }
package/src/container.js CHANGED
@@ -37,6 +37,9 @@ export function nameTaken(n) {
37
37
  const px = loadSettings().containerPrefix;
38
38
  return hasContainer(n) || ok('docker', ['inspect', px + n]) || ok('docker', ['volume', 'inspect', `${px}${n}-src`]);
39
39
  }
40
+ // Why a container name can't be used, or undefined if it can
41
+ export const nameError = v => (!/^[a-z0-9][a-z0-9-]*$/.test(v ?? '') ? 'Use lowercase letters, digits and -'
42
+ : nameTaken(v) ? `A container named '${v}' already exists` : undefined);
40
43
 
41
44
  const bindable = port => new Promise(r => {
42
45
  const s = createServer().once('error', () => r(false)).listen(port, '127.0.0.1', () => s.close(() => r(true)));
@@ -82,11 +85,20 @@ export async function up(b, rebuild) {
82
85
  }
83
86
  export const state = b => runSync('docker', ['inspect', '-f', '{{.State.Status}}', b.container]) || 'missing';
84
87
 
85
- // o: { src, name, tools, git: 'token'|'forward'|'none', token, opencodeVersion }. step(label, fn) shows progress.
88
+ // Rebuild with another OpenCode version (and the current template); volumes are kept
89
+ export async function upgradeContainer(b, version) {
90
+ b.opencodeVersion = version;
91
+ writeDevcontainer(b);
92
+ await up(b, true);
93
+ saveContainer(b);
94
+ }
95
+
96
+ // o: { src, name, tools, git: 'token'|'forward'|'none', token, remote, opencodeVersion, cloneToken, cloneBearer }.
97
+ // step(label, fn) shows progress.
86
98
  export async function createContainer(o, step = (_, f) => f()) {
87
99
  const { name } = o, src = isGitUrl(o.src) ? o.src : resolve(o.src), s = loadSettings(), px = s.containerPrefix;
88
100
  const b = {
89
- name, dir: join(ROOT, name), container: px + name, serverName: s.serverPrefix + name, projectName: s.projectPrefix + name, color: s.color,
101
+ name, dir: join(ROOT, name), container: px + name, serverName: s.serverPrefix + name, projectName: s.projectPrefix + name, color: s.color, projectIcon: s.projectIcon,
90
102
  volumes: { src: `${px}${name}-src`, home: `${px}${name}-home` },
91
103
  port: await freePort(), password: randomBytes(16).toString('hex'),
92
104
  source: src, git: o.git, tools: o.tools, opencodeVersion: o.opencodeVersion, created: new Date().toISOString(),
@@ -94,7 +106,8 @@ export async function createContainer(o, step = (_, f) => f()) {
94
106
  saveContainer(b);
95
107
  writeDevcontainer(b);
96
108
  await run('docker', ['volume', 'create', b.volumes.src]);
97
- await step('Copying the source into the container', () => seed(b.volumes.src, { ...o, src }));
109
+ // Clone with the repo token, else with a connection token (o.cloneToken, from agentcage ui); none is kept in the container
110
+ await step('Copying the source into the container', () => seed(b.volumes.src, { src, token: o.token ?? o.cloneToken, bearer: !o.token && o.cloneBearer }));
98
111
  await step('Building and starting the container (the first build takes a few minutes)', () => up(b));
99
112
  await step('Setting up git', async () => {
100
113
  await copyIdentity(b.container);
@@ -134,6 +147,14 @@ export function pinIdeFolder(b) {
134
147
  }
135
148
  }
136
149
 
150
+ // IDEs that can attach to a container, found on PATH or in their usual install folder: [{ value: command, label }]
151
+ export function findIdes() {
152
+ const found = cmd => !!runSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
153
+ const ag = process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Antigravity IDE', 'bin', 'antigravity-ide.cmd') : 'antigravity-ide';
154
+ return [['cursor', 'Cursor'], ['code', 'VS Code'], [ag, 'Antigravity']].filter(([c]) => existsSync(c) || found(c))
155
+ .map(([value, label]) => ({ value, label }));
156
+ }
157
+
137
158
  // Opens a VS Code-based IDE attached to the running container
138
159
  export function openIde(b, ide) {
139
160
  pinIdeFolder(b);
package/src/git.js CHANGED
@@ -6,9 +6,10 @@ import { run, runSync } from './run.js';
6
6
  import { hostInfo } from './catalog.js';
7
7
 
8
8
  // https://host/owner/repo(.git), git@host:owner/repo(.git), ssh://git@host/owner/repo → { host, path, https }
9
+ // Azure DevOps URLs (https://[org@]dev.azure.com/org/project/_git/repo) have no .git suffix
9
10
  export function parseRemote(url) {
10
11
  const m = url?.match(/^(?:(?:https?|ssh|git):\/\/(?:[^@/]+@)?|[^@\s/]+@)([^/:]+)[:/](.+?)(?:\.git)?\/?$/);
11
- return m && { host: m[1], path: m[2], https: `https://${m[1]}/${m[2]}.git` };
12
+ return m && { host: m[1], path: m[2], https: `https://${m[1]}/${m[2]}${m[1] === 'dev.azure.com' ? '' : '.git'}` };
12
13
  }
13
14
  export const isGitUrl = s => /^(https?|ssh|git):\/\/|^[^@\s/]+@[^:]+:/.test(s);
14
15
  export const localRemote = dir => runSync('git', ['-C', dir, 'remote', 'get-url', 'origin']) || null;
@@ -17,12 +18,16 @@ export const localRemote = dir => runSync('git', ['-C', dir, 'remote', 'get-url'
17
18
  const TAR = 'cd /src && tar -cf - --exclude=node_modules --exclude=.venv --exclude=.next . | tar -xf - -C /dst';
18
19
  const seedFolder = (vol, dir) => run('docker', ['run', '--rm', '-v', `${vol}:/dst`, '-v', `${dir}:/src:ro`, 'alpine', 'sh', '-c', TAR]);
19
20
 
20
- export async function seed(vol, { src, git, token }) {
21
+ // token: clone with it (a repo token, or a GitHub / Azure connection token). bearer: send it as a Bearer header
22
+ // (Microsoft Entra tokens from `az`) instead of as a password.
23
+ export async function seed(vol, { src, token, bearer }) {
21
24
  if (!isGitUrl(src)) await seedFolder(vol, src);
22
- else if (git === 'token') {
25
+ else if (token) {
23
26
  // Clone in a throwaway container; the token travels as an env var, never in the URL or on disk
24
27
  const helper = '!f() { echo username=$GIT_USER; echo password=$GIT_TOKEN; }; f';
25
- await run('docker', ['run', '--rm', '-e', 'GIT_USER', '-e', 'GIT_TOKEN', '-v', `${vol}:/dst`, 'alpine/git', '-c', `credential.helper=${helper}`, 'clone', src, '/dst'],
28
+ const clone = bearer ? ['--entrypoint', 'sh', 'alpine/git', '-c', 'git -c "http.extraHeader=Authorization: Bearer $GIT_TOKEN" clone "$0" /dst', src]
29
+ : ['alpine/git', '-c', `credential.helper=${helper}`, 'clone', src, '/dst'];
30
+ await run('docker', ['run', '--rm', '-e', 'GIT_USER', '-e', 'GIT_TOKEN', '-v', `${vol}:/dst`, ...clone],
26
31
  { env: { GIT_USER: hostInfo(parseRemote(src).host).user, GIT_TOKEN: token } });
27
32
  } else {
28
33
  // Clone on the host with the user's own git credentials (private repos work), then copy in
@@ -45,11 +50,12 @@ export async function copyIdentity(container) {
45
50
 
46
51
  // Repo-scoped token: stored with git's credential store in the container's home volume, used for this repo only.
47
52
  // The empty credential.helper entry clears helpers set earlier (such as the one an attached IDE forwards).
53
+ // $2 is host/path exactly as cloned (with .git, or without for Azure): useHttpPath matches the credential on it.
48
54
  const TOKEN_SETUP = `IFS= read -r T; umask 077
49
- printf 'https://%s:%s@%s/%s.git\\n' "$1" "$T" "$2" "$3" > ~/.git-credentials
50
- git remote set-url origin "https://$2/$3.git"
55
+ printf 'https://%s:%s@%s\\n' "$1" "$T" "$2" > ~/.git-credentials
56
+ git remote set-url origin "https://$2"
51
57
  git config --local --unset-all credential.helper
52
58
  git config --local --add credential.helper '' && git config --local --add credential.helper store && git config --local credential.useHttpPath true`;
53
59
 
54
60
  export const setupToken = (container, dir, remote, token) =>
55
- run('docker', ['exec', '-i', '-u', 'node', '-w', dir, container, 'sh', '-c', TOKEN_SETUP, '_', hostInfo(remote.host).user, remote.host, remote.path], { input: token + '\n' });
61
+ run('docker', ['exec', '-i', '-u', 'node', '-w', dir, container, 'sh', '-c', TOKEN_SETUP, '_', hostInfo(remote.host).user, remote.https.slice(8)], { input: token + '\n' });
package/src/icon.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // the project prefix ("ac") in small letters on top, the first three letters of the container name in capitals below —
3
3
  // and a light color band along the bottom, its hue picked from the name (looks random, stays the same per project).
4
4
  // A hand-rolled PNG encoder (zlib + CRC) with a 5x7 bitmap font keeps this dependency-free.
5
+ import { readFileSync } from 'node:fs';
5
6
  import { deflateSync } from 'node:zlib';
6
7
 
7
8
  const SIZE = 128, SS = 4, BG = [20, 83, 45], FG = [255, 255, 255]; // SS: supersampling for smooth edges
@@ -110,11 +111,26 @@ const draw = (img, dim, text, s, y) => {
110
111
  };
111
112
 
112
113
  // The icon as a data URL for OpenCode's project icon override
113
- export const projectIcon = (name, prefix = 'ac') => {
114
+ export const projectIcon = (name, prefix = 'ac') =>
115
+ `data:image/png;base64,${png(render([[prefix.replace(/[^a-z0-9]/g, ''), 3], [name.slice(0, 3).toUpperCase(), 6]], name)).toString('base64')}`;
116
+
117
+ // The agentcage app icon (UI, shortcuts): src/logo.svg, with logo.png rendered from it at 256 px
118
+ export const appIcon = () => readFileSync(new URL('./logo.png', import.meta.url));
119
+
120
+ // The app icon as .ico for Windows shortcuts: one 256 px PNG entry (size bytes 0 mean 256)
121
+ export function appIco() {
122
+ const p = appIcon(), head = Buffer.alloc(22);
123
+ head.writeUInt16LE(1, 2); head.writeUInt16LE(1, 4); // ICONDIR: icon type, one image
124
+ head.writeUInt16LE(1, 10); head.writeUInt16LE(32, 12); head.writeUInt32LE(p.length, 14); head.writeUInt32LE(22, 18);
125
+ return Buffer.concat([head, p]);
126
+ }
127
+
128
+ // rows: [[text, scale]] stacked and centered above the accent band; seed picks the band's hue. Returns RGBA pixels.
129
+ function render(rows, seed) {
114
130
  const dim = SIZE * SS, img = Buffer.alloc(dim * dim * 3);
115
- const band = accent(name);
131
+ const band = accent(seed);
116
132
  for (let i = 0; i < dim * dim; i++) { const c = i >= dim * (SIZE - BAND) * SS ? band : BG; img[i * 3] = c[0]; img[i * 3 + 1] = c[1]; img[i * 3 + 2] = c[2]; }
117
- const rows = [[prefix.replace(/[^a-z0-9]/g, ''), 3], [name.slice(0, 3).toUpperCase(), 6]].filter(([t]) => t);
133
+ rows = rows.filter(([t]) => t);
118
134
  const gap = 12, total = rows.reduce((n, [, s]) => n + 7 * s, 0) + gap * (rows.length - 1);
119
135
  let y = Math.round((SIZE - BAND - total) / 2) - 4; // centered above the band, then nudged up
120
136
  for (const [t, s] of rows) { draw(img, dim, t, s, y); y += 7 * s + gap; }
@@ -128,5 +144,5 @@ export const projectIcon = (name, prefix = 'ac') => {
128
144
  const o = (py * SIZE + px) * 4, n = SS * SS;
129
145
  out[o] = Math.round(r / n); out[o + 1] = Math.round(g / n); out[o + 2] = Math.round(b / n); out[o + 3] = 255;
130
146
  }
131
- return `data:image/png;base64,${png(out).toString('base64')}`;
132
- };
147
+ return out;
148
+ }
package/src/logo.png ADDED
Binary file
package/src/logo.svg ADDED
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1f7a44"/><stop offset="1" stop-color="#14532d"/></linearGradient>
4
+ </defs>
5
+ <rect width="256" height="256" rx="56" fill="url(#bg)"/>
6
+ <g stroke-linecap="round" stroke-linejoin="round" fill="none">
7
+ <!-- side faces, lightly shaded -->
8
+ <path d="M56 92 L128 134 L128 216 L56 174 Z" fill="#fff" fill-opacity=".08" stroke="none"/>
9
+ <path d="M128 134 L200 92 L200 174 L128 216 Z" fill="#fff" fill-opacity=".16" stroke="none"/>
10
+ <!-- lid -->
11
+ <path d="M128 50 L200 92 L128 134 L56 92 Z" fill="#bbf7d0" stroke="#fff" stroke-width="10"/>
12
+ <!-- bars -->
13
+ <path d="M80 106 V188 M104 120 V202 M152 120 V202 M176 106 V188" stroke="#fff" stroke-width="8"/>
14
+ <!-- outline -->
15
+ <path d="M56 92 V174 L128 216 L200 174 V92 M128 134 V216" stroke="#fff" stroke-width="10"/>
16
+ </g>
17
+ </svg>
package/src/opencode.js CHANGED
@@ -31,6 +31,10 @@ export function detectDesktop() {
31
31
  export const desktopRunning = () => P === 'win32'
32
32
  ? /^OpenCode\.exe/im.test(runSync('tasklist', ['/FI', 'IMAGENAME eq OpenCode.exe', '/NH']))
33
33
  : ok('pgrep', ['-x', 'OpenCode']);
34
+ // Same check without blocking (agentcage ui polls it)
35
+ export const desktopRunningAsync = () => (P === 'win32'
36
+ ? run('tasklist', ['/FI', 'IMAGENAME eq OpenCode.exe', '/NH']).then(o => /^OpenCode\.exe/im.test(o))
37
+ : run('pgrep', ['-x', 'OpenCode']).then(() => true)).catch(() => false);
34
38
 
35
39
  export const latestVersion = async () => (await (await fetch('https://registry.npmjs.org/@opencode/cli/latest')).json()).version;
36
40
 
@@ -81,18 +85,27 @@ export const removeDesktopServers = urls => serverState((s, d) => {
81
85
  // Names stored at creation; containers made before the settings existed use the current settings
82
86
  export const serverName = c => c.serverName ?? loadSettings().serverPrefix + c.name;
83
87
 
84
- // Sets the project's name and its generated icon in the container's OpenCode server. Asking about the folder creates the project first.
88
+ // JSON calls to the container's OpenCode server API (over the published loopback port, with its password)
89
+ const serverApi = c => (path, init) => fetch(`http://127.0.0.1:${c.port}/api${path}`, { ...init,
90
+ headers: { authorization: `Basic ${Buffer.from(`opencode:${c.password}`).toString('base64')}`, 'content-type': 'application/json' } })
91
+ .then(r => (r.ok ? r.json() : Promise.reject(new Error(`OpenCode API ${path}: HTTP ${r.status}`))));
92
+
93
+ // Stores a model provider API key in the container's OpenCode (its home volume), without a terminal
94
+ export const setProviderKey = (c, id, key) =>
95
+ serverApi(c)('/credential', { method: 'POST', body: JSON.stringify({ integrationID: id, label: 'agentcage', value: { type: 'key', key } }) });
96
+
97
+ // Sets the project's name and icon (generated image or plain color) in the container's OpenCode server. Asking about the folder creates the project first.
85
98
  // The server starts in the background after `devcontainer up`, so retry for ~30 s.
86
99
  export async function setupProject(c) {
87
100
  const s = loadSettings(), name = c.projectName ?? s.projectPrefix + c.name, color = c.color ?? s.color;
88
- const base = `http://127.0.0.1:${c.port}/api`, dir = `/workspaces/${c.name}`;
89
- const api = (path, init) => fetch(base + path, { ...init, headers: { authorization: `Basic ${Buffer.from(`opencode:${c.password}`).toString('base64')}`, 'content-type': 'application/json' } })
90
- .then(r => (r.ok ? r.json() : Promise.reject(new Error(`OpenCode API ${path}: HTTP ${r.status}`))));
101
+ const api = serverApi(c), dir = `/workspaces/${c.name}`;
91
102
  for (let i = 0; ; i++) {
92
103
  try {
93
104
  await api(`/vcs?location[directory]=${encodeURIComponent(dir)}`);
94
105
  const { id } = (await api('/project')).find(x => x.canonical === dir);
95
- return await api(`/project/${id}`, { method: 'PATCH', body: JSON.stringify({ name, icon: { color, override: projectIcon(c.name, s.projectPrefix) } }) });
106
+ // An empty override clears an earlier generated image, so the plain color shows
107
+ const override = (c.projectIcon ?? s.projectIcon) === 'color' ? '' : projectIcon(c.name, s.projectPrefix);
108
+ return await api(`/project/${id}`, { method: 'PATCH', body: JSON.stringify({ name, icon: { color, override } }) });
96
109
  } catch (e) { if (i >= 15) throw e; await new Promise(r => setTimeout(r, 2000)); }
97
110
  }
98
111
  }
package/src/run.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Process helpers. Arguments are always passed as arrays (no shell), so paths never get mangled.
2
+ // windowsHide: on Windows, no console window flashes up for each command when agentcage ui runs without a terminal.
2
3
  import { spawn, spawnSync } from 'node:child_process';
3
4
  import { createRequire } from 'node:module';
4
5
  import { dirname, join } from 'node:path';
@@ -8,15 +9,15 @@ const fail = (cmd, args, code, err) =>
8
9
 
9
10
  // Quick commands: returns trimmed stdout ('' if the command is missing and check is off)
10
11
  export function runSync(cmd, args = [], { check = false, input } = {}) {
11
- const r = spawnSync(cmd, args, { encoding: 'utf8', input });
12
+ const r = spawnSync(cmd, args, { encoding: 'utf8', input, windowsHide: true });
12
13
  if (check && r.status !== 0) throw fail(cmd, args, r.status, r.stderr || r.error?.message);
13
14
  return (r.stdout || '').trim();
14
15
  }
15
- export const ok = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
16
+ export const ok = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore', windowsHide: true }).status === 0;
16
17
 
17
18
  // Long-running commands (keeps spinners animating). input: string sent on stdin; tty: hand over the terminal
18
19
  export const run = (cmd, args = [], { input, env, tty } = {}) => new Promise((resolve, reject) => {
19
- const p = spawn(cmd, args, { env: { ...process.env, ...env }, stdio: tty ? 'inherit' : 'pipe' });
20
+ const p = spawn(cmd, args, { env: { ...process.env, ...env }, stdio: tty ? 'inherit' : 'pipe', windowsHide: true });
20
21
  let out = '', err = '';
21
22
  p.stdout?.on('data', d => (out += d));
22
23
  p.stderr?.on('data', d => (err += d));
@@ -29,9 +30,11 @@ export const run = (cmd, args = [], { input, env, tty } = {}) => new Promise((re
29
30
  const DC = join(dirname(createRequire(import.meta.url).resolve('@devcontainers/cli/package.json')), 'devcontainer.js');
30
31
  export const devcontainer = args => run(process.execPath, [DC, ...args]);
31
32
 
32
- // IDE launchers (cursor, code) are .cmd shims on Windows and need a shell there
33
+ // IDE launchers (cursor, code) are .cmd shims on Windows and need a shell there; its console window is hidden.
34
+ // Programs given by .exe path (the browser) start directly, since hiding would hide their window too.
33
35
  export function launch(cmd, args) {
34
- const win = process.platform === 'win32';
36
+ const shell = process.platform === 'win32' && !/\.exe$/i.test(cmd);
35
37
  const line = [cmd, ...args].map(a => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
36
- return spawn(win ? line : cmd, win ? [] : args, { shell: win, stdio: 'ignore', detached: true }).unref();
38
+ const c = spawn(shell ? line : cmd, shell ? [] : args, { shell, stdio: 'ignore', detached: true, windowsHide: shell });
39
+ c.on('error', () => {}).unref(); // a missing program must not crash a long-running process (agentcage ui)
37
40
  }