agent-dag 1.32.2 → 1.33.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/README.md +21 -0
- package/dist/web/assets/index-Disgmxp1.js +66 -0
- package/dist/web/assets/{index-CAnz1LW4.css → index-hRjhJVfb.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/cswap-admin.mjs +502 -0
- package/src/server/exec.mjs +103 -0
- package/src/server/index.mjs +44 -0
- package/dist/web/assets/index-C0PC3b6r.js +0 -66
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Everything the accounts panel does that CHANGES the claude-swap store.
|
|
2
|
+
//
|
|
3
|
+
// Reading it lives in claude-accounts.mjs. This is the other half: signing a
|
|
4
|
+
// new account in, sharing one to another machine, and the small edits —
|
|
5
|
+
// rename, reorder, remove.
|
|
6
|
+
//
|
|
7
|
+
// Two constraints from claude-swap's own source shape all of it.
|
|
8
|
+
//
|
|
9
|
+
// `cswap add` does not sign anyone in. It captures whatever is already live —
|
|
10
|
+
// identity from ~/.claude.json, credentials from the macOS Keychain item
|
|
11
|
+
// "Claude Code-credentials". So a browser login has to land in Claude Code's
|
|
12
|
+
// own store first, which is exactly what `claude auth login` does, and the
|
|
13
|
+
// panel's job is to drive that conversation rather than to invent one.
|
|
14
|
+
//
|
|
15
|
+
// And `cswap add` takes no lock while assigning the next slot as max+1. Two
|
|
16
|
+
// concurrent adds pick the same number and the second write silently drops the
|
|
17
|
+
// first account's record. Nothing upstream prevents it, so every mutation here
|
|
18
|
+
// goes through one mutex.
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { run, runDetached, runInteractive } from "./exec.mjs";
|
|
23
|
+
import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
24
|
+
|
|
25
|
+
// An OAuth code is short-lived at the source; there is no point holding a child
|
|
26
|
+
// open longer than a user would plausibly take to fetch one.
|
|
27
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
28
|
+
const CSWAP_TIMEOUT_MS = 60_000;
|
|
29
|
+
// How long to wait for the CLI's verdict on a pasted code before saying so.
|
|
30
|
+
// Exchanging a code is one HTTPS round trip; a minute is generous.
|
|
31
|
+
const CODE_VERDICT_MS = 60_000;
|
|
32
|
+
// How long a shared account stays importable. Long enough to walk to the other
|
|
33
|
+
// machine, short enough that a copy left in clipboard history goes stale.
|
|
34
|
+
export const SHARE_TTL_MS = 10 * 60_000;
|
|
35
|
+
const SHARE_PREFIX = "ccdeck1:";
|
|
36
|
+
|
|
37
|
+
function backupRoot() {
|
|
38
|
+
if (process.env.CLAUDE_SWAP_BACKUP) return process.env.CLAUDE_SWAP_BACKUP;
|
|
39
|
+
if (process.platform === "linux") {
|
|
40
|
+
return process.env.XDG_DATA_HOME
|
|
41
|
+
? join(process.env.XDG_DATA_HOME, "claude-swap")
|
|
42
|
+
: join(homedir(), ".local", "share", "claude-swap");
|
|
43
|
+
}
|
|
44
|
+
return join(homedir(), ".claude-swap-backup");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── serialization ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
let _chain = Promise.resolve();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One store mutation at a time.
|
|
53
|
+
*
|
|
54
|
+
* Not defence against another process — that would need claude-swap's own file
|
|
55
|
+
* lock, which `add` does not take either. This is defence against ourselves:
|
|
56
|
+
* two browser tabs, or a double-click, are enough to race a slot assignment.
|
|
57
|
+
*/
|
|
58
|
+
export function withStoreLock(fn) {
|
|
59
|
+
const next = _chain.then(fn, fn);
|
|
60
|
+
// Keep the chain alive even when a link rejects, or every later mutation
|
|
61
|
+
// inherits the failure.
|
|
62
|
+
_chain = next.then(() => {}, () => {});
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function cswapBin() {
|
|
69
|
+
return process.env.AGENTS_DECK_CSWAP ?? "cswap";
|
|
70
|
+
}
|
|
71
|
+
async function claudeBin() {
|
|
72
|
+
return process.env.AGENTS_DECK_CLAUDE ?? "claude";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Slot → email for everything currently in the store, plus the active slot. */
|
|
76
|
+
export async function readStore() {
|
|
77
|
+
try {
|
|
78
|
+
const seq = JSON.parse(await readFile(join(backupRoot(), "sequence.json"), "utf8"));
|
|
79
|
+
const accounts = seq?.accounts ?? {};
|
|
80
|
+
return {
|
|
81
|
+
slots: Object.keys(accounts),
|
|
82
|
+
emails: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.email ?? ""])),
|
|
83
|
+
activeNum: seq?.activeAccountNumber ?? null,
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return { slots: [], emails: {}, activeNum: null };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which slot appeared between two store reads.
|
|
92
|
+
*
|
|
93
|
+
* `cswap add --json` is rejected by argparse (exit 2), so its human output is
|
|
94
|
+
* the only thing it offers and parsing that would break on any wording change.
|
|
95
|
+
* The store is the fact. `null` means nothing new — which is not a failure: an
|
|
96
|
+
* account already present is refreshed in place, on its existing slot.
|
|
97
|
+
*/
|
|
98
|
+
export function newSlot(before, after) {
|
|
99
|
+
const had = new Set(before.slots);
|
|
100
|
+
const fresh = after.slots.filter(s => !had.has(s));
|
|
101
|
+
return fresh.length === 1 ? fresh[0] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Anthropic's own view of who is signed in. Null when it cannot be read. */
|
|
105
|
+
export async function currentIdentity() {
|
|
106
|
+
const r = await run(await claudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
|
|
107
|
+
if (!r.ok) return null;
|
|
108
|
+
try {
|
|
109
|
+
const j = JSON.parse(r.stdout);
|
|
110
|
+
return j?.loggedIn ? { email: j.email ?? "", orgId: j.orgId ?? "" } : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── login ────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
// `claude auth login` prints the link as an OSC-8 hyperlink: ESC ] 8 ; ; <url>
|
|
119
|
+
// BEL, then the visible text — which is the same url again — then an empty
|
|
120
|
+
// closer. Verified byte for byte against real output: two `\x1b]8;;`, both
|
|
121
|
+
// terminated by BEL, never the ESC-backslash form.
|
|
122
|
+
//
|
|
123
|
+
// So the url appears TWICE, back to back, and a naive /https:\/\/\S+/ captures
|
|
124
|
+
// them joined into one unusable string. Stripping the escape sequences takes
|
|
125
|
+
// the link target away with them and leaves the visible copy, once.
|
|
126
|
+
const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
127
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
128
|
+
|
|
129
|
+
export function stripTerminalEscapes(text) {
|
|
130
|
+
return String(text ?? "").replace(OSC8, "").replace(ANSI, "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The sign-in URL out of `claude auth login`'s output, or null. */
|
|
134
|
+
export function extractLoginUrl(text) {
|
|
135
|
+
const clean = stripTerminalEscapes(text);
|
|
136
|
+
const m = clean.match(/https:\/\/[^\s'"]*\/oauth\/authorize\?[^\s'"]+/);
|
|
137
|
+
return m ? m[0] : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The prompt the CLI blocks on. Matched rather than assumed: writing a code
|
|
141
|
+
// into a child that is not asking for one would send it somewhere unknown.
|
|
142
|
+
const CODE_PROMPT = /paste code here/i;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The whole login, as one object, because the browser talks to it twice: once
|
|
146
|
+
* to start and get a URL, once to hand back the code.
|
|
147
|
+
*
|
|
148
|
+
* `previousActive` is captured before anything happens. `cswap add` sets
|
|
149
|
+
* activeAccountNumber to whatever it just added, so without this the machine
|
|
150
|
+
* silently changes account underneath every running session.
|
|
151
|
+
*/
|
|
152
|
+
let _login = null;
|
|
153
|
+
|
|
154
|
+
export function loginState() {
|
|
155
|
+
if (!_login) return { state: "idle" };
|
|
156
|
+
const { state, url, error, account, expiresAt } = _login;
|
|
157
|
+
return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function startLogin({ email } = {}) {
|
|
161
|
+
// Registering is the half that writes to the store; interrupting it would
|
|
162
|
+
// leave an account half-recorded, so that one is refused. A flow merely
|
|
163
|
+
// waiting for a code is not precious — it is most often the one abandoned by
|
|
164
|
+
// the page reload that just happened — and it yields to the new request
|
|
165
|
+
// rather than blocking it for the rest of its five minutes.
|
|
166
|
+
if (_login?.state === "registering") {
|
|
167
|
+
return { ok: false, reason: "already_running", ...loginState() };
|
|
168
|
+
}
|
|
169
|
+
if (_login?.state === "awaiting_url" || _login?.state === "awaiting_code") {
|
|
170
|
+
await cancelLogin();
|
|
171
|
+
}
|
|
172
|
+
const before = await readStore();
|
|
173
|
+
const identity = await currentIdentity();
|
|
174
|
+
|
|
175
|
+
const args = ["auth", "login"];
|
|
176
|
+
if (typeof email === "string" && email.includes("@")) args.push("--email", email);
|
|
177
|
+
|
|
178
|
+
const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
|
|
179
|
+
// A sign-in outlives the request that started it, so it can also outlive the
|
|
180
|
+
// deck. Nothing else would ever reap it: it is waiting on a stdin that no
|
|
181
|
+
// longer has a writer, and it holds the user's next attempt hostage for five
|
|
182
|
+
// minutes. Killed on the way out, and unregistered as soon as it settles so
|
|
183
|
+
// the handler list cannot grow.
|
|
184
|
+
const onExit = () => { try { child.kill(); } catch { /* already gone */ } };
|
|
185
|
+
process.on("exit", onExit);
|
|
186
|
+
child.done.then(() => process.off("exit", onExit), () => process.off("exit", onExit));
|
|
187
|
+
|
|
188
|
+
_login = {
|
|
189
|
+
state: "awaiting_url",
|
|
190
|
+
child,
|
|
191
|
+
previousActive: before.activeNum,
|
|
192
|
+
previousEmail: identity?.email ?? null,
|
|
193
|
+
before,
|
|
194
|
+
url: null,
|
|
195
|
+
error: null,
|
|
196
|
+
account: null,
|
|
197
|
+
expiresAt: Date.now() + LOGIN_TIMEOUT_MS,
|
|
198
|
+
// How many times the CLI has asked for a code. A second ask after we
|
|
199
|
+
// answered is how a rejected code announces itself — the process does not
|
|
200
|
+
// exit, it just asks again, so waiting for exit would hang for the whole
|
|
201
|
+
// five-minute window on a typo.
|
|
202
|
+
prompts: 0,
|
|
203
|
+
lastPromptText: "",
|
|
204
|
+
};
|
|
205
|
+
const flow = _login;
|
|
206
|
+
|
|
207
|
+
child.onLine((line) => {
|
|
208
|
+
if (!flow.url) {
|
|
209
|
+
const url = extractLoginUrl(line);
|
|
210
|
+
if (url) { flow.url = url; flow.state = "awaiting_code"; }
|
|
211
|
+
}
|
|
212
|
+
// The prompt is an unterminated line, re-delivered as it grows, so the
|
|
213
|
+
// same ask must not count twice.
|
|
214
|
+
const clean = stripTerminalEscapes(line);
|
|
215
|
+
if (CODE_PROMPT.test(clean)) {
|
|
216
|
+
if (clean !== flow.lastPromptText) { flow.prompts += 1; flow.lastPromptText = clean; }
|
|
217
|
+
} else if (clean.trim()) {
|
|
218
|
+
flow.lastPromptText = "";
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
// The child dying before the code was accepted is a failure of the login, not
|
|
222
|
+
// of the deck; say so rather than leaving the dialog spinning.
|
|
223
|
+
child.done.then((r) => {
|
|
224
|
+
if (flow !== _login) return;
|
|
225
|
+
if (flow.state === "awaiting_url" || flow.state === "awaiting_code") {
|
|
226
|
+
flow.state = "failed";
|
|
227
|
+
flow.error = r.timedOut ? "the sign-in window expired" : firstUseful(r.stderr || r.stdout) || "sign-in ended without a code";
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// The URL arrives on the child's first write, typically within a second.
|
|
232
|
+
const url = await waitFor(() => flow.url, 15_000);
|
|
233
|
+
if (!url) {
|
|
234
|
+
child.kill();
|
|
235
|
+
_login = { state: "failed", error: "the claude CLI did not print a sign-in link" };
|
|
236
|
+
return { ok: false, reason: "no_url", ...loginState() };
|
|
237
|
+
}
|
|
238
|
+
return { ok: true, ...loginState() };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Hand the code back, then register whatever it signed us in as.
|
|
243
|
+
*
|
|
244
|
+
* Every step after the code is verification, not optimism: the CLI can exit 0
|
|
245
|
+
* having changed nothing, so the identity is re-read and compared before the
|
|
246
|
+
* store is touched at all.
|
|
247
|
+
*/
|
|
248
|
+
export async function submitLoginCode(code) {
|
|
249
|
+
const flow = _login;
|
|
250
|
+
if (!flow || flow.state !== "awaiting_code") return { ok: false, reason: "not_waiting", ...loginState() };
|
|
251
|
+
if (typeof code !== "string" || !code.trim()) return { ok: false, reason: "empty_code", ...loginState() };
|
|
252
|
+
if (flow.prompts === 0) return { ok: false, reason: "not_prompted", ...loginState() };
|
|
253
|
+
|
|
254
|
+
const askedBefore = flow.prompts;
|
|
255
|
+
flow.state = "registering";
|
|
256
|
+
flow.child.write(code.trim() + "\n");
|
|
257
|
+
|
|
258
|
+
// Whichever comes first: the CLI finishing, or it asking again. A wrong code
|
|
259
|
+
// produces the second, and the flow stays usable so the user can retype
|
|
260
|
+
// rather than starting the whole sign-in over.
|
|
261
|
+
const r = await Promise.race([
|
|
262
|
+
flow.child.done,
|
|
263
|
+
waitFor(() => flow.prompts > askedBefore, CODE_VERDICT_MS, 200).then(again => (again ? "rejected" : "slow")),
|
|
264
|
+
]);
|
|
265
|
+
if (r === "rejected") {
|
|
266
|
+
flow.state = "awaiting_code";
|
|
267
|
+
flow.error = "that code was not accepted — copy it again from the browser";
|
|
268
|
+
return { ok: false, reason: "code_rejected", ...loginState() };
|
|
269
|
+
}
|
|
270
|
+
if (r === "slow") {
|
|
271
|
+
flow.state = "awaiting_code";
|
|
272
|
+
flow.error = "the claude CLI has not answered — try the code again";
|
|
273
|
+
return { ok: false, reason: "no_verdict", ...loginState() };
|
|
274
|
+
}
|
|
275
|
+
if (!r.ok) {
|
|
276
|
+
flow.state = "failed";
|
|
277
|
+
flow.error = r.timedOut ? "the sign-in window expired" : firstUseful(r.stderr || r.stdout) || "the code was not accepted";
|
|
278
|
+
return { ok: false, reason: "login_failed", ...loginState() };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const identity = await currentIdentity();
|
|
282
|
+
if (!identity) {
|
|
283
|
+
flow.state = "failed";
|
|
284
|
+
flow.error = "signed in, but the claude CLI still reports nobody logged in";
|
|
285
|
+
return { ok: false, reason: "no_identity", ...loginState() };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return withStoreLock(async () => {
|
|
289
|
+
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
290
|
+
if (!add.ok) {
|
|
291
|
+
flow.state = "failed";
|
|
292
|
+
flow.error = addFailureText(add);
|
|
293
|
+
await restoreActive(flow.previousActive);
|
|
294
|
+
return { ok: false, reason: "add_failed", ...loginState() };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const after = await readStore();
|
|
298
|
+
const slot = newSlot(flow.before, after);
|
|
299
|
+
// No new slot means the account was already managed and cswap refreshed its
|
|
300
|
+
// credentials in place. That is a success with a different sentence.
|
|
301
|
+
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
302
|
+
|
|
303
|
+
await restoreActive(flow.previousActive);
|
|
304
|
+
invalidateClaudeAccountsCache();
|
|
305
|
+
// Collect straight away, so the new row shows numbers instead of "never
|
|
306
|
+
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
307
|
+
runDetached(await cswapBin(), ["list"]);
|
|
308
|
+
|
|
309
|
+
flow.state = "done";
|
|
310
|
+
flow.account = { num, email: identity.email, added: slot != null };
|
|
311
|
+
return { ok: true, ...loginState() };
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function cancelLogin() {
|
|
316
|
+
const flow = _login;
|
|
317
|
+
if (!flow) return { ok: true, ...loginState() };
|
|
318
|
+
try { flow.child?.kill(); } catch { /* already gone */ }
|
|
319
|
+
// The login may have completed before the cancel arrived, in which case the
|
|
320
|
+
// live credentials already moved and putting them back is the point.
|
|
321
|
+
await restoreActive(flow.previousActive);
|
|
322
|
+
invalidateClaudeAccountsCache();
|
|
323
|
+
_login = null;
|
|
324
|
+
return { ok: true, ...loginState() };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Put the account that was active before the login back in front. */
|
|
328
|
+
async function restoreActive(num) {
|
|
329
|
+
if (num == null) return;
|
|
330
|
+
const after = await readStore();
|
|
331
|
+
if (String(after.activeNum) === String(num)) return;
|
|
332
|
+
await run(await cswapBin(), ["switch", String(num)], { timeout: 30_000 }).catch(() => {});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── share / import ───────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* One account, packaged for another deck.
|
|
339
|
+
*
|
|
340
|
+
* claude-swap's envelope carries the account's OAuth token in the clear — its
|
|
341
|
+
* own module header says so ("No encryption is built in"). The wrapper adds an
|
|
342
|
+
* expiry so a copy left behind in clipboard history stops working, and nothing
|
|
343
|
+
* more: it is not encryption and is not presented as any.
|
|
344
|
+
*
|
|
345
|
+
* The default export shape is used deliberately, never --full, which would
|
|
346
|
+
* embed the entire ~/.claude.json including every project and MCP server.
|
|
347
|
+
*/
|
|
348
|
+
export function wrapShare(payload, now = Date.now(), ttlMs = SHARE_TTL_MS) {
|
|
349
|
+
const body = JSON.stringify({ v: 1, exp: now + ttlMs, payload });
|
|
350
|
+
return SHARE_PREFIX + Buffer.from(body, "utf8").toString("base64");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The inverse. Returns `{ok:true, payload}` or `{ok:false, reason}`. */
|
|
354
|
+
export function unwrapShare(blob, now = Date.now()) {
|
|
355
|
+
const text = String(blob ?? "").trim();
|
|
356
|
+
if (!text.startsWith(SHARE_PREFIX)) return { ok: false, reason: "not_a_share" };
|
|
357
|
+
let env;
|
|
358
|
+
try {
|
|
359
|
+
env = JSON.parse(Buffer.from(text.slice(SHARE_PREFIX.length), "base64").toString("utf8"));
|
|
360
|
+
} catch {
|
|
361
|
+
return { ok: false, reason: "corrupt" };
|
|
362
|
+
}
|
|
363
|
+
if (env?.v !== 1) return { ok: false, reason: "wrong_version" };
|
|
364
|
+
// Checked before the payload is looked at, let alone handed to cswap.
|
|
365
|
+
if (typeof env.exp !== "number" || env.exp < now) return { ok: false, reason: "expired" };
|
|
366
|
+
if (typeof env.payload !== "string" || !env.payload) return { ok: false, reason: "corrupt" };
|
|
367
|
+
return { ok: true, payload: env.payload };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function shareAccount(num) {
|
|
371
|
+
const n = Number(num);
|
|
372
|
+
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
373
|
+
const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
374
|
+
if (!r.ok || !r.stdout.trim()) {
|
|
375
|
+
return { ok: false, reason: "export_failed", detail: firstUseful(r.stderr || r.stdout) };
|
|
376
|
+
}
|
|
377
|
+
return { ok: true, blob: wrapShare(r.stdout), expiresAt: Date.now() + SHARE_TTL_MS };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function importAccount(blob) {
|
|
381
|
+
const un = unwrapShare(blob);
|
|
382
|
+
if (!un.ok) return { ok: false, reason: un.reason };
|
|
383
|
+
|
|
384
|
+
return withStoreLock(async () => {
|
|
385
|
+
const before = await readStore();
|
|
386
|
+
const child = runInteractive(await cswapBin(), ["import", "-"], { timeout: CSWAP_TIMEOUT_MS });
|
|
387
|
+
child.write(un.payload);
|
|
388
|
+
try { child.write(""); } catch { /* best effort */ }
|
|
389
|
+
// cswap reads stdin to EOF, so the pipe has to close for it to proceed.
|
|
390
|
+
endStdin(child);
|
|
391
|
+
|
|
392
|
+
const r = await child.done;
|
|
393
|
+
if (!r.ok) return { ok: false, reason: "import_failed", detail: firstUseful(r.stderr || r.stdout) };
|
|
394
|
+
|
|
395
|
+
const after = await readStore();
|
|
396
|
+
const slot = newSlot(before, after);
|
|
397
|
+
invalidateClaudeAccountsCache();
|
|
398
|
+
if (slot != null) runDetached(await cswapBin(), ["list"]);
|
|
399
|
+
// No new slot is not an error: without --force, cswap skips an account it
|
|
400
|
+
// already holds. Saying which happened is the difference between "it
|
|
401
|
+
// worked" and "why is nothing different".
|
|
402
|
+
return { ok: true, added: slot != null, num: slot, output: firstUseful(r.stdout) };
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── the small edits ──────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
// The exact question `cswap remove` asks. There is no --yes flag: assume_yes is
|
|
409
|
+
// a Python parameter its TUI passes in-process, so the only way through from a
|
|
410
|
+
// CLI is to answer. Matched, never assumed — an unrecognised prompt gets the
|
|
411
|
+
// child killed instead of a blind "y".
|
|
412
|
+
const REMOVE_PROMPT = /are you sure you want to permanently remove account-(\d+)/i;
|
|
413
|
+
|
|
414
|
+
export function removePromptMatches(line, num) {
|
|
415
|
+
const m = REMOVE_PROMPT.exec(stripTerminalEscapes(line));
|
|
416
|
+
return Boolean(m) && m[1] === String(num);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function removeAccount(num) {
|
|
420
|
+
const n = Number(num);
|
|
421
|
+
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
422
|
+
|
|
423
|
+
return withStoreLock(async () => {
|
|
424
|
+
const child = runInteractive(await cswapBin(), ["remove", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
425
|
+
let answered = false;
|
|
426
|
+
child.onLine((line) => {
|
|
427
|
+
if (answered) return;
|
|
428
|
+
if (removePromptMatches(line, n)) { answered = true; child.write("y\n"); }
|
|
429
|
+
});
|
|
430
|
+
const r = await child.done;
|
|
431
|
+
invalidateClaudeAccountsCache();
|
|
432
|
+
if (!r.ok) return { ok: false, reason: "remove_failed", detail: firstUseful(r.stderr || r.stdout) };
|
|
433
|
+
// Exit 0 without the prompt means cswap declined for its own reason — a
|
|
434
|
+
// live session on that account, most often — and printed why.
|
|
435
|
+
if (!answered) return { ok: false, reason: "not_confirmed", detail: firstUseful(r.stdout || r.stderr) };
|
|
436
|
+
return { ok: true, output: firstUseful(r.stdout) };
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function setAlias(num, alias) {
|
|
441
|
+
const n = Number(num);
|
|
442
|
+
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
443
|
+
const clean = typeof alias === "string" ? alias.trim() : "";
|
|
444
|
+
const args = clean ? ["alias", String(n), clean] : ["alias", String(n), "--unset"];
|
|
445
|
+
return withStoreLock(async () => {
|
|
446
|
+
const r = await run(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
|
|
447
|
+
invalidateClaudeAccountsCache();
|
|
448
|
+
return r.ok
|
|
449
|
+
? { ok: true, output: firstUseful(r.stdout) }
|
|
450
|
+
: { ok: false, reason: "alias_failed", detail: firstUseful(r.stderr || r.stdout) };
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function moveAccount(num, slot) {
|
|
455
|
+
const n = Number(num), s = Number(slot);
|
|
456
|
+
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
457
|
+
if (!Number.isInteger(s) || s < 1 || s > 999) return { ok: false, reason: "bad_slot" };
|
|
458
|
+
return withStoreLock(async () => {
|
|
459
|
+
const r = await run(await cswapBin(), ["move", String(n), String(s)], { timeout: CSWAP_TIMEOUT_MS });
|
|
460
|
+
invalidateClaudeAccountsCache();
|
|
461
|
+
return r.ok
|
|
462
|
+
? { ok: true, output: firstUseful(r.stdout) }
|
|
463
|
+
: { ok: false, reason: "move_failed", detail: firstUseful(r.stderr || r.stdout) };
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ── text ─────────────────────────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
/** The line worth showing a user out of a CLI's output. */
|
|
470
|
+
export function firstUseful(text) {
|
|
471
|
+
const lines = stripTerminalEscapes(text)
|
|
472
|
+
.split(/\r?\n/)
|
|
473
|
+
.map(l => l.replace(/^Error:\s*/i, "").trim())
|
|
474
|
+
.filter(l => l && !/^-+$/.test(l));
|
|
475
|
+
return lines.length ? lines[lines.length - 1].slice(0, 300) : "";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* `cswap add`'s failure, in the words most likely to be actionable.
|
|
480
|
+
*
|
|
481
|
+
* The Keychain case is singled out because it is the one a server hits and a
|
|
482
|
+
* terminal does not: a process without a GUI session cannot read the login
|
|
483
|
+
* keychain, so the credential read times out and the message alone
|
|
484
|
+
* ("unreadable right now") does not say what to do.
|
|
485
|
+
*/
|
|
486
|
+
export function addFailureText(r) {
|
|
487
|
+
const text = firstUseful(r.stderr || r.stdout);
|
|
488
|
+
if (/keychain/i.test(text)) {
|
|
489
|
+
return `${text} — start agents-deck from a Terminal window rather than a background service.`;
|
|
490
|
+
}
|
|
491
|
+
return text || `cswap add exited ${r.code}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function waitFor(get, timeoutMs, stepMs = 100) {
|
|
495
|
+
const until = Date.now() + timeoutMs;
|
|
496
|
+
for (;;) {
|
|
497
|
+
const v = get();
|
|
498
|
+
if (v) return v;
|
|
499
|
+
if (Date.now() >= until) return null;
|
|
500
|
+
await new Promise(r => setTimeout(r, stepMs));
|
|
501
|
+
}
|
|
502
|
+
}
|
package/src/server/exec.mjs
CHANGED
|
@@ -110,6 +110,109 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
|
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Run a command whose stdin stays open, so the caller can answer it.
|
|
115
|
+
*
|
|
116
|
+
* `run` above closes stdin and waits for the end; that is right for everything
|
|
117
|
+
* that only reports. It is useless for the two commands the accounts panel has
|
|
118
|
+
* to drive: `claude auth login` prints a URL and then blocks reading the code
|
|
119
|
+
* the user pastes back, and `cswap remove` blocks on its own `[y/N]` — there is
|
|
120
|
+
* no `--yes` flag to avoid it. Both need a child that outlives one request and
|
|
121
|
+
* can be written to.
|
|
122
|
+
*
|
|
123
|
+
* Returns immediately with a handle:
|
|
124
|
+
* write(text) — into the child's stdin
|
|
125
|
+
* kill() — give up; `done` still settles
|
|
126
|
+
* onLine(cb) — every complete stdout/stderr line as it arrives
|
|
127
|
+
* done — Promise<{ok, code, killed, timedOut, stdout, stderr}>
|
|
128
|
+
*
|
|
129
|
+
* Never rejects, for the same reason `run` never does. Same Windows candidate
|
|
130
|
+
* resolution, since `claude` and `cswap` are `.cmd` shims there.
|
|
131
|
+
*/
|
|
132
|
+
export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 << 10 } = {}) {
|
|
133
|
+
const tries = candidates(cmd);
|
|
134
|
+
const lineSubs = [];
|
|
135
|
+
let child = null;
|
|
136
|
+
let pending = ""; // partial line carried between chunks
|
|
137
|
+
let stdout = "", stderr = "";
|
|
138
|
+
let timedOut = false, killed = false;
|
|
139
|
+
let settle;
|
|
140
|
+
const done = new Promise((resolve) => { settle = resolve; });
|
|
141
|
+
|
|
142
|
+
// Subscribers get `(text, partial)`. A subscriber must not throw and must
|
|
143
|
+
// tolerate repeats: `partial` is the still-unterminated tail, re-offered as
|
|
144
|
+
// it grows, because a prompt is written WITHOUT a newline —
|
|
145
|
+
// "Paste code here if prompted > " never terminates a line, so a
|
|
146
|
+
// newline-only reader would wait for it forever.
|
|
147
|
+
const emitLines = (text) => {
|
|
148
|
+
pending += text;
|
|
149
|
+
let nl;
|
|
150
|
+
while ((nl = pending.indexOf("\n")) !== -1) {
|
|
151
|
+
const line = pending.slice(0, nl).replace(/\r$/, "");
|
|
152
|
+
pending = pending.slice(nl + 1);
|
|
153
|
+
for (const cb of lineSubs) { try { cb(line, false); } catch { /* a subscriber must not kill the child */ } }
|
|
154
|
+
}
|
|
155
|
+
if (pending) {
|
|
156
|
+
for (const cb of lineSubs) { try { cb(pending, true); } catch { /* ignore */ } }
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const finish = (code, err) => {
|
|
161
|
+
if (!settle) return;
|
|
162
|
+
const s = settle; settle = null;
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
s({ ok: code === 0 && !err && !timedOut, code: err?.code ?? code ?? -1, killed, timedOut, stdout, stderr });
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const timer = setTimeout(() => {
|
|
168
|
+
timedOut = true;
|
|
169
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
170
|
+
}, timeout);
|
|
171
|
+
timer.unref?.();
|
|
172
|
+
|
|
173
|
+
const attempt = (i) => {
|
|
174
|
+
if (i >= tries.length) return finish(-1, { code: "ENOENT" });
|
|
175
|
+
const raw = tries[i];
|
|
176
|
+
const { file, args: argv, opts } = isBatch(raw) ? viaCmd(raw, args) : { file: raw, args, opts: {} };
|
|
177
|
+
try {
|
|
178
|
+
child = spawn(file, argv, { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, ...opts });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return tryNext(err) ? attempt(i + 1) : finish(-1, err);
|
|
181
|
+
}
|
|
182
|
+
child.on("error", (err) => {
|
|
183
|
+
// Only retry another spelling while nothing has run yet; a mid-run error
|
|
184
|
+
// is this child's failure, not evidence the name was wrong.
|
|
185
|
+
if (tryNext(err) && !stdout && !stderr) { child = null; return attempt(i + 1); }
|
|
186
|
+
finish(-1, err);
|
|
187
|
+
});
|
|
188
|
+
child.on("spawn", () => resolved.set(cmd, raw));
|
|
189
|
+
// Capped so a runaway child cannot grow the heap without bound; the tail is
|
|
190
|
+
// what carries the error, so the head is what gets dropped.
|
|
191
|
+
const keep = (buf, text) => (buf + text).slice(-maxOutput);
|
|
192
|
+
child.stdout?.on("data", (d) => { const t = String(d); stdout = keep(stdout, t); emitLines(t); });
|
|
193
|
+
child.stderr?.on("data", (d) => { const t = String(d); stderr = keep(stderr, t); emitLines(t); });
|
|
194
|
+
child.on("close", (code) => finish(code ?? -1, null));
|
|
195
|
+
};
|
|
196
|
+
attempt(0);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
write(text) {
|
|
200
|
+
try { child?.stdin?.write(text); } catch { /* the child is gone; `done` says so */ }
|
|
201
|
+
},
|
|
202
|
+
/** Close stdin. A command that reads to EOF (`cswap import -`) needs this
|
|
203
|
+
* to start work at all; a prompting one must never see it. */
|
|
204
|
+
end() {
|
|
205
|
+
try { child?.stdin?.end(); } catch { /* already closed */ }
|
|
206
|
+
},
|
|
207
|
+
kill() {
|
|
208
|
+
killed = true;
|
|
209
|
+
try { child?.kill(); } catch { /* already gone */ }
|
|
210
|
+
},
|
|
211
|
+
onLine(cb) { lineSubs.push(cb); },
|
|
212
|
+
done,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
113
216
|
/**
|
|
114
217
|
* Start a command and don't wait for it. Same resolution, no output captured.
|
|
115
218
|
* Used where the result lands somewhere else — a file the next poll reads, or
|
package/src/server/index.mjs
CHANGED
|
@@ -1121,6 +1121,48 @@ async function handleClaudeAccountSwitch(req, res) {
|
|
|
1121
1121
|
send(res, result.ok ? 200 : 400, result);
|
|
1122
1122
|
}
|
|
1123
1123
|
|
|
1124
|
+
function cswapAdminModule() {
|
|
1125
|
+
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Reading the login's progress. The browser polls this while its dialog is
|
|
1129
|
+
// open, the same way the upgrade notice polls /api/version.
|
|
1130
|
+
async function handleAccountLoginState(_req, res) {
|
|
1131
|
+
const { loginState } = await cswapAdminModule();
|
|
1132
|
+
send(res, 200, { ok: true, ...loginState() });
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Everything that changes the account store, behind one verb switch — the shape
|
|
1137
|
+
* handleCswapAutoAction already uses.
|
|
1138
|
+
*
|
|
1139
|
+
* The sign-in code is the one field here that is a credential. It is read out
|
|
1140
|
+
* of the body, handed straight to the child's stdin, and never logged, echoed
|
|
1141
|
+
* back, or written anywhere — which is also why this is a POST body and not a
|
|
1142
|
+
* query parameter.
|
|
1143
|
+
*/
|
|
1144
|
+
async function handleClaudeAccountAdmin(req, res) {
|
|
1145
|
+
const admin = await cswapAdminModule();
|
|
1146
|
+
const body = await readBody(req).catch(() => null);
|
|
1147
|
+
let parsed = null;
|
|
1148
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
1149
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
1150
|
+
|
|
1151
|
+
let result;
|
|
1152
|
+
switch (parsed.action) {
|
|
1153
|
+
case "login": result = await admin.startLogin({ email: parsed.email }); break;
|
|
1154
|
+
case "login-code": result = await admin.submitLoginCode(parsed.code); break;
|
|
1155
|
+
case "login-cancel": result = await admin.cancelLogin(); break;
|
|
1156
|
+
case "share": result = await admin.shareAccount(parsed.account); break;
|
|
1157
|
+
case "import": result = await admin.importAccount(parsed.blob); break;
|
|
1158
|
+
case "remove": result = await admin.removeAccount(parsed.account); break;
|
|
1159
|
+
case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
|
|
1160
|
+
case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
|
|
1161
|
+
default: return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
1162
|
+
}
|
|
1163
|
+
send(res, result.ok ? 200 : 400, result);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1124
1166
|
function cswapAutoModule() {
|
|
1125
1167
|
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
|
|
1126
1168
|
}
|
|
@@ -1276,6 +1318,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1276
1318
|
if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
|
|
1277
1319
|
if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
|
|
1278
1320
|
if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
|
|
1321
|
+
if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
|
|
1322
|
+
if (req.method === "POST" && url.pathname === "/api/claude-accounts/admin") return guard(handleClaudeAccountAdmin(req, res), res);
|
|
1279
1323
|
if (req.method === "GET" && url.pathname === "/api/sound-hook") return guard(handleSoundHook(req, res), res);
|
|
1280
1324
|
if (req.method === "POST" && url.pathname === "/api/sound-hook") return guard(handleSoundHookSet(req, res), res);
|
|
1281
1325
|
if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
|