agent-dag 1.28.0 → 1.29.0

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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-D3CYh4Dp.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-WLMEm0bm.css">
8
+ <script type="module" crossorigin src="/assets/index-DR2DFwYn.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BLOOP_Fy.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  // and cannot lose a login. Switching shells out to `cswap` rather than
15
15
  // reimplementing the lock protocol its correctness depends on.
16
16
  import { readFile } from "node:fs/promises";
17
+ import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
17
18
  import { run, runDetached } from "./exec.mjs";
18
19
  import { join } from "node:path";
19
20
  import { homedir, platform } from "node:os";
@@ -68,7 +69,9 @@ function nudgeCollector(rows, now) {
68
69
  if (!due) return;
69
70
 
70
71
  _lastNudge = now;
71
- runDetached("cswap", ["list"]); // not installed — the panel already says so
72
+ // Fire-and-forget: this function is deliberately synchronous so callers never
73
+ // wait on it, and resolving the binary is the only async part.
74
+ cswapBin().then(bin => runDetached(bin, ["list"])).catch(() => {});
72
75
  }
73
76
 
74
77
  async function readJson(path) {
@@ -103,8 +106,11 @@ function lane(id, label, win) {
103
106
  /**
104
107
  * Every managed account with whatever usage claude-swap last saw for it.
105
108
  *
106
- * Returns { ok: false, reason: "no_store" } when claude-swap isn't installed —
107
- * the panel then explains itself rather than showing an empty list.
109
+ * When there is nothing to show, says which of the two reasons it is:
110
+ * "no_cswap" (the tool is not installed, and here is the command for this
111
+ * machine) or "no_accounts" (it is installed but nothing has been added yet).
112
+ * They need different things from the user, and reporting both as one empty
113
+ * panel leaves whichever one they are in with nowhere to go.
108
114
  */
109
115
  export async function fetchClaudeAccounts({ force = false } = {}) {
110
116
  const now = Date.now();
@@ -114,7 +120,12 @@ export async function fetchClaudeAccounts({ force = false } = {}) {
114
120
 
115
121
  const root = backupRoot();
116
122
  const seq = await readJson(join(root, "sequence.json"));
117
- if (!seq?.accounts) return finish({ ok: false, reason: "no_store", fetchedAt: now });
123
+ if (!seq?.accounts) {
124
+ const version = await cswapVersion();
125
+ return finish(version
126
+ ? { ok: false, reason: "no_accounts", version, fetchedAt: now }
127
+ : { ok: false, reason: "no_cswap", hint: await installHint(), fetchedAt: now });
128
+ }
118
129
 
119
130
  const usage = await readJson(join(root, "cache", "usage.json"));
120
131
  // A schema bump means the rows may not mean what this code thinks they do.
@@ -201,9 +212,11 @@ export function switchClaudeAccount(accountNum) {
201
212
 
202
213
  // Argument vector, never a shell — and resolved through exec.mjs so the
203
214
  // Windows `.exe`/`.cmd` shim is found too.
204
- return run("cswap", ["switch", String(num)], { timeout: 30_000 }).then(r => {
205
- if (r.ok) return { ok: true, output: r.stdout.trim() };
206
- const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
207
- return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
208
- });
215
+ return cswapBin()
216
+ .then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
217
+ .then(r => {
218
+ if (r.ok) return { ok: true, output: r.stdout.trim() };
219
+ const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
220
+ return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
221
+ });
209
222
  }
@@ -11,6 +11,7 @@
11
11
  // on, the setting survives restarts, and the UI can always ask what a tick
12
12
  // WOULD do (--dry-run) before committing to letting it happen.
13
13
  import { run } from "./exec.mjs";
14
+ import { cswapBin } from "./cswap-install.mjs";
14
15
  import { readFile, writeFile, mkdir } from "node:fs/promises";
15
16
  import { join } from "node:path";
16
17
  import { homedir } from "node:os";
@@ -37,7 +38,7 @@ const SETTINGS = {
37
38
 
38
39
  /** Parse `cswap config` — "key value (default)" per line. */
39
40
  export async function readCswapConfig() {
40
- const r = await run("cswap", ["config"]);
41
+ const r = await run(await cswapBin(), ["config"]);
41
42
  if (!r.ok) return null;
42
43
  const out = {};
43
44
  for (const line of r.stdout.split("\n")) {
@@ -71,7 +72,7 @@ export async function setCswapConfig(key, value) {
71
72
  if (str && !/^[A-Za-z0-9 ,._-]{1,120}$/.test(str)) return { ok: false, reason: "bad_value" };
72
73
  }
73
74
 
74
- const r = await run("cswap", ["config", "set", key, str]);
75
+ const r = await run(await cswapBin(), ["config", "set", key, str]);
75
76
  return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
76
77
  }
77
78
 
@@ -104,7 +105,7 @@ function summarise(stdout) {
104
105
  * never switches and never writes claude-swap's state.
105
106
  */
106
107
  export async function previewAutoSwitch() {
107
- const r = await run("cswap", ["auto", "--once", "--dry-run", "--json"], { timeout: TICK_TIMEOUT_MS });
108
+ const r = await run(await cswapBin(), ["auto", "--once", "--dry-run", "--json"], { timeout: TICK_TIMEOUT_MS });
108
109
  if (!r.ok && !r.stdout) {
109
110
  return { ok: false, reason: r.code === "ENOENT" ? "no_cswap" : "tick_failed",
110
111
  detail: (r.stderr || "").trim().slice(0, 300) };
@@ -114,7 +115,7 @@ export async function previewAutoSwitch() {
114
115
 
115
116
  /** Evaluate a tick for real. May switch the active account. */
116
117
  async function runAutoTick() {
117
- const r = await run("cswap", ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
118
+ const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
118
119
  if (!r.ok && !r.stdout) {
119
120
  return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
120
121
  }
@@ -233,6 +234,6 @@ export async function autoStatus() {
233
234
  export async function setAccountEnabled(accountNum, enabled) {
234
235
  const num = Number(accountNum);
235
236
  if (!Number.isInteger(num) || num < 1 || num > 999) return { ok: false, reason: "bad_account" };
236
- const r = await run("cswap", [enabled ? "enable" : "disable", String(num)]);
237
+ const r = await run(await cswapBin(), [enabled ? "enable" : "disable", String(num)]);
237
238
  return r.ok ? { ok: true } : { ok: false, reason: "command_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
238
239
  }
@@ -8,7 +8,8 @@
8
8
  // entirely. It is always best-effort — the deck's core function does not
9
9
  // depend on it, so a failure is reported and then ignored.
10
10
  import { run, runDetached } from "./exec.mjs";
11
- import { mkdirSync, statSync, writeFileSync } from "node:fs";
11
+ import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
12
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
12
13
  import { join } from "node:path";
13
14
  import { homedir } from "node:os";
14
15
 
@@ -19,9 +20,45 @@ const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
19
20
  const UPDATE_CHECK_MS = 24 * 3600_000;
20
21
  const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
21
22
 
22
- /** Installed version string, or null when cswap isn't on PATH. */
23
+ /**
24
+ * How to invoke cswap: the bare name when PATH resolves it, otherwise an
25
+ * absolute path to where its installers actually put it.
26
+ *
27
+ * ~/.local/bin is where both `uv tool install` and `pipx install` place
28
+ * executables, on every platform, and it is famously not on PATH — that is the
29
+ * whole reason `pipx ensurepath` exists. Installing claude-swap successfully
30
+ * and then reporting it as missing because the shell cannot see it is a bad
31
+ * enough outcome on its own; it is worse now that the deck may have done the
32
+ * installing. So PATH is a convenience here, not the source of truth.
33
+ *
34
+ * Re-resolved when a lookup fails so an install during this process is picked
35
+ * up without a restart.
36
+ */
37
+ let _bin = null;
38
+ export async function cswapBin() {
39
+ if (_bin) return _bin;
40
+ if ((await run("cswap", ["--version"], { timeout: 8_000 })).ok) return (_bin = "cswap");
41
+
42
+ const exe = process.platform === "win32" ? "cswap.exe" : "cswap";
43
+ const candidates = [
44
+ join(homedir(), ".local", "bin", exe),
45
+ // pipx before 1.5 on Windows, and any pip --user install.
46
+ process.platform === "win32"
47
+ ? join(homedir(), "AppData", "Roaming", "Python", "Scripts", exe)
48
+ : join(homedir(), ".pyenv", "shims", exe),
49
+ ];
50
+ for (const c of candidates) {
51
+ if (existsSync(c) && (await run(c, ["--version"], { timeout: 8_000 })).ok) return (_bin = c);
52
+ }
53
+ return "cswap"; // not found; leave the bare name so errors read sensibly
54
+ }
55
+
56
+ /** Forget the cached resolution — call after installing. */
57
+ export function resetCswapBin() { _bin = null; }
58
+
59
+ /** Installed version string, or null when cswap cannot be found. */
23
60
  export async function cswapVersion() {
24
- const r = await run("cswap", ["--version"]);
61
+ const r = await run(await cswapBin(), ["--version"]);
25
62
  if (!r.ok) return null;
26
63
  // "claude-swap 0.25.0" → "0.25.0"
27
64
  const m = (r.stdout || r.stderr).trim().match(/(\d+\.\d+\.\d+\S*)/);
@@ -37,18 +74,129 @@ export async function cswapVersion() {
37
74
  * Python environment where it can collide with their own dependencies, which
38
75
  * is not a thing to do to someone without asking.
39
76
  */
77
+ /**
78
+ * Python interpreters that are safe to execute on this machine.
79
+ *
80
+ * Both desktop platforms ship a fake python that does something other than run
81
+ * python when none is installed, and this code runs unprompted at startup for
82
+ * an optional panel — so neither may be executed on spec.
83
+ *
84
+ * macOS: /usr/bin/python3 is a shim that opens the "install developer tools?"
85
+ * dialog. `xcode-select -p` says whether the real thing is behind it, and is
86
+ * itself only a path lookup.
87
+ *
88
+ * Windows: `python` and `python3` are App Execution Aliases under
89
+ * WindowsApps — zero-length reparse points that open the Microsoft Store. They
90
+ * are on PATH whether or not Python exists, so presence proves nothing and
91
+ * running one opens the Store. `where` reports the paths without executing
92
+ * anything, so the aliases can be filtered out and the real interpreter (if
93
+ * any) called by absolute path. `py`, the Python launcher, only exists when
94
+ * Python was actually installed and is safe as-is.
95
+ *
96
+ * Returns absolute paths or bare command names, best first; empty when there
97
+ * is nothing safe to run.
98
+ */
99
+ let _pythons = null;
100
+ async function safePythons() {
101
+ if (_pythons != null) return _pythons;
102
+
103
+ if (process.platform === "darwin") {
104
+ _pythons = (await run("xcode-select", ["-p"], { timeout: 5_000 })).ok
105
+ ? ["python3", "python"]
106
+ : [];
107
+ return _pythons;
108
+ }
109
+
110
+ if (process.platform !== "win32") {
111
+ _pythons = ["python3", "python"];
112
+ return _pythons;
113
+ }
114
+
115
+ const found = [];
116
+ // The launcher first: it is never an alias.
117
+ if ((await run("py", ["-0"], { timeout: 8_000 })).ok) found.push("py");
118
+ for (const name of ["python", "python3"]) {
119
+ const r = await run("where", [name], { timeout: 8_000 });
120
+ if (!r.ok) continue;
121
+ for (const line of r.stdout.split(/\r?\n/)) {
122
+ const p = line.trim();
123
+ if (!p || /\\WindowsApps\\/i.test(p)) continue; // Store alias, not an interpreter
124
+ found.push(p);
125
+ break;
126
+ }
127
+ }
128
+ _pythons = found;
129
+ return _pythons;
130
+ }
131
+
132
+ /**
133
+ * Ways to install a Python application, best first.
134
+ *
135
+ * `python -m pipx` matters more than it looks: pipx is very often present as a
136
+ * module without a `pipx` on PATH — every Debian/Ubuntu `apt install pipx`, and
137
+ * any `pip install --user pipx` where ~/.local/bin was never added to PATH. The
138
+ * two-entry version of this list reported "needs uv or pipx" to people who had
139
+ * pipx installed, which is the kind of wrong answer that stops someone looking.
140
+ */
141
+ async function installers() {
142
+ const out = [
143
+ { cmd: "uv", probe: ["--version"], args: ["tool", "install", "claude-swap"], via: "uv" },
144
+ { cmd: "pipx", probe: ["--version"], args: ["install", "claude-swap"], via: "pipx" },
145
+ ];
146
+ // A uv fetched on an earlier run counts as installed tooling from here on.
147
+ const own = existingBootstrappedUv();
148
+ if (own) out.push({ cmd: own, probe: ["--version"], args: ["tool", "install", "claude-swap"], via: "uv (bundled)" });
149
+ for (const py of await safePythons()) {
150
+ out.push({
151
+ cmd: py,
152
+ probe: ["-m", "pipx", "--version"],
153
+ args: ["-m", "pipx", "install", "claude-swap"],
154
+ via: `${py} -m pipx`,
155
+ });
156
+ }
157
+ return out;
158
+ }
159
+
40
160
  async function installCswap() {
41
- for (const [cmd, args] of [
42
- ["uv", ["tool", "install", "claude-swap"]],
43
- ["pipx", ["install", "claude-swap"]],
44
- ]) {
45
- const probe = await run(cmd, ["--version"], { timeout: 5_000 });
46
- if (!probe.ok) continue;
161
+ for (const { cmd, probe, args, via } of await installers()) {
162
+ if (!(await run(cmd, probe, { timeout: 8_000 })).ok) continue;
47
163
  const r = await run(cmd, args, { timeout: INSTALL_TIMEOUT_MS });
48
- if (r.ok) return { ok: true, via: cmd };
49
- return { ok: false, reason: "install_failed", via: cmd, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
164
+ if (r.ok) return { ok: true, via };
165
+ return { ok: false, reason: "install_failed", via, detail: (r.stderr || r.stdout).trim().slice(0, 300) };
166
+ }
167
+
168
+ // Nothing on the machine can install a Python application. Rather than hand
169
+ // the user a command and stop, fetch uv itself — verified, and into the
170
+ // deck's own directory, see uv-bootstrap.mjs — and use that.
171
+ const boot = await bootstrapUv();
172
+ if (!boot.ok) return { ok: false, reason: "no_installer", bootstrap: boot.reason, hint: await installHint() };
173
+
174
+ const r = await run(boot.bin, ["tool", "install", "claude-swap"], { timeout: INSTALL_TIMEOUT_MS });
175
+ if (r.ok) return { ok: true, via: `uv ${boot.version} (fetched)` };
176
+ return { ok: false, reason: "install_failed", via: "uv (fetched)", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
177
+ }
178
+
179
+ /**
180
+ * What to actually type, for this machine.
181
+ *
182
+ * "needs uv or pipx" is a dead end for the person who has neither and no
183
+ * opinion about Python packaging — which is most people running a Node CLI.
184
+ * uv is a single self-contained binary and is what claude-swap documents, so
185
+ * that is what gets recommended; if the machine already has Python, pipx via
186
+ * pip is offered instead because it uses something already installed.
187
+ */
188
+ export async function installHint() {
189
+ const uv = process.platform === "win32"
190
+ ? 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex" (then: uv tool install claude-swap)'
191
+ : "curl -LsSf https://astral.sh/uv/install.sh | sh (then: uv tool install claude-swap)";
192
+ for (const py of await safePythons()) {
193
+ if ((await run(py, ["-c", "import sys"], { timeout: 5_000 })).ok) {
194
+ // Quoted: a resolved Windows path routinely contains spaces.
195
+ const q = /\s/.test(py) ? `"${py}"` : py;
196
+ return `${q} -m pip install --user pipx && ${q} -m pipx install claude-swap`;
197
+ }
50
198
  }
51
- return { ok: false, reason: "no_installer" };
199
+ return uv;
52
200
  }
53
201
 
54
202
  function updateCheckDue() {
@@ -94,15 +242,20 @@ function isOlder(a, b) {
94
242
  * take tens of seconds, which is not a thing to put in front of the server
95
243
  * starting. The running copy keeps working; the new one is there next launch.
96
244
  */
97
- function upgradeInBackground(via) {
98
- const args = via === "uv" ? ["tool", "upgrade", "claude-swap"] : ["upgrade", "claude-swap"];
99
- runDetached(via, args);
245
+ function upgradeInBackground(found) {
246
+ const { cmd, via } = found;
247
+ const args = via === "uv"
248
+ ? ["tool", "upgrade", "claude-swap"]
249
+ : via === "pipx"
250
+ ? ["upgrade", "claude-swap"]
251
+ : ["-m", "pipx", "upgrade", "claude-swap"]; // python -m pipx
252
+ runDetached(cmd, args);
100
253
  }
101
254
 
102
255
  /** Whichever Python tool installer is available, or null. */
103
256
  async function findInstaller() {
104
- for (const cmd of ["uv", "pipx"]) {
105
- if ((await run(cmd, ["--version"], { timeout: 5_000 })).ok) return cmd;
257
+ for (const { cmd, probe, via } of await installers()) {
258
+ if ((await run(cmd, probe, { timeout: 8_000 })).ok) return { cmd, via };
106
259
  }
107
260
  return null;
108
261
  }
@@ -127,10 +280,10 @@ export async function ensureCswap() {
127
280
  touchMarker();
128
281
  const latest = await latestOnPypi();
129
282
  if (latest && existing !== "installed" && isOlder(existing, latest)) {
130
- const via = await findInstaller();
131
- if (via) {
132
- upgradeInBackground(via);
133
- return { state: "upgrading", version: existing, latest, via };
283
+ const found = await findInstaller();
284
+ if (found) {
285
+ upgradeInBackground(found);
286
+ return { state: "upgrading", version: existing, latest, via: found.via };
134
287
  }
135
288
  }
136
289
  return { state: "present", version: existing };
@@ -138,9 +291,11 @@ export async function ensureCswap() {
138
291
 
139
292
  const result = await installCswap();
140
293
  if (!result.ok) return { state: "unavailable", ...result };
294
+ resetCswapBin(); // it exists now; the earlier "not on PATH" answer is stale
141
295
 
142
296
  // Freshly installed tools land in ~/.local/bin, which may not be on the PATH
143
- // of the shell that launched us — so confirm rather than assume.
297
+ // of the shell that launched us — cswapBin looks there directly, so this
298
+ // confirms the install rather than confirming the user's PATH.
144
299
  const version = await cswapVersion();
145
300
  return version
146
301
  ? { state: "installed", via: result.via, version }
@@ -0,0 +1,186 @@
1
+ // Last-resort acquisition of `uv`, so claude-swap can be installed on a machine
2
+ // with no Python tooling at all.
3
+ //
4
+ // The alternative everyone reaches for is `curl -LsSf https://astral.sh/uv/
5
+ // install.sh | sh`, which executes whatever that URL happens to serve, with the
6
+ // user's privileges, and drops files into their global tool path. This does the
7
+ // narrower thing instead: fetch one named release artifact from Astral's own
8
+ // GitHub releases, check it against the SHA-256 that release publishes beside
9
+ // it, and unpack it inside ~/.agents-deck. Nothing is executed until it has
10
+ // been verified, nothing lands outside a directory the deck already owns, and
11
+ // deleting that directory undoes all of it.
12
+ //
13
+ // It is still a network fetch of an executable, so it only happens when there
14
+ // is no other way: uv, pipx, and python -m pipx have all already been ruled out
15
+ // by the caller. AGENTS_DECK_NO_INSTALL=1 disables it along with everything else.
16
+ import { createHash } from "node:crypto";
17
+ import { mkdir, writeFile, rm, chmod, readdir } from "node:fs/promises";
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { homedir, tmpdir } from "node:os";
21
+ import { run } from "./exec.mjs";
22
+
23
+ const TOOL_DIR = join(homedir(), ".agents-deck", "tools");
24
+ const UV_DIR = join(TOOL_DIR, "uv");
25
+
26
+ const RELEASE_API = "https://api.github.com/repos/astral-sh/uv/releases/latest";
27
+ const DOWNLOAD_BASE = "https://github.com/astral-sh/uv/releases/download";
28
+
29
+ // Used when the releases API cannot be reached — rate limiting is per-IP and
30
+ // unauthenticated, so this is a normal outcome rather than an error. Any
31
+ // reasonably recent uv can install claude-swap; it self-updates later.
32
+ const FALLBACK_VERSION = "0.12.3";
33
+
34
+ const DOWNLOAD_TIMEOUT_MS = 120_000;
35
+
36
+ /**
37
+ * Release artifact for this machine, or null if uv does not publish one.
38
+ *
39
+ * Rosetta is deliberately not special-cased: an x64 Node on Apple Silicon
40
+ * reports x64 and gets the x64 build, which runs.
41
+ */
42
+ export function assetName(platform = process.platform, arch = process.arch) {
43
+ const a = { x64: "x86_64", arm64: "aarch64", ia32: "i686" }[arch];
44
+ if (!a) return null;
45
+ // Apple publishes no i686 build, and never will.
46
+ if (platform === "darwin") return a === "i686" ? null : `uv-${a}-apple-darwin.tar.gz`;
47
+ if (platform === "win32") return `uv-${a}-pc-windows-msvc.zip`;
48
+ if (platform === "linux") return `uv-${a}-unknown-linux-gnu.tar.gz`;
49
+ return null;
50
+ }
51
+
52
+ /** Path to a uv this function installed earlier, or null. */
53
+ export function existingBootstrappedUv() {
54
+ const bin = join(UV_DIR, process.platform === "win32" ? "uv.exe" : "uv");
55
+ return existsSync(bin) ? bin : null;
56
+ }
57
+
58
+ async function latestVersion() {
59
+ try {
60
+ const res = await fetch(RELEASE_API, {
61
+ headers: { accept: "application/vnd.github+json", "user-agent": "agents-deck" },
62
+ signal: AbortSignal.timeout(8_000),
63
+ });
64
+ if (!res.ok) return FALLBACK_VERSION;
65
+ const tag = (await res.json())?.tag_name;
66
+ return typeof tag === "string" && /^\d/.test(tag) ? tag : FALLBACK_VERSION;
67
+ } catch {
68
+ return FALLBACK_VERSION;
69
+ }
70
+ }
71
+
72
+ async function download(url, { asText = false } = {}) {
73
+ const res = await fetch(url, {
74
+ redirect: "follow",
75
+ headers: { "user-agent": "agents-deck" },
76
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
77
+ });
78
+ if (!res.ok) return null;
79
+ return asText ? (await res.text()) : Buffer.from(await res.arrayBuffer());
80
+ }
81
+
82
+ /**
83
+ * Unpack the artifact.
84
+ *
85
+ * `tar` is present on macOS, on Linux, and on Windows 10 1803 and later (as
86
+ * bsdtar), but Windows ships .zip and older Windows has no tar at all — so zip
87
+ * goes through PowerShell's Expand-Archive, which is always there.
88
+ */
89
+ async function extract(archivePath, destDir) {
90
+ if (archivePath.endsWith(".zip")) {
91
+ const r = await run("powershell.exe", [
92
+ "-NoProfile", "-NonInteractive", "-Command",
93
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destDir}' -Force`,
94
+ ], { timeout: 120_000 });
95
+ return r.ok;
96
+ }
97
+ const r = await run("tar", ["-xzf", archivePath, "-C", destDir], { timeout: 120_000 });
98
+ return r.ok;
99
+ }
100
+
101
+ /**
102
+ * Find the uv executable somewhere under `dir`.
103
+ *
104
+ * The archives put it in a versioned subdirectory whose name has changed
105
+ * between releases, so it is located rather than assumed.
106
+ */
107
+ async function findUv(dir, depth = 0) {
108
+ const wanted = process.platform === "win32" ? "uv.exe" : "uv";
109
+ let entries;
110
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return null; }
111
+ for (const e of entries) {
112
+ if (e.isFile() && e.name === wanted) return join(dir, e.name);
113
+ }
114
+ if (depth >= 2) return null;
115
+ for (const e of entries) {
116
+ if (!e.isDirectory()) continue;
117
+ const hit = await findUv(join(dir, e.name), depth + 1);
118
+ if (hit) return hit;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Download and verify uv into ~/.agents-deck/tools/uv.
125
+ *
126
+ * Returns { ok: true, bin, version } or { ok: false, reason }. Never throws:
127
+ * every caller treats a missing uv as an expected state.
128
+ */
129
+ export async function bootstrapUv() {
130
+ const already = existingBootstrappedUv();
131
+ if (already) return { ok: true, bin: already, version: "existing" };
132
+
133
+ // Downloading an executable is a bigger step than installing a package with a
134
+ // tool the user already chose, so it gets its own off switch as well as the
135
+ // blanket one — someone may want the managed installs and not this.
136
+ if (process.env.AGENTS_DECK_NO_DOWNLOAD === "1") return { ok: false, reason: "download_disabled" };
137
+
138
+ const asset = assetName();
139
+ if (!asset) return { ok: false, reason: "unsupported_platform" };
140
+
141
+ const version = await latestVersion();
142
+ const base = `${DOWNLOAD_BASE}/${version}/${asset}`;
143
+
144
+ const staging = join(tmpdir(), `agents-deck-uv-${process.pid}`);
145
+ try {
146
+ const [archive, sumText] = await Promise.all([
147
+ download(base),
148
+ download(`${base}.sha256`, { asText: true }),
149
+ ]);
150
+ if (!archive) return { ok: false, reason: "download_failed" };
151
+
152
+ // No published checksum means no way to know what was downloaded, and an
153
+ // unverified binary is not something to run on someone's machine.
154
+ const expected = sumText?.trim().split(/\s+/)[0]?.toLowerCase();
155
+ if (!expected || !/^[0-9a-f]{64}$/.test(expected)) {
156
+ return { ok: false, reason: "no_checksum" };
157
+ }
158
+ const actual = createHash("sha256").update(archive).digest("hex");
159
+ if (actual !== expected) return { ok: false, reason: "checksum_mismatch" };
160
+
161
+ await mkdir(staging, { recursive: true });
162
+ const archivePath = join(staging, asset);
163
+ await writeFile(archivePath, archive);
164
+ if (!(await extract(archivePath, staging))) return { ok: false, reason: "extract_failed" };
165
+
166
+ const found = await findUv(staging);
167
+ if (!found) return { ok: false, reason: "not_in_archive" };
168
+
169
+ await mkdir(UV_DIR, { recursive: true });
170
+ const dest = join(UV_DIR, process.platform === "win32" ? "uv.exe" : "uv");
171
+ // Copy rather than rename: the staging dir is in the system temp directory,
172
+ // which is often a different filesystem, and rename across one fails.
173
+ const { copyFile } = await import("node:fs/promises");
174
+ await copyFile(found, dest);
175
+ if (process.platform !== "win32") await chmod(dest, 0o755);
176
+
177
+ const check = await run(dest, ["--version"], { timeout: 15_000 });
178
+ if (!check.ok) return { ok: false, reason: "does_not_run" };
179
+
180
+ return { ok: true, bin: dest, version };
181
+ } catch (err) {
182
+ return { ok: false, reason: "error", detail: String(err?.message ?? err).slice(0, 200) };
183
+ } finally {
184
+ await rm(staging, { recursive: true, force: true }).catch(() => {});
185
+ }
186
+ }