agentcage 0.1.7 → 0.1.9

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/src/ui.html ADDED
@@ -0,0 +1,477 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>agentcage</title>
7
+ <link rel="icon" href="/icon.svg">
8
+ <style>
9
+ :root { --bg: #f6f7f6; --panel: #fff; --text: #1b1f1c; --muted: #66706a; --line: #dfe4e0; --brand: #14532d; --brand-text: #fff;
10
+ --ok: #15803d; --on: #22c55e; --off: #c5ccc7; --warn: #b45309; --bad: #b91c1c; --chip: #eef2ef; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; }
11
+ @media (prefers-color-scheme: dark) { :root { --bg: #121614; --panel: #1b201d; --text: #e5ebe7; --muted: #95a29a; --line: #2c342f;
12
+ --brand: #1f6f3e; --ok: #4ade80; --on: #6ee7a0; --off: #4a534e; --warn: #fbbf24; --bad: #f87171; --chip: #252c28; } }
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; background: var(--bg); color: var(--text); }
15
+ header { display: flex; align-items: center; gap: 12px; padding: 10px 20px; background: var(--brand); color: var(--brand-text); }
16
+ header img { width: 28px; height: 28px; border-radius: 6px; }
17
+ header b { font-size: 16px; margin-right: 16px; }
18
+ .tip { position: fixed; right: 20px; top: 62px; width: 350px; max-width: calc(100vw - 40px); background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 9px; padding: 12px 30px 12px 14px; font-size: 13px; line-height: 1.45; box-shadow: 0 6px 20px rgba(0,0,0,.25); z-index: 5; }
19
+ .tip .x { position: absolute; top: 4px; right: 4px; border: 0; background: none; padding: 2px 6px; color: var(--muted); }
20
+ nav button { background: none; border: 0; color: inherit; opacity: .75; padding: 6px 10px; border-radius: 6px; font: inherit; cursor: pointer; }
21
+ nav button.on, nav button:hover { opacity: 1; background: rgba(255,255,255,.14); }
22
+ main { max-width: 1320px; margin: 0 auto; padding: 20px; }
23
+ h2 { font-size: 18px; margin: 0 0 4px; } h3 { font-size: 15px; margin: 0 0 8px; }
24
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
25
+ .spread { justify-content: space-between; }
26
+ .muted { color: var(--muted); } .small { font-size: 12px; }
27
+ .banner { padding: 10px 14px; border-radius: 8px; margin: 12px 0; background: var(--chip); border-left: 4px solid var(--warn); }
28
+ .banner.bad { border-color: var(--bad); }
29
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; margin-top: 14px; }
30
+ #cards { grid-template-columns: repeat(auto-fill, minmax(min(100%, 620px), 1fr)); } /* wide enough for all buttons in one row */
31
+ .desc { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.45; }
32
+ .gear { font-size: 16px; line-height: 1; padding: 5px 9px; }
33
+ .card .actions { flex-wrap: nowrap; } dd.src { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; word-break: normal; }
34
+ .ok { color: var(--ok); font-weight: 600; } .steps { margin: 8px 0 0; padding-left: 20px; line-height: 1.6; } a { color: var(--ok); }
35
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 8px; }
36
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--off); flex: none; }
37
+ .dot.running { background: var(--on); } .dot.creating, .dot.busy { background: var(--warn); } .dot.missing { background: var(--bad); }
38
+ .name { font-weight: 600; font-size: 15px; word-break: break-all; }
39
+ .chip { background: var(--chip); border-radius: 999px; padding: 2px 8px; font-size: 12px; }
40
+ dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; margin: 0; font-size: 13px; } dt { color: var(--muted); } dd { margin: 0; word-break: break-all; }
41
+ button, .btn { font: inherit; border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 7px; padding: 6px 11px; cursor: pointer; }
42
+ button:hover { border-color: var(--muted); } button:disabled { opacity: .5; cursor: default; }
43
+ button.primary { background: var(--brand); border-color: var(--brand); color: var(--brand-text); }
44
+ button.danger { color: var(--bad); } button.warn { color: var(--warn); border-color: var(--warn); }
45
+ input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 7px; padding: 7px 9px; width: 100%; }
46
+ label { display: block; margin: 12px 0 4px; font-weight: 500; }
47
+ label.inline { display: flex; gap: 8px; align-items: flex-start; font-weight: normal; margin: 6px 0; } label.inline input { width: auto; margin-top: 3px; }
48
+ .err { color: var(--bad); font-size: 12px; min-height: 16px; margin-top: 3px; }
49
+ .busy { display: flex; gap: 8px; align-items: center; color: var(--warn); font-size: 13px; }
50
+ .spin { width: 13px; height: 13px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; flex: none; }
51
+ @keyframes spin { to { transform: rotate(360deg); } }
52
+ dialog { border: 1px solid var(--line); border-radius: 12px; padding: 0; width: min(640px, 94vw); background: var(--panel); color: var(--text); }
53
+ dialog::backdrop { background: rgba(0,0,0,.45); }
54
+ dialog form { padding: 20px; max-height: 88vh; overflow: auto; }
55
+ .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-top: 8px; }
56
+ .tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; }
57
+ .tabs button.on { border-bottom-color: var(--brand); font-weight: 600; }
58
+ .list { border: 1px solid var(--line); border-radius: 7px; max-height: 220px; overflow: auto; margin-top: 6px; }
59
+ .list div { padding: 6px 10px; cursor: pointer; display: flex; justify-content: space-between; gap: 8px; }
60
+ .list div:hover { background: var(--chip); } .list div.on { background: var(--brand); color: var(--brand-text); }
61
+ pre { white-space: pre-wrap; background: var(--chip); padding: 8px 10px; border-radius: 7px; font-size: 12px; margin: 6px 0 0; }
62
+ code { background: var(--chip); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
63
+ details { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; margin: 10px 0; }
64
+ summary { cursor: pointer; font-weight: 600; } details p, details li { line-height: 1.5; }
65
+ .hidden { display: none !important; }
66
+ .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: 9px 16px; border-radius: 8px; max-width: 90vw; }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div id="tip" class="tip hidden" role="dialog" aria-label="Desktop icon"><button type="button" class="x" aria-label="Close">×</button>
71
+ <b>Open agentcage from your desktop</b><div style="margin-top:4px">Add an agentcage icon to your <span class="sc-places"></span>, so you can open this window without a terminal.</div>
72
+ <div class="row" style="margin-top:10px"><button class="primary add">Add desktop icon</button><button class="never">Don't show this again</button></div></div>
73
+ <header>
74
+ <img src="/icon.svg" alt=""><b>agentcage</b>
75
+ <nav class="row">
76
+ <button data-view="containers" class="on">Containers</button>
77
+ <button data-view="connections">Connections</button>
78
+ <button data-view="settings">Settings</button>
79
+ <button data-view="help">Help</button>
80
+ </nav>
81
+ </header>
82
+
83
+ <main>
84
+ <section id="containers">
85
+ <div class="row spread">
86
+ <div><h2>Containers</h2><div id="desktop" class="muted small"></div></div>
87
+ <button class="primary" id="new" disabled>+ New container</button>
88
+ </div>
89
+ <div id="banners"></div>
90
+ <div id="cards" class="grid"><p class="muted">Loading containers…</p></div>
91
+ <p id="empty" class="muted hidden">No containers yet. Click <b>+ New container</b> to create one from a folder or a repository.</p>
92
+ </section>
93
+
94
+ <section id="connections" class="hidden">
95
+ <div class="row spread"><h2>Connections</h2><button id="conn-refresh">Check again</button></div>
96
+ <p class="muted">Connect GitHub and Azure Repos to pick repositories when you create a container. A logged-in <code>gh</code> or
97
+ <code>az</code> CLI is used automatically; otherwise paste a personal access token. Tokens are only used on this computer to list
98
+ and clone repositories. They never go into a container.</p>
99
+ <div class="grid">
100
+ <div class="card" id="conn-github">
101
+ <h3>GitHub</h3><div class="status muted">Checking…</div>
102
+ <details class="howto"><summary>How to connect</summary><ol class="steps">
103
+ <li>Install the GitHub CLI: <a href="https://cli.github.com/" target="_blank" rel="noopener">cli.github.com</a> (on Windows you can also run <code>winget install GitHub.cli</code>).</li>
104
+ <li>Open a new terminal and run <code>gh auth login</code>. It logs you in through the browser.</li>
105
+ <li>Click <b>Check again</b>. Still not connected? Close this window and open agentcage again, so it finds the newly installed CLI.</li></ol>
106
+ <p class="small muted">The CLI is the easiest way. Or paste a personal access token below.</p></details>
107
+ <div class="pat"><label>Personal access token</label><input type="password" autocomplete="off" placeholder="github_pat_…">
108
+ <div class="small muted">Fine-grained or classic, with read access to the repositories (Contents: Read). Or run <code>gh auth login</code>.</div>
109
+ <div class="row" style="margin-top:8px"><button class="save">Save token</button><button class="remove danger hidden">Remove saved token</button></div><div class="err"></div></div>
110
+ </div>
111
+ <div class="card" id="conn-azure">
112
+ <h3>Azure Repos</h3><div class="status muted">Checking…</div>
113
+ <details class="howto"><summary>How to connect</summary><ol class="steps">
114
+ <li>Install the Azure CLI: <a href="https://learn.microsoft.com/cli/azure/install-azure-cli" target="_blank" rel="noopener">learn.microsoft.com/cli/azure/install-azure-cli</a> (on Windows you can also run <code>winget install Microsoft.AzureCLI</code>).</li>
115
+ <li>Open a new terminal and run <code>az login</code>. It logs you in through the browser.</li>
116
+ <li>Click <b>Check again</b>. Still not connected? Close this window and open agentcage again, so it finds the newly installed CLI.</li></ol>
117
+ <p class="small muted">The CLI is the easiest way. Or paste a personal access token below.</p></details>
118
+ <div class="pat"><label>Personal access token</label><input type="password" autocomplete="off">
119
+ <label>Organization <span class="muted small">(needed if the token is for one organization)</span></label><input class="org" placeholder="myorg">
120
+ <div class="small muted">Scope: Code (Read), or Code (Read &amp; write) if you also use it as repo token. Or run <code>az login</code>.</div>
121
+ <div class="row" style="margin-top:8px"><button class="save">Save token</button><button class="remove danger hidden">Remove saved token</button></div><div class="err"></div></div>
122
+ </div>
123
+ </div>
124
+ </section>
125
+
126
+ <section id="settings" class="hidden">
127
+ <h2>Settings</h2>
128
+ <p class="muted">Apply to containers created afterwards. Stored in <code>~/.agentcage/settings.json</code>, shared with <code>agentcage settings</code>.</p>
129
+ <div class="card" style="max-width:560px"><div id="settings-form"></div>
130
+ <div class="row" style="margin-top:14px"><button class="primary" id="settings-save">Save</button><span id="settings-msg" class="muted small"></span></div></div>
131
+ <div class="card hidden" id="sc-card" style="max-width:560px;margin-top:14px"><h3>Desktop icon</h3>
132
+ <p class="muted" style="margin:0">An agentcage icon on your <span class="sc-places"></span> opens this window without a terminal. <span id="sc-status"></span></p>
133
+ <div class="row"><button class="primary" id="sc-add">Add desktop icon</button></div></div>
134
+ </section>
135
+
136
+ <section id="help" class="hidden">
137
+ <h2>How agentcage works</h2>
138
+ <details open><summary>What agentcage does</summary>
139
+ <p>Each project gets its own Linux container, so AI coding agents work in isolation. The project's code lives in a Docker
140
+ <b>volume</b>, not in a folder mounted from your computer, so agents in the container can't see your other files. The container
141
+ has no access to the Docker socket and is not privileged.</p>
142
+ <p>Each container can run an <b>OpenCode server</b>. The OpenCode Desktop app on your computer connects to it and is only the UI:
143
+ every command, file edit and tool call of the agent runs inside the container. Claude Code can be installed in the container too.</p></details>
144
+ <details><summary>Creating a container</summary>
145
+ <ul><li><b>Folder:</b> copied once into the container, including untracked files such as <code>.env.local</code>.
146
+ <code>node_modules</code>, <code>.venv</code> and <code>.next</code> are skipped; the container runs <code>npm ci</code> / <code>uv sync</code> instead.</li>
147
+ <li><b>Git URL, GitHub or Azure Repos:</b> cloned into the container. Private repositories are cloned with your connection (see Connections).</li>
148
+ <li>The first build downloads the base image and takes a few minutes. Later containers are faster.</li>
149
+ <li>The container's copy is separate from your computer's: changes leave the container with <b>git push</b>.</li></ul></details>
150
+ <details><summary>OpenCode Desktop</summary>
151
+ <ul><li>Each container is added to OpenCode Desktop as a server named like <code>ac-&lt;name&gt;</code>, with the project already open.
152
+ Start your sessions there. <b>Only sessions on that server are isolated</b>; the app's built-in Local server runs on your computer.</li>
153
+ <li>Desktop's server list can only be changed while the app is <b>closed</b>. If it was open, close it and click <b>Add to OpenCode</b> on the container.</li>
154
+ <li>The container runs the same OpenCode version as your Desktop app. When the app updates, click <b>Upgrade</b>: the container is
155
+ rebuilt and your code, logins and sessions are kept.</li></ul></details>
156
+ <details><summary>Git push access</summary>
157
+ <ul><li><b>Forwarded IDE credentials:</b> while Cursor or VS Code is attached to the container, your git credentials and SSH agent are
158
+ forwarded into it, so pushing just works. <b>Any agent in the container can then push to every repository you can access.</b>
159
+ Without an attached IDE the container can't push.</li>
160
+ <li><b>Repo token:</b> a token limited to this repository is stored in the container (GitHub fine-grained token, GitLab project token,
161
+ Bitbucket repository token). Agents can only push there. Azure DevOps tokens can't be limited to one repository: they reach every
162
+ repository in the organization.</li>
163
+ <li>The default is set in Settings (<i>Git token per project</i>).</li></ul></details>
164
+ <details><summary>Connections</summary>
165
+ <p>GitHub and Azure Repos connections are used to list your repositories and to clone private ones. A logged-in <code>gh</code> /
166
+ <code>az</code> CLI is reused and nothing is stored. A pasted token is stored in <code>~/.agentcage/connections.json</code>, readable
167
+ only by your user. Connection tokens are never put into a container, because they reach all your repositories.</p></details>
168
+ <details><summary>Model providers and Claude Code</summary>
169
+ <p>OpenCode needs an API key for each model provider. Click the gear button (⚙, OpenCode providers) on a container to add one; it is stored only in the
170
+ container's home volume. Claude Code logs in on first use: run <code>agentcage shell &lt;name&gt;</code>, then <code>claude</code>.</p></details>
171
+ <details><summary>IDEs</summary>
172
+ <p><b>Open in IDE</b> attaches Cursor, VS Code or Antigravity to the container in the project folder. Use the IDE for editing, diffs
173
+ and git. Its built-in AI agent runs outside the container's isolation, so run agents through OpenCode or Claude Code.</p></details>
174
+ <details><summary>Desktop icon</summary>
175
+ <ul><li>Go to <b>Settings</b> and click <b>Add desktop icon</b> at the bottom of the page. The icon is added to your desktop and to the
176
+ Start Menu (Windows) or app menu (Linux). New users are also offered it when the window opens.</li>
177
+ <li>The icon opens this window without a terminal. It reuses agentcage if it is already running.</li>
178
+ <li>In a terminal: <code>agentcage ui --shortcut</code> adds the icon, <code>agentcage ui --no-shortcut</code> removes it.</li>
179
+ <li>If you update or move Node.js, click <b>Add desktop icon</b> again so the icon points to the new location.</li>
180
+ <li>Not available on macOS yet: run <code>agentcage ui</code> there.</li></ul></details>
181
+ <details><summary>Command line and troubleshooting</summary>
182
+ <ul><li>Everything here also works in a terminal: <code>agentcage</code> (menu), <code>agentcage list</code>, <code>agentcage new</code>,
183
+ <code>agentcage shell &lt;name&gt;</code>, <code>agentcage help</code>.</li>
184
+ <li><code>agentcage doctor</code> checks Docker, Node and OpenCode Desktop.</li>
185
+ <li>If creating a container failed, fix the cause and click <b>Remove</b> on it to clean up.</li>
186
+ <li>This window talks to a small server on your computer only (127.0.0.1). It stops when you close the window.
187
+ Start it again from the agentcage icon or with <code>agentcage ui</code>.</li></ul></details>
188
+ </section>
189
+ </main>
190
+
191
+ <dialog id="dlg-new"><form method="dialog" id="new-form">
192
+ <div class="row spread"><h2>New container</h2><button type="button" class="close">✕</button></div>
193
+ <label>Project source</label>
194
+ <div class="tabs" id="src-tabs">
195
+ <button type="button" data-src="folder" class="on">Folder</button><button type="button" data-src="url">Git URL</button>
196
+ <button type="button" data-src="github">GitHub</button><button type="button" data-src="azure">Azure Repos</button>
197
+ </div>
198
+ <div data-pane="folder"><label>Folder on this computer</label><div class="row" style="flex-wrap:nowrap"><input id="f-folder" placeholder="C:\Users\me\code\my-project or /home/me/code/my-project"><button type="button" id="browse">Browse…</button></div></div>
199
+ <div data-pane="url" class="hidden"><label>Repository URL</label><input id="f-url" placeholder="https://github.com/owner/repo.git"></div>
200
+ <div data-pane="github" class="hidden"><div class="notconn muted hidden">Not connected. Connect GitHub under <b>Connections</b>.</div>
201
+ <div class="conn"><input class="filter" placeholder="Filter repositories…" style="margin-top:10px"><div class="list"></div></div></div>
202
+ <div data-pane="azure" class="hidden"><div class="notconn muted hidden">Not connected. Connect Azure Repos under <b>Connections</b>.</div>
203
+ <div class="conn"><label>Organization</label><select class="orgs"></select><input class="filter" placeholder="Filter repositories…" style="margin-top:8px"><div class="list"></div></div></div>
204
+ <div class="err" id="e-src"></div>
205
+
206
+ <label>Name</label><input id="f-name" autocomplete="off"><div class="err" id="e-name"></div>
207
+ <div id="prefix-note" class="small muted"></div>
208
+
209
+ <div id="push" class="hidden"><label>How should the container push to <span id="remote"></span>?</label>
210
+ <label class="inline"><input type="radio" name="git" value="forward"><span><b>My IDE's forwarded credentials</b><span class="desc">Agents can only push while your IDE is attached to the
211
+ container, but then they can push to all your repositories. Pushing and other source control are done from the attached IDE or
212
+ over SSH into the container.</span></span></label>
213
+ <label class="inline"><input type="radio" name="git" value="token"><span><b>A token for this repo only</b> <span class="chip">recommended</span><span class="desc">Agents can push to
214
+ this repository only, also when no IDE is attached. Paste a token limited to this repository below.</span></span></label>
215
+ <div id="token-box" class="hidden"><pre id="token-help"></pre><input type="password" id="f-token" placeholder="Paste the token" autocomplete="off" style="margin-top:6px"></div>
216
+ </div>
217
+ <div id="clone-note" class="small muted"></div>
218
+
219
+ <label>AI agents and tools</label><div id="tools"></div>
220
+ <label>Open in an IDE when ready</label><select id="f-ide"></select>
221
+ <div class="err" id="e-create"></div>
222
+ <div class="row" style="justify-content:flex-end;margin-top:8px"><button type="button" class="close">Cancel</button><button class="primary" id="create" value="default">Create container</button></div>
223
+ </form></dialog>
224
+
225
+ <dialog id="dlg-key"><form method="dialog">
226
+ <h2>OpenCode providers</h2>
227
+ <p class="muted small">Connect a model provider to OpenCode in this container by adding its API key. Stored only in the container's OpenCode (its home volume). <span id="key-for"></span></p>
228
+ <label>Provider</label><select id="k-provider"></select><div id="k-hint" class="small muted"></div>
229
+ <label>API key</label><input type="password" id="k-key" autocomplete="off"><div class="err" id="e-key"></div>
230
+ <div class="row" style="justify-content:flex-end;margin-top:8px"><button type="button" class="close">Cancel</button><button class="primary" id="k-save" value="default">Save key</button></div>
231
+ </form></dialog>
232
+
233
+ <script>
234
+ const TOKEN = location.hash.slice(1), $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
235
+ let META = {}, STATE = { containers: [] }, CONN = null, timer;
236
+
237
+ async function api(path, opt = {}) {
238
+ const r = await fetch(path, { method: opt.method ?? 'GET', headers: { 'x-agentcage-token': TOKEN, 'content-type': 'application/json' },
239
+ body: opt.body && JSON.stringify(opt.body) });
240
+ const j = await r.json().catch(() => ({}));
241
+ if (!r.ok) throw new Error(j.error ?? r.statusText);
242
+ return j;
243
+ }
244
+ // Element builder; text always goes through textContent
245
+ function h(tag, props = {}, ...kids) {
246
+ const e = document.createElement(tag);
247
+ for (const [k, v] of Object.entries(props)) if (k.startsWith('on')) e.addEventListener(k.slice(2), v); else if (v !== undefined && v !== false) e.setAttribute(k, v);
248
+ e.append(...kids.flat().filter(k => k !== null && k !== undefined && k !== false));
249
+ return e;
250
+ }
251
+ function toast(msg) { const t = h('div', { class: 'toast' }, msg); document.body.append(t); setTimeout(() => t.remove(), 4500); }
252
+ const run = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message); } };
253
+
254
+ // Views
255
+ $$('nav button').forEach(b => b.onclick = () => {
256
+ $$('nav button').forEach(x => x.classList.toggle('on', x === b));
257
+ $$('main section').forEach(s => s.classList.toggle('hidden', s.id !== b.dataset.view));
258
+ if (b.dataset.view === 'connections') loadConnections();
259
+ if (b.dataset.view === 'settings') renderSettings();
260
+ });
261
+
262
+ // Containers
263
+ async function refresh() {
264
+ clearTimeout(timer);
265
+ try { STATE = await api('/api/state'); render(); $('#new').disabled = false; } catch (e) { $('#banners').replaceChildren(h('div', { class: 'banner bad' }, `Lost the connection to agentcage: ${e.message}. Restart it with: agentcage ui`)); }
266
+ timer = setTimeout(refresh, STATE.containers?.some(c => c.busy && !c.busy.error) ? 1500 : 10000);
267
+ }
268
+ const act = (name, action, body) => run(async () => { await api(`/api/containers/${name}/${action}`, { method: 'POST', body }); await refresh(); })();
269
+
270
+ function render() {
271
+ const d = STATE.desktop;
272
+ $('#desktop').textContent = d.found ? `OpenCode Desktop ${d.version ?? ''}${d.running ? ' · open' : ' · closed'}` : 'OpenCode Desktop not found: add servers by hand (shown on each container)';
273
+ $('#banners').replaceChildren(...[!STATE.docker && h('div', { class: 'banner bad' }, 'Docker is not running. Start Docker Desktop (or the Docker service). This page updates by itself.')].filter(Boolean));
274
+ $('#empty').classList.toggle('hidden', STATE.containers.length > 0);
275
+ $('#cards').replaceChildren(...STATE.containers.map(card));
276
+ }
277
+
278
+ function card(c) {
279
+ const busy = c.busy && !c.busy.error, oc = c.tools.includes('opencode'), outdated = oc && STATE.desktop.version && c.opencodeVersion && c.opencodeVersion !== STATE.desktop.version;
280
+ const ide = STATE.ides[0];
281
+ const actions = busy || c.state === 'creating' ? [] : [
282
+ c.state === 'running' ? h('button', { onclick: () => act(c.name, 'stop') }, 'Stop') : c.state !== 'missing' && h('button', { onclick: () => act(c.name, 'start') }, 'Start'),
283
+ c.state !== 'missing' && (STATE.ides.length > 1
284
+ ? h('select', { style: 'width:auto', onchange: e => { if (e.target.value) act(c.name, 'open', { ide: e.target.value }); e.target.value = ''; } },
285
+ h('option', { value: '' }, 'Open in…'), STATE.ides.map(i => h('option', { value: i.value }, i.label)))
286
+ : h('button', { onclick: () => act(c.name, 'open', { ide: ide?.value }) }, `Open in ${ide?.label ?? 'IDE'}`)),
287
+ oc && c.registered === false && h('button', { onclick: () => act(c.name, 'connect') }, 'Add to OpenCode'),
288
+ oc && c.state !== 'missing' && h('button', { class: 'gear', title: 'OpenCode providers: add a model provider API key', 'aria-label': 'OpenCode providers', onclick: () => keyDialog(c) }, '⚙'),
289
+ oc && h('button', { class: outdated ? 'warn' : '', title: 'Rebuild with the Desktop app\'s OpenCode version and the current agentcage setup. Code, logins and sessions are kept.',
290
+ onclick: () => confirm(`Rebuild ${c.name}? Code, logins and sessions are kept; it takes a few minutes.`) && act(c.name, 'upgrade') }, outdated ? 'Upgrade OpenCode' : 'Rebuild'),
291
+ h('button', { class: 'danger', onclick: () => confirm(`Delete ${c.name} with its volumes? Unpushed work in the container is lost.`) && act(c.name, 'remove') }, 'Remove'),
292
+ ];
293
+ return h('div', { class: 'card' },
294
+ h('div', { class: 'row' }, h('span', { class: `dot ${busy ? 'busy' : c.state}` }), h('span', { class: 'name' }, c.name),
295
+ h('span', { class: 'chip' }, busy && c.state === 'missing' ? 'creating' : c.state === 'exited' ? 'stopped' : c.state)),
296
+ c.source && h('dl', {},
297
+ h('dt', {}, 'Source'), h('dd', { class: 'src', title: c.source }, c.source),
298
+ h('dt', {}, 'Tools'), h('dd', {}, c.tools.join(', ') || 'none'),
299
+ oc && [h('dt', {}, 'OpenCode'), h('dd', {}, `${c.server} · port ${c.port} · ${c.opencodeVersion ?? '?'}`, outdated ? ` (Desktop has ${STATE.desktop.version})` : '')],
300
+ oc && c.registered === false && !busy && [h('dt', {}, 'Desktop'), h('dd', {}, STATE.desktop.running ? 'Not added yet: close OpenCode Desktop, then click Add to OpenCode' : 'Not added yet')],
301
+ h('dt', {}, 'Git push'), h('dd', {}, { token: 'repo token', forward: 'forwarded IDE credentials', none: 'no remote' }[c.git] ?? c.git),
302
+ c.status && [h('dt', {}, 'Docker'), h('dd', {}, c.status)]),
303
+ busy && h('div', { class: 'busy' }, h('span', { class: 'spin' }), c.busy.label),
304
+ c.busy?.error && h('div', {}, h('div', { class: 'err' }, `${c.busy.label} failed: ${c.busy.error}`),
305
+ h('button', { class: 'small', onclick: () => act(c.name, 'dismiss') }, 'Dismiss')),
306
+ c.state === 'missing' && !busy && h('div', { class: 'small muted' }, 'The Docker container no longer exists. Remove cleans up its config and volumes.'),
307
+ actions.length > 0 && h('div', { class: 'row actions' }, actions));
308
+ }
309
+
310
+ // New container dialog
311
+ const dlg = $('#dlg-new'), F = { src: 'folder', picked: { github: null, azure: null }, nameEdited: false, repos: {} };
312
+ const source = () => ({ folder: $('#f-folder').value.trim(), url: $('#f-url').value.trim(), github: F.picked.github, azure: F.picked.azure })[F.src] ?? '';
313
+ $('#new').onclick = run(async () => {
314
+ F.nameEdited = false; $('#new-form').reset(); $('#e-create').textContent = '';
315
+ const s = STATE.settings;
316
+ $('#prefix-note').textContent = `Docker container ${s.containerPrefix}<name>, OpenCode server ${s.serverPrefix}<name>, project ${s.projectPrefix}<name> (change in Settings)`;
317
+ $('#tools').replaceChildren(...[...META.agents, ...META.devTools].map(t => h('label', { class: 'inline' },
318
+ h('input', { type: 'checkbox', value: t.id, checked: t.id === 'opencode' }), h('span', {}, t.label, ' ', h('span', { class: 'muted small' }, `— ${t.hint}`)))));
319
+ $('#f-ide').replaceChildren(h('option', { value: '' }, "Don't open"), ...STATE.ides.map(i => h('option', { value: i.value }, i.label)));
320
+ setSrc(F.src); checkSrc(); dlg.showModal();
321
+ });
322
+ $$('#src-tabs button').forEach(b => b.onclick = () => setSrc(b.dataset.src));
323
+ function setSrc(src) {
324
+ F.src = src;
325
+ $$('#src-tabs button').forEach(b => b.classList.toggle('on', b.dataset.src === src));
326
+ $$('[data-pane]').forEach(p => p.classList.toggle('hidden', p.dataset.pane !== src));
327
+ if (src === 'github' || src === 'azure') loadRepoPane(src);
328
+ checkSrc();
329
+ }
330
+ async function loadRepoPane(id) {
331
+ const pane = $(`[data-pane=${id}]`);
332
+ CONN ??= await api('/api/connections').catch(() => ({}));
333
+ const c = CONN[id];
334
+ pane.querySelector('.notconn').classList.toggle('hidden', !!c);
335
+ pane.querySelector('.conn').classList.toggle('hidden', !c);
336
+ if (!c) return;
337
+ if (id === 'azure') {
338
+ const sel = pane.querySelector('.orgs');
339
+ if (!sel.options.length) { sel.replaceChildren(...c.orgs.map(o => h('option', { value: o }, o))); sel.onchange = () => loadRepos('azure'); }
340
+ if (!c.orgs.length) return pane.querySelector('.list').replaceChildren(h('div', { class: 'muted' }, 'No organizations found. Add one under Connections.'));
341
+ }
342
+ pane.querySelector('.filter').oninput = () => showRepos(id);
343
+ if (!F.repos[id + (id === 'azure' ? pane.querySelector('.orgs').value : '')]) await loadRepos(id); else showRepos(id);
344
+ }
345
+ async function loadRepos(id) {
346
+ const pane = $(`[data-pane=${id}]`), org = id === 'azure' ? pane.querySelector('.orgs').value : '';
347
+ pane.querySelector('.list').replaceChildren(h('div', { class: 'muted' }, 'Loading repositories…'));
348
+ try { F.repos[id + org] = await api(`/api/repos/${id}${org ? `?org=${encodeURIComponent(org)}` : ''}`); showRepos(id); }
349
+ catch (e) { pane.querySelector('.list').replaceChildren(h('div', { class: 'err' }, e.message)); }
350
+ }
351
+ function showRepos(id) {
352
+ const pane = $(`[data-pane=${id}]`), org = id === 'azure' ? pane.querySelector('.orgs').value : '', q = pane.querySelector('.filter').value.toLowerCase();
353
+ const list = (F.repos[id + org] ?? []).filter(r => r.name.toLowerCase().includes(q)).slice(0, 200);
354
+ pane.querySelector('.list').replaceChildren(...list.map(r => h('div', { class: F.picked[id] === r.url ? 'on' : '', onclick: () => { F.picked[id] = r.url; showRepos(id); checkSrc(); } },
355
+ h('span', {}, r.name), r.private && h('span', { class: 'small' }, 'private'))), ...(list.length ? [] : [h('div', { class: 'muted' }, 'No repositories')]));
356
+ }
357
+ let checkTimer;
358
+ const checkSrc = () => { clearTimeout(checkTimer); checkTimer = setTimeout(run(doCheck), 300); };
359
+ $('#f-folder').oninput = $('#f-url').oninput = checkSrc;
360
+ // Opens the system folder picker (shown by the agentcage process on this computer)
361
+ $('#browse').onclick = run(async () => {
362
+ $('#browse').disabled = true;
363
+ try { const { path } = await api('/api/browse', { method: 'POST', body: { start: $('#f-folder').value } }); if (path) { $('#f-folder').value = path; checkSrc(); } }
364
+ finally { $('#browse').disabled = false; }
365
+ });
366
+ $('#f-name').oninput = () => { F.nameEdited = true; checkSrc(); };
367
+ async function doCheck() {
368
+ const src = source(), name = F.nameEdited ? $('#f-name').value.trim() : '';
369
+ if (!src) { if (!F.nameEdited) $('#f-name').value = ''; $('#e-src').textContent = ''; $('#push').classList.add('hidden'); $('#clone-note').textContent = ''; return; }
370
+ const c = await api(`/api/check?src=${encodeURIComponent(src)}&name=${encodeURIComponent(name)}`);
371
+ if (src !== source()) return; // changed meanwhile
372
+ $('#e-src').textContent = c.srcError ?? '';
373
+ if (!F.nameEdited) $('#f-name').value = c.suggested;
374
+ $('#e-name').textContent = c.nameError ?? '';
375
+ $('#push').classList.toggle('hidden', !c.remote);
376
+ $('#remote').textContent = c.remote ?? '';
377
+ $('#token-help').textContent = c.help ?? '';
378
+ $('#clone-note').textContent = c.cloneWith ? 'Cloned with your connection; that token is not put into the container.' : '';
379
+ if (c.remote && !$$('[name=git]').some(r => r.checked)) {
380
+ const def = { never: 'forward', always: 'token' }[STATE.settings.gitToken];
381
+ if (def) $(`[name=git][value=${def}]`).checked = true;
382
+ }
383
+ $('#token-box').classList.toggle('hidden', $('[name=git][value=token]').checked !== true);
384
+ }
385
+ $$('[name=git]').forEach(r => r.onchange = () => $('#token-box').classList.toggle('hidden', !$('[name=git][value=token]').checked));
386
+ $('#create').onclick = run(async e => {
387
+ e.preventDefault();
388
+ const git = $$('[name=git]').find(r => r.checked)?.value;
389
+ if (!$('#push').classList.contains('hidden') && !git) return ($('#e-create').textContent = 'Choose how the container pushes to git.');
390
+ try {
391
+ await api('/api/containers', { method: 'POST', body: { src: source(), name: $('#f-name').value.trim(), git, token: $('#f-token').value,
392
+ tools: $$('#tools input:checked').map(i => i.value), ide: $('#f-ide').value } });
393
+ } catch (err) { return ($('#e-create').textContent = err.message); }
394
+ dlg.close(); toast('Creating the container. The first build takes a few minutes.'); await refresh();
395
+ });
396
+
397
+ $$("dialog .close").forEach(b => b.onclick = () => b.closest("dialog").close());
398
+
399
+ // API key dialog
400
+ const kd = $('#dlg-key');
401
+ function keyDialog(c) {
402
+ kd.dataset.name = c.name; $('#key-for').textContent = `Container: ${c.name}.`; $('#k-key').value = ''; $('#e-key').textContent = '';
403
+ $('#k-provider').replaceChildren(...META.providers.map(p => h('option', { value: p.id }, p.label)));
404
+ $('#k-provider').onchange = () => ($('#k-hint').textContent = `Get a key at ${META.providers.find(p => p.id === $('#k-provider').value).hint}`);
405
+ $('#k-provider').onchange(); kd.showModal();
406
+ }
407
+ $('#k-save').onclick = async e => {
408
+ e.preventDefault();
409
+ try { await api(`/api/containers/${kd.dataset.name}/provider`, { method: 'POST', body: { id: $('#k-provider').value, key: $('#k-key').value.trim() } }); }
410
+ catch (err) { return ($('#e-key').textContent = err.message); }
411
+ kd.close(); toast('API key saved in the container. Pick the provider\'s models in OpenCode.');
412
+ };
413
+
414
+ // Connections
415
+ async function loadConnections() {
416
+ CONN = await api('/api/connections').catch(e => (toast(e.message), {}));
417
+ for (const id of ['github', 'azure']) {
418
+ const card = $(`#conn-${id}`), c = CONN[id], via = { gh: 'the gh CLI', az: 'the az CLI', pat: 'a saved token' }[c?.source];
419
+ card.querySelector('.status').replaceChildren(...(c ? [h('span', { class: 'ok' }, '✓ Connected'), `${c.user ? ` as ${c.user}` : ''} through ${via}${id === 'azure' && c.orgs?.length ? ` · organizations: ${c.orgs.join(', ')}` : ''}`] : ['Not connected']));
420
+ card.querySelector('.howto').open = !c;
421
+ card.querySelector('.remove').classList.toggle('hidden', c?.source !== 'pat');
422
+ card.querySelector('.save').onclick = async () => {
423
+ const err = card.querySelector('.err'); err.textContent = '';
424
+ try { await api(`/api/connections/${id}`, { method: 'PUT', body: { token: card.querySelector('.pat input').value, org: card.querySelector('.org')?.value } }); }
425
+ catch (e) { return (err.textContent = e.message); }
426
+ card.querySelector('.pat input').value = ''; F.repos = {}; loadConnections();
427
+ };
428
+ card.querySelector('.remove').onclick = run(async () => { await api(`/api/connections/${id}`, { method: 'DELETE' }); F.repos = {}; loadConnections(); });
429
+ }
430
+ }
431
+
432
+ $('#conn-refresh').onclick = () => { F.repos = {}; loadConnections(); };
433
+
434
+ // Settings
435
+ function renderSettings() {
436
+ const s = STATE.settings;
437
+ const field = (k, c = META.choices[k]) => k === 'projectIcon' // radios: the color picker only shows for a plain-color icon
438
+ ? Object.entries(c).map(([v, l]) => h('label', { class: 'inline' }, h('input', { type: 'radio', name: k, value: v, checked: s[k] === v, onchange: showColor }), l))
439
+ : c ? h('select', { name: k }, Object.entries(c).map(([v, l]) => h('option', { value: v, selected: s[k] === v }, l)))
440
+ : h('input', { name: k, value: s[k], placeholder: 'none' });
441
+ $('#settings-form').replaceChildren(...META.keys.map(k => h('div', { 'data-key': k }, h('label', {}, META.labels[k]), field(k))));
442
+ showColor();
443
+ $('#settings-msg').textContent = '';
444
+ }
445
+ function showColor() {
446
+ $('#settings-form [data-key=color]').classList.toggle('hidden', !$('#settings-form [name=projectIcon][value=color]').checked);
447
+ }
448
+ $('#settings-save').onclick = run(async () => {
449
+ await api('/api/settings', { method: 'PUT', body: Object.fromEntries($$('#settings-form [name]').filter(i => i.type !== 'radio' || i.checked).map(i => [i.name, i.value.trim()])) });
450
+ STATE.settings = (await api('/api/state')).settings; $('#settings-msg').textContent = 'Saved.';
451
+ });
452
+
453
+ // Desktop icon: a button at the bottom of Settings, and a popover when the window opens while there is no desktop icon
454
+ // (until the user adds one or picks "Don't show this again")
455
+ async function shortcuts() {
456
+ const sc = await api('/api/shortcut').catch(() => ({})), tip = $('#tip');
457
+ if (!sc.supported) return;
458
+ $$('.sc-places').forEach(e => (e.textContent = META.platform === 'win32' ? 'desktop and Start Menu' : 'desktop and app menu'));
459
+ const added = exists => { $('#sc-status').textContent = exists ? 'You already have one; adding it again recreates it.' : ''; $('#sc-add').textContent = exists ? 'Recreate desktop icon' : 'Add desktop icon'; };
460
+ added(sc.exists); $('#sc-card').classList.remove('hidden');
461
+ const add = run(async () => { tip.classList.add('hidden'); await api('/api/shortcut', { method: 'POST' }); added(true); toast('Added the agentcage icon.'); });
462
+ $('#sc-add').onclick = tip.querySelector('.add').onclick = add;
463
+ tip.querySelector('.x').onclick = () => tip.classList.add('hidden');
464
+ tip.querySelector('.never').onclick = run(async () => { tip.classList.add('hidden'); await api('/api/shortcut/hide-tip', { method: 'POST' }); });
465
+ tip.classList.toggle('hidden', sc.exists || sc.hideTip);
466
+ }
467
+ // Tell the server this window is open (it stops a few seconds after the window closes); reconnect if it drops
468
+ async function alive() { try { const r = await fetch('/api/alive', { headers: { 'x-agentcage-token': TOKEN } }); await r.text(); } catch {} setTimeout(alive, 2000); }
469
+
470
+ (async () => {
471
+ if (!TOKEN) return $('#banners').replaceChildren(h('div', { class: 'banner bad' }, 'Open agentcage with the agentcage icon or by running: agentcage ui'));
472
+ try { META = await api('/api/meta'); } catch (e) { return $('#banners').replaceChildren(h('div', { class: 'banner bad' }, `${e.message}`)); }
473
+ alive(); refresh(); shortcuts();
474
+ })();
475
+ </script>
476
+ </body>
477
+ </html>
package/src/wizard.js CHANGED
@@ -1,12 +1,11 @@
1
1
  // Interactive dialogs (@clack/prompts) on top of the container functions. Flags pre-fill answers; --yes takes defaults.
2
2
  import * as p from '@clack/prompts';
3
3
  import { existsSync } from 'node:fs';
4
- import { join } from 'node:path';
5
4
  import { AGENTS, DEV_TOOLS, PROVIDERS, hostInfo } from './catalog.js';
6
- import { cleanName, createContainer, listContainers, nameTaken, openIde, publishedPorts, removeContainer, state, ws } from './container.js';
5
+ import { cleanName, createContainer, findIdes, listContainers, nameError, openIde, publishedPorts, removeContainer, state, ws } from './container.js';
7
6
  import { isGitUrl, localRemote, parseRemote } from './git.js';
8
7
  import { connectDesktop, desktopRunning, desktopServers, detectDesktop, latestVersion, loginProvider, removeDesktopServers, serverName, setupProject } from './opencode.js';
9
- import { ok, runSync } from './run.js';
8
+ import { ok } from './run.js';
10
9
  import { CHOICES, DEFAULTS, LABELS, loadSettings, saveSettings } from './settings.js';
11
10
 
12
11
  export const ask = async q => {
@@ -28,14 +27,6 @@ and SSH agent are forwarded into the container. Any agent running in the contain
28
27
  that time can use them to push to EVERY repository you have access to, not only
29
28
  this one. When no IDE is attached, the container cannot push at all.`;
30
29
 
31
- // IDEs that can attach to a container, found on PATH or in their usual install folder
32
- function findIdes() {
33
- const found = cmd => !!runSync(process.platform === 'win32' ? 'where' : 'which', [cmd]);
34
- const ag = process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Antigravity IDE', 'bin', 'antigravity-ide.cmd') : 'antigravity-ide';
35
- return [['cursor', 'Cursor'], ['code', 'VS Code'], [ag, 'Antigravity']].filter(([c]) => existsSync(c) || found(c))
36
- .map(([value, label]) => ({ value, label }));
37
- }
38
-
39
30
  async function waitForDocker() {
40
31
  while (!ok('docker', ['info'])) {
41
32
  p.log.warn('Docker is not running (or not installed). Start Docker Desktop / the Docker service.');
@@ -104,9 +95,8 @@ export async function newWizard(f = {}) {
104
95
 
105
96
  const s = loadSettings(), px = v => (v ? `"${v}"` : 'none');
106
97
  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`);
107
- 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);
108
- const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: 'Container name', initialValue: cleanName(src), validate: nameOk })));
109
- if (nameOk(name)) throw new Error(nameOk(name));
98
+ const name = await pick(f.name, cleanName(src), () => ask(p.text({ message: 'Container name', initialValue: cleanName(src), validate: nameError })));
99
+ if (nameError(name)) throw new Error(nameError(name));
110
100
 
111
101
  // Git push access
112
102
  const remoteUrl = isGitUrl(src) ? src : localRemote(src), remote = parseRemote(remoteUrl);