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.
@@ -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
+ }
@@ -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
@@ -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);