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.
- package/bin/agent-dag.js +7 -2
- package/dist/web/assets/{index-WLMEm0bm.css → index-BLOOP_Fy.css} +1 -1
- package/dist/web/assets/{index-D3CYh4Dp.js → index-DR2DFwYn.js} +15 -15
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/claude-accounts.mjs +22 -9
- package/src/server/cswap-auto.mjs +6 -5
- package/src/server/cswap-install.mjs +177 -22
- package/src/server/uv-bootstrap.mjs +186 -0
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
107
|
-
* the
|
|
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)
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
42
|
-
|
|
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
|
|
49
|
-
return { ok: false, reason: "install_failed", via
|
|
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
|
|
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(
|
|
98
|
-
const
|
|
99
|
-
|
|
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
|
|
105
|
-
if ((await run(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
|
|
131
|
-
if (
|
|
132
|
-
upgradeInBackground(
|
|
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 —
|
|
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
|
+
}
|