ccdeck 3.43.3 → 3.44.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.
Files changed (82) hide show
  1. package/README.md +74 -20
  2. package/THIRD_PARTY_NOTICES.md +43 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +60 -16
  9. package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
  10. package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
  11. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
  12. package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
  13. package/dist/web/assets/app-CQIstkMF.js +318 -0
  14. package/dist/web/assets/app-Ch-0CYlK.css +1 -0
  15. package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/package.json +2 -1
  18. package/release-notes.json +40 -0
  19. package/src/server/account-notify.mjs +206 -9
  20. package/src/server/account-pick.mjs +160 -0
  21. package/src/server/account-projects.mjs +55 -15
  22. package/src/server/account-routes.mjs +47 -4
  23. package/src/server/account-watch.mjs +48 -4
  24. package/src/server/activity.mjs +3 -2
  25. package/src/server/args.mjs +112 -2
  26. package/src/server/autoswitch-failover.mjs +138 -0
  27. package/src/server/claude-accounts.mjs +115 -14
  28. package/src/server/claude-dir.mjs +142 -5
  29. package/src/server/claude-identity.mjs +23 -0
  30. package/src/server/cli-lines.mjs +224 -0
  31. package/src/server/codex-cost-usage.mjs +67 -0
  32. package/src/server/codex-enrichment.mjs +53 -9
  33. package/src/server/codex-review-policy.d.mts +4 -0
  34. package/src/server/codex-review-policy.mjs +11 -0
  35. package/src/server/codex-skills.mjs +146 -0
  36. package/src/server/codex-watch.mjs +5 -2
  37. package/src/server/cswap-auto-choice.mjs +53 -0
  38. package/src/server/cswap-auto-loop.mjs +100 -9
  39. package/src/server/deck-client.mjs +112 -0
  40. package/src/server/deck-prefs.mjs +30 -6
  41. package/src/server/event-pipeline.mjs +19 -3
  42. package/src/server/event-routes.mjs +15 -0
  43. package/src/server/exec.mjs +6 -2
  44. package/src/server/index.mjs +17 -1
  45. package/src/server/installer.mjs +149 -8
  46. package/src/server/lan-copies.mjs +43 -5
  47. package/src/server/lan-deck.mjs +3 -0
  48. package/src/server/lan-engine.mjs +7 -2
  49. package/src/server/lan-manifest.mjs +2 -2
  50. package/src/server/lan-serve.mjs +17 -2
  51. package/src/server/lan-sync.mjs +5 -5
  52. package/src/server/lan-wire.mjs +3 -3
  53. package/src/server/login-guard.mjs +162 -0
  54. package/src/server/login-service.mjs +3 -0
  55. package/src/server/pinned-build.mjs +5 -0
  56. package/src/server/prefs-routes.mjs +4 -0
  57. package/src/server/process-command.mjs +4 -1
  58. package/src/server/reports.mjs +7 -1
  59. package/src/server/request-gates.mjs +7 -0
  60. package/src/server/session-read-gate.mjs +5 -3
  61. package/src/server/session-summary.mjs +281 -0
  62. package/src/server/skill-hook.mjs +162 -0
  63. package/src/server/stop-continue-gate.d.mts +8 -0
  64. package/src/server/stop-continue-gate.mjs +77 -0
  65. package/src/server/stop-continue.mjs +544 -0
  66. package/src/server/stop-failure.d.mts +10 -0
  67. package/src/server/stop-failure.mjs +97 -0
  68. package/src/server/swap-log.mjs +19 -10
  69. package/src/server/tool-audit-report.mjs +5 -3
  70. package/src/server/tool-audit-routes.mjs +5 -3
  71. package/src/server/tool-audit-skills.mjs +82 -0
  72. package/src/server/tool-audit.mjs +53 -11
  73. package/src/server/traffic-radar-capture.mjs +10 -1
  74. package/src/server/traffic-radar-monitor.mjs +4 -2
  75. package/src/server/traffic-radar-routes.mjs +13 -0
  76. package/src/server/traffic-radar-service.mjs +133 -0
  77. package/src/server/traffic-radar-wire.mjs +4 -3
  78. package/src/server/usage-day.mjs +1 -1
  79. package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
  80. package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
  81. package/dist/web/assets/app-DsHSufBt.css +0 -1
  82. package/dist/web/assets/app-HpFVXK_g.js +0 -310
@@ -0,0 +1,613 @@
1
+ // The commands (#2183): `ccdeck accounts`, `ccdeck switch`, `ccdeck usage`,
2
+ // `ccdeck sessions` and `ccdeck open` — the deck's Accounts panel and session
3
+ // list for somebody who reached the machine over SSH and has no tab to look at.
4
+ //
5
+ // Shaped like the one-shots (bin/cli/one-shot.js): do the thing, print, exit,
6
+ // never start a server. Each one is a CLIENT of the deck already running —
7
+ // see src/server/deck-client.mjs for why it has to be, not merely may be — and
8
+ // the account commands fall back to claude-swap's store, read in this process,
9
+ // only when no deck is up to ask. That fallback is safe for the same reason the
10
+ // rule exists: with no deck there is no second writer for the switch to race —
11
+ // which is why a deck that is registered and alive but did not answer in time
12
+ // gets its roster read here and never a switch.
13
+ //
14
+ // Every command takes `--json`: one object on stdout, `schemaVersion` first,
15
+ // `ok` beside it, the human sentences left off. Exit codes are part of the
16
+ // contract — see EXIT.
17
+ import { spawn } from "node:child_process";
18
+ import { hostname, userInfo } from "node:os";
19
+ import { join } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { COMMANDS, isPortValue } from "../../src/server/args.mjs";
22
+ import { isWsl } from "../../src/server/open-url.mjs";
23
+ import { COMMAND, PKG_ROOT, PKG_VERSION } from "./package.js";
24
+ import { G, P, UNICODE, cols } from "./screen.js";
25
+ import { oneShotVoice, reportFlagRows } from "./one-shot.js";
26
+
27
+ const load = (rel) => import(pathToFileURL(join(PKG_ROOT, rel)).href);
28
+
29
+ /** The picker, loaded only when somebody is about to be shown it. */
30
+ async function pickAccount(rows, opts) {
31
+ const { pickAccount: pick } = await import("./picker.js");
32
+ return pick(rows, opts);
33
+ }
34
+
35
+ /**
36
+ * What a command's exit code means. 0 and 1 are the usual pair; 2 is the
37
+ * command line itself (an account nothing matches, a command misspelled), so a
38
+ * script can tell "you asked wrong" from "it went wrong"; 3 is a command that
39
+ * needs a running deck and found none, which a script answers by starting one.
40
+ */
41
+ export const EXIT = Object.freeze({ OK: 0, FAILED: 1, USAGE: 2, NO_DECK: 3 });
42
+
43
+ /** The version of `--json` output. Bumped only for a change a reader of the
44
+ * old shape would misread; new fields are added under the same number. */
45
+ export const SCHEMA_VERSION = 1;
46
+
47
+ /** Run the command `flags` names and resolve to the exit code. */
48
+ export async function runCommand(flags) {
49
+ const out = output(flags);
50
+ if (typeof flags.unknownCommand === "string") return out.fail(EXIT.USAGE, "unknown_command", unknownCommandText(flags.unknownCommand));
51
+ reportFlagRows(flags, oneShotVoice());
52
+ // A `--port` that names no port names no deck, and reading it as "any deck"
53
+ // would answer about — or switch through — one nobody named. Failing closed,
54
+ // as `--stop` does: `--port 431x`, `--port $UNSET`, `--port4317`.
55
+ const askedPort = (flags.incomplete ?? []).some(x => x.flag === "--port" || x.flag === "-p")
56
+ || (flags.unknown ?? []).some(t => /^(-p|--port)/.test(String(t)));
57
+ if (askedPort || (flags.port != null && !isPortValue(flags.port))) {
58
+ return out.fail(EXIT.USAGE, "bad_port", `--port${flags.port != null ? ` ${flags.port}` : ""}: not a port number ${G.dash} expected 0-65535`);
59
+ }
60
+ switch (flags.command) {
61
+ case "accounts": return accountsCommand(flags, out);
62
+ case "switch": return switchCommand(flags, out);
63
+ case "usage": return usageCommand(flags, out);
64
+ case "sessions": return sessionsCommand(flags, out);
65
+ case "open": return openCommand(flags, out);
66
+ default: return out.fail(EXIT.USAGE, "unknown_command", unknownCommandText(String(flags.command)));
67
+ }
68
+ }
69
+
70
+ // ── how a command speaks ─────────────────────────────────────────────────────
71
+
72
+ /** The two voices, chosen once: lines for a person, one object for a script. */
73
+ function output(flags) {
74
+ const json = flags.json === true;
75
+ const say = (line = "") => { if (!json) process.stdout.write(`${line}\n`); };
76
+ return {
77
+ json,
78
+ say,
79
+ /** The answer. In JSON, the object; for a person, nothing — the command
80
+ * has already said its lines. */
81
+ done(data, code = EXIT.OK) {
82
+ if (json) process.stdout.write(`${JSON.stringify({ schemaVersion: SCHEMA_VERSION, ok: code === EXIT.OK, ...data })}\n`);
83
+ return code;
84
+ },
85
+ /** A failure: `code` for the exit, `reason` for a script, `text` for a
86
+ * person (on stderr, where an error belongs, prefixed with the command). */
87
+ fail(code, reason, text, extra = {}) {
88
+ if (json) {
89
+ process.stdout.write(`${JSON.stringify({ schemaVersion: SCHEMA_VERSION, ok: false, error: { code: reason, message: plain(text) }, ...extra })}\n`);
90
+ } else {
91
+ for (const line of [].concat(text)) process.stderr.write(`${COMMAND}: ${line}\n`);
92
+ }
93
+ return code;
94
+ },
95
+ };
96
+ }
97
+
98
+ /** A message without the backticks a terminal reader is shown, for JSON. */
99
+ const plain = (text) => [].concat(text).join(" ").replace(/`/g, "");
100
+
101
+ /** The closest command word to a misspelled one, by edit distance, when it is
102
+ * close enough to be what was meant. */
103
+ function suggestion(word) {
104
+ const words = [...Object.keys(COMMANDS), "status", "stop", "logs", "help", "start"];
105
+ let best = null;
106
+ for (const w of words) {
107
+ const d = distance(String(word).toLowerCase(), w);
108
+ if (d <= 2 && (!best || d < best.d)) best = { w, d };
109
+ }
110
+ return best?.w ?? null;
111
+ }
112
+
113
+ function distance(a, b) {
114
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
115
+ for (let i = 1; i <= a.length; i++) {
116
+ let prev = row[0];
117
+ row[0] = i;
118
+ for (let j = 1; j <= b.length; j++) {
119
+ const cur = row[j];
120
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
121
+ prev = cur;
122
+ }
123
+ }
124
+ return row[b.length];
125
+ }
126
+
127
+ function unknownCommandText(word) {
128
+ const near = suggestion(word);
129
+ return [
130
+ `unknown command "${word}"${near ? ` ${G.dash} did you mean \`${COMMAND} ${near}\`?` : ""}`,
131
+ `\`${COMMAND} --help\` lists the commands; \`${COMMAND}\` alone starts the deck.`,
132
+ ];
133
+ }
134
+
135
+ // ── where the answers come from ──────────────────────────────────────────────
136
+
137
+ /** The running deck, or null — `--port` names one. */
138
+ async function deckFor(flags) {
139
+ const { findDeck } = await load("src/server/deck-client.mjs");
140
+ const port = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
141
+ return { deck: await findDeck({ port }), port };
142
+ }
143
+
144
+ /** Decks whose process is alive although none answered the challenge — slow
145
+ * at boot, in a pause, on a loaded machine. A switch here would race theirs. */
146
+ async function silentDecks(port) {
147
+ const { registeredDecks } = await load("src/server/running-deck.mjs");
148
+ const decks = await registeredDecks().catch(() => []);
149
+ return port == null ? decks : decks.filter(d => d.port === port);
150
+ }
151
+
152
+ async function ask(deck, opts) {
153
+ const { askDeck } = await load("src/server/deck-client.mjs");
154
+ return askDeck(deck, opts);
155
+ }
156
+
157
+ /**
158
+ * The roster, from the deck when one is running and from claude-swap's store
159
+ * in this process when none is. `{ roster, source, deck }`, or `{ failed }`
160
+ * with the exit code already printed.
161
+ */
162
+ async function readRoster(flags, out) {
163
+ const { deck, port } = await deckFor(flags);
164
+ if (!deck && port != null) {
165
+ return { failed: out.fail(EXIT.NO_DECK, "no_deck", `no deck is listening on ${port} ${G.dash} \`${COMMAND} status\` lists them`) };
166
+ }
167
+ if (deck) {
168
+ const r = await ask(deck, { path: "/api/claude-accounts?refresh=1" });
169
+ if (r.ok && r.body) return { roster: r.body, source: "deck", deck };
170
+ return { failed: out.fail(EXIT.FAILED, "deck_unreachable", deckTrouble(deck, r)) };
171
+ }
172
+ const silent = await silentDecks(port);
173
+ const { fetchClaudeAccounts } = await load("src/server/claude-accounts.mjs");
174
+ return { roster: await fetchClaudeAccounts({ force: true }), source: "local", deck: null, silent };
175
+ }
176
+
177
+ /** The footer of a roster read here rather than through a deck. */
178
+ function localNote(silent) {
179
+ return silent?.length
180
+ ? `the deck on ${silent.map(d => d.port).join(", ")} did not answer ${G.dash} read from claude-swap's store`
181
+ : `no deck is running ${G.dash} read from claude-swap's store`;
182
+ }
183
+
184
+ /** What to say about a deck that did not answer as expected. */
185
+ function deckTrouble(deck, r) {
186
+ if (r.status === 404) {
187
+ return `the deck on ${deck.port} (v${deck.version || "?"}) predates this command ${G.dash} \`${COMMAND} --new\` restarts it on v${PKG_VERSION}`;
188
+ }
189
+ if (r.status === 0) return `the deck on ${deck.port} did not answer (${r.reason ?? "no reply"})`;
190
+ return `the deck on ${deck.port} answered ${r.status}${r.body?.reason ? ` (${r.body.reason})` : ""}`;
191
+ }
192
+
193
+ /** Why a roster has nothing in it, in the words a person needs next. */
194
+ function rosterTrouble(roster) {
195
+ switch (roster?.reason) {
196
+ case "no_accounts":
197
+ return `no Claude accounts yet ${G.dash} sign in with \`claude\`, then add it from the deck's Accounts panel or with \`cswap add\``;
198
+ case "cswap_installing":
199
+ return `the deck is installing claude-swap, which keeps the accounts ${G.dash} try again in a minute`;
200
+ case "cswap_refused":
201
+ return `the claude-swap on this machine (${roster.version ?? "unknown version"}) is one the deck refused to run${roster.want ? ` ${G.dash} it wants ${roster.want}` : ""}`;
202
+ case "no_cswap":
203
+ return [`claude-swap, which keeps the accounts, is not installed ${G.dash} \`${COMMAND}\` installs it when it starts`]
204
+ .concat(typeof roster.hint === "string" && roster.hint ? [`or by hand: ${roster.hint}`] : []);
205
+ default:
206
+ return `could not read the accounts${roster?.reason ? ` (${roster.reason})` : ""}`;
207
+ }
208
+ }
209
+
210
+ /** The roster's rows as `--json` hands them out: the fields a script needs,
211
+ * without the panel's internal bookkeeping. */
212
+ function accountJson(a) {
213
+ return {
214
+ num: a.num, email: a.email, alias: a.alias, org: a.org,
215
+ active: a.active === true, disabled: a.disabled === true, alive: a.alive !== false,
216
+ headroom: a.headroom ?? null,
217
+ lanes: (a.lanes ?? []).map(l => ({ id: l.id, label: l.label, pct: l.pct, resetAt: l.resetAt ?? null })),
218
+ fetchedAt: a.fetchedAt ?? null, stale: a.stale === true,
219
+ error: a.error ?? null, staleCopy: a.staleCopy === true, stopped: a.stopped === true,
220
+ };
221
+ }
222
+
223
+ function screen() {
224
+ return { tone: P, g: G, unicode: UNICODE, columns: cols(), now: Date.now() };
225
+ }
226
+
227
+ // ── accounts ────────────────────────────────────────────────────────────────
228
+
229
+ async function accountsCommand(flags, out) {
230
+ const got = await readRoster(flags, out);
231
+ if (got.failed !== undefined) return got.failed;
232
+ const { roster, source, silent } = got;
233
+ if (!roster?.ok) return out.fail(EXIT.FAILED, roster?.reason ?? "unreadable", rosterTrouble(roster), { source });
234
+ const { accountLines } = await load("src/server/cli-lines.mjs");
235
+ out.say("");
236
+ for (const line of accountLines(roster.accounts, screen())) out.say(line);
237
+ out.say("");
238
+ out.say(` ${P.muted}\`${COMMAND} switch <n|email|alias>\` ${G.bullet} \`--best\` picks as Auto-switch would${P.reset}`);
239
+ if (source === "local") out.say(` ${P.muted}${localNote(silent)}${P.reset}`);
240
+ out.say("");
241
+ return out.done({ source, activeNum: roster.activeNum ?? null, accounts: roster.accounts.map(accountJson) });
242
+ }
243
+
244
+ // ── switch ──────────────────────────────────────────────────────────────────
245
+
246
+ async function switchCommand(flags, out) {
247
+ const operands = flags.operands ?? [];
248
+ // `cswap swap 1 2` exchanges two slots. Here `swap` switches to one account,
249
+ // and two of them is somebody who expected the other meaning — answered with
250
+ // where that lives rather than with a guess at which of the two they meant.
251
+ if (operands.length > 1) {
252
+ return out.fail(EXIT.USAGE, "too_many_accounts", [
253
+ `\`${COMMAND} ${flags.commandWord ?? "switch"}\` moves the live login to ONE account ${G.dash} name one, or use \`--best\``,
254
+ `to exchange two slots' numbers, that is claude-swap's own: \`cswap swap ${operands[0]} ${operands[1]}\``,
255
+ ]);
256
+ }
257
+ if (operands.length && flags.best) {
258
+ return out.fail(EXIT.USAGE, "both_named", `name an account or pass \`--best\`, not both`);
259
+ }
260
+
261
+ const got = await readRoster(flags, out);
262
+ if (got.failed !== undefined) return got.failed;
263
+ const { roster, source, deck, silent } = got;
264
+ // A deck that is alive and did not answer holds the store's lock in its own
265
+ // process, where this one cannot see it: switching beside it is the race
266
+ // #1039 closed. Said before anything is picked, not after.
267
+ if (!deck && silent?.length) {
268
+ return out.fail(EXIT.FAILED, "deck_not_answering", [
269
+ `the deck on ${silent.map(d => d.port).join(", ")} is running but did not answer ${G.dash} not switching behind its back`,
270
+ `try again in a moment; \`${COMMAND} status\` shows it`,
271
+ ]);
272
+ }
273
+ if (!roster?.ok) return out.fail(EXIT.FAILED, roster?.reason ?? "unreadable", rosterTrouble(roster), { source });
274
+ const { accountName, bestAccount, resolveAccount, switchable } = await load("src/server/account-pick.mjs");
275
+ const accounts = roster.accounts;
276
+ const before = accounts.find(a => a.active) ?? null;
277
+ const interactive = !out.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
278
+
279
+ let target = null;
280
+ let rule = null;
281
+ if (flags.best) {
282
+ const best = bestAccount(accounts, await autoRules(deck, accounts));
283
+ if (best.none) return out.fail(EXIT.FAILED, "nothing_switchable", `no account can take the login ${G.dash} every one is disabled or needs a fresh sign-in`);
284
+ if (best.already) {
285
+ out.say(`\n ${P.ok}${G.ok}${P.reset} already on ${BEST_STAYS[best.rule] ?? BEST_STAYS["most-room"]} ${G.dash} ${accountName(best.already)}\n`);
286
+ return out.done({ switched: false, source, rule: best.rule, account: accountJson(best.already) });
287
+ }
288
+ target = best.account;
289
+ rule = best.rule;
290
+ } else if (operands.length) {
291
+ const hit = resolveAccount(accounts, operands[0]);
292
+ if (hit.account) target = hit.account;
293
+ else if (hit.ambiguous && interactive) {
294
+ target = await pickAccount(hit.ambiguous, { ...screen(), title: `"${operands[0]}" matches ${hit.ambiguous.length} accounts` });
295
+ if (!target) return cancelled(out);
296
+ } else if (hit.ambiguous) {
297
+ return out.fail(EXIT.USAGE, "ambiguous", [
298
+ `"${operands[0]}" matches ${hit.ambiguous.length} accounts ${G.dash} name one by its number:`,
299
+ ...hit.ambiguous.map(a => ` ${accountName(a)}`),
300
+ ], { matches: hit.ambiguous.map(accountJson) });
301
+ } else {
302
+ return out.fail(EXIT.USAGE, "no_such_account", [
303
+ `no account matches "${operands[0]}"`,
304
+ `\`${COMMAND} accounts\` lists them by number, email and alias`,
305
+ ]);
306
+ }
307
+ } else if (interactive) {
308
+ const initial = Math.max(0, accounts.findIndex(a => a.active));
309
+ target = await pickAccount(accounts, { ...screen(), initial });
310
+ if (!target) return cancelled(out);
311
+ } else {
312
+ return out.fail(EXIT.USAGE, "no_account_named",
313
+ `name the account: \`${COMMAND} switch <n|email|alias>\`, or \`--best\` for the one Auto-switch would pick`);
314
+ }
315
+
316
+ if (target.active) {
317
+ out.say(`\n ${P.ok}${G.ok}${P.reset} already on ${accountName(target)}\n`);
318
+ return out.done({ switched: false, source, account: accountJson(target) });
319
+ }
320
+ // The one refusal here: a login known to be dead. Switching to it signs every
321
+ // running session out, and a person who named it from a terminal is the least
322
+ // likely to have seen the panel's red badge first.
323
+ if (!switchable(target) && !target.disabled) {
324
+ return out.fail(EXIT.FAILED, "needs_sign_in", [
325
+ `${accountName(target)} needs a fresh sign-in ${G.dash} switching to it would sign every running session out`,
326
+ `sign it in again from the deck's Accounts panel, then switch`,
327
+ ], { account: accountJson(target) });
328
+ }
329
+
330
+ const result = deck
331
+ ? await switchThroughDeck(deck, target.num)
332
+ : await switchHere(target.num);
333
+ if (!result.ok && result.reason === "deck_timeout") {
334
+ // The deck may still be waiting its turn at the store's lock, and the
335
+ // switch lands when it gets it: "could not" would be a guess.
336
+ return out.fail(EXIT.FAILED, "deck_timeout", [
337
+ `the deck has not finished switching to ${accountName(target)} ${G.dash} it may still land`,
338
+ `\`${COMMAND} accounts\` shows which account is live`,
339
+ ]);
340
+ }
341
+ // The deck's own refusal of a login it found dead when it looked, under its
342
+ // store lock (#2184) — newer than the roster this command read, so said in
343
+ // the words of the refusal above rather than as a failed command.
344
+ if (!result.ok && result.reason === "login_expired") {
345
+ return out.fail(EXIT.FAILED, "login_expired", [
346
+ `${accountName(target)}'s login expired ${G.dash} switching to it would sign every running session out`,
347
+ `sign it in again from the deck's Accounts panel, then switch`,
348
+ ], { account: accountJson(target) });
349
+ }
350
+ if (!result.ok) {
351
+ const why = result.reason === "no_cswap" ? `claude-swap is not installed`
352
+ : result.reason === "timeout" ? `claude-swap took too long to answer`
353
+ : result.output ? String(result.output).trim().split("\n").pop()
354
+ : result.reason ?? "the switch failed";
355
+ return out.fail(EXIT.FAILED, result.reason ?? "switch_failed", `could not switch to ${accountName(target)} ${G.dash} ${why}`);
356
+ }
357
+
358
+ // Who follows the switch. Claude Code reads the login per request, so every
359
+ // running Claude session moves with it from its next message — worth saying
360
+ // out loud, since that is the whole effect and it is not visible anywhere.
361
+ const live = deck ? await liveClaudeSessions(deck) : null;
362
+ const usage = lanesSummary(target);
363
+ out.say("");
364
+ out.say(` ${P.ok}${G.ok}${P.reset} switched to ${P.bold}${accountName(target)}${P.reset}${usage ? ` ${P.muted}${G.bullet} ${usage}${P.reset}` : ""}`);
365
+ if (before) out.say(` ${P.muted}from ${accountName(before)}${P.reset}`);
366
+ if (rule && rule !== "most-room") out.say(` ${P.muted}${BEST_PICKS[rule] ?? ""}${P.reset}`);
367
+ if (live != null && live > 0) {
368
+ out.say(` ${P.muted}${live} running Claude session${live === 1 ? "" : "s"} ${live === 1 ? "uses" : "use"} it from the next message${P.reset}`);
369
+ }
370
+ if (!deck) out.say(` ${P.muted}no deck is running ${G.dash} switched with claude-swap directly${P.reset}`);
371
+ out.say("");
372
+ return out.done({
373
+ switched: true, source,
374
+ ...(rule ? { rule } : {}),
375
+ account: accountJson({ ...target, active: true }),
376
+ previous: before ? accountJson({ ...before, active: false }) : null,
377
+ liveSessions: live,
378
+ });
379
+ }
380
+
381
+ function cancelled(out) {
382
+ out.say(` ${P.muted}${G.dash} nothing switched${P.reset}\n`);
383
+ return out.done({ switched: false, cancelled: true }, EXIT.OK);
384
+ }
385
+
386
+ /** Where `--best` stayed, by the rule that kept it (account-pick.mjs). */
387
+ const BEST_STAYS = Object.freeze({
388
+ "most-room": "the account with the most room",
389
+ home: "your home account, which Auto-switch keeps while it has room",
390
+ rules: "the account Auto-switch's rules keep",
391
+ });
392
+
393
+ /** Why `--best` went where it went, when it was not by room alone. */
394
+ const BEST_PICKS = Object.freeze({
395
+ home: "your home account, as Auto-switch would choose",
396
+ "weekly-reset": "the soonest weekly reset, as Auto-switch would choose",
397
+ });
398
+
399
+ /**
400
+ * Auto-switch's home and weekly rules (#2200), for `--best` to pick what Auto-
401
+ * switch would — or null for "neither is set", where `--best` is the most
402
+ * room, the engine's own choice. From the deck when one answered: the guarded
403
+ * read its panel makes, with its token. With no deck, read here. A deck that
404
+ * predates the rules (404) or cannot say has none to follow.
405
+ */
406
+ async function autoRules(deck, accounts) {
407
+ const { bestRules } = await load("src/server/cswap-auto-choice.mjs");
408
+ if (!deck) return bestRules(accounts).catch(() => null);
409
+ const r = await ask(deck, { path: "/api/cswap-auto" });
410
+ if (!r.ok || !r.body?.choice || typeof r.body.choice !== "object") return null;
411
+ return bestRules(accounts, { choice: r.body.choice, settings: r.body.settings ?? {} }).catch(() => null);
412
+ }
413
+
414
+ /** "5h 12% · 7d 40%", from a row's lanes, or "". */
415
+ function lanesSummary(a) {
416
+ return (a.lanes ?? [])
417
+ .filter(l => l.id === "five_hour" || l.id === "seven_day")
418
+ .map(l => `${l.label} ${Math.round(l.pct)}%`)
419
+ .join(` ${G.bullet} `);
420
+ }
421
+
422
+ /** The switch, asked of the deck: its lock, its cache invalidation, its swap
423
+ * log — the same route the panel's click takes. */
424
+ async function switchThroughDeck(deck, num) {
425
+ // Longer than the deck's own thirty seconds for `cswap switch`, so it is the
426
+ // deck's timeout that answers rather than this one cutting it off mid-switch.
427
+ const r = await ask(deck, { method: "POST", path: "/api/claude-accounts/switch", body: { account: num }, timeoutMs: 45_000 });
428
+ if (r.body && typeof r.body === "object") return r.body;
429
+ return { ok: false, reason: r.reason === "timeout" ? "deck_timeout" : r.reason ?? `http_${r.status}` };
430
+ }
431
+
432
+ /** The switch with no deck to ask: claude-accounts.mjs's own, under the same
433
+ * mutex, and recorded in the same swap log the deck's route writes. */
434
+ async function switchHere(num) {
435
+ const { switchClaudeAccount } = await load("src/server/claude-accounts.mjs");
436
+ const result = await switchClaudeAccount(num);
437
+ if (result.ok) {
438
+ const { recordSwap } = await load("src/server/swap-log.mjs");
439
+ await recordSwap(num, "manual");
440
+ }
441
+ return result;
442
+ }
443
+
444
+ /** How many Claude sessions the deck sees running, or null when it cannot say. */
445
+ async function liveClaudeSessions(deck) {
446
+ const r = await ask(deck, { path: "/api/sessions", timeoutMs: 3_000 });
447
+ if (!r.ok || !Array.isArray(r.body?.sessions)) return null;
448
+ return r.body.sessions.filter(s => s.provider !== "codex" && s.state !== "ended").length;
449
+ }
450
+
451
+ // ── usage ───────────────────────────────────────────────────────────────────
452
+
453
+ async function usageCommand(flags, out) {
454
+ const got = await readRoster(flags, out);
455
+ if (got.failed !== undefined) return got.failed;
456
+ const { roster, source, silent } = got;
457
+ if (!roster?.ok) return out.fail(EXIT.FAILED, roster?.reason ?? "unreadable", rosterTrouble(roster), { source });
458
+ const active = roster.accounts.find(a => a.active);
459
+ if (!active) {
460
+ return out.fail(EXIT.FAILED, "none_active", `no account is active ${G.dash} \`${COMMAND} switch\` picks one`, { source });
461
+ }
462
+ const { usageLines } = await load("src/server/cli-lines.mjs");
463
+ out.say("");
464
+ for (const line of usageLines(active, screen())) out.say(line);
465
+ out.say("");
466
+ if (source === "local") out.say(` ${P.muted}${localNote(silent)}${P.reset}\n`);
467
+ return out.done({ source, account: accountJson(active) });
468
+ }
469
+
470
+ // ── sessions ────────────────────────────────────────────────────────────────
471
+
472
+ async function sessionsCommand(flags, out) {
473
+ const { deck, port } = await deckFor(flags);
474
+ if (!deck) {
475
+ return out.fail(EXIT.NO_DECK, "no_deck", port != null
476
+ ? `no deck is listening on ${port} ${G.dash} \`${COMMAND} status\` lists them`
477
+ : `no deck is running ${G.dash} sessions are what a running deck watches; \`${COMMAND}\` starts one`);
478
+ }
479
+ const r = await ask(deck, { path: "/api/sessions" });
480
+ if (!r.ok || !Array.isArray(r.body?.sessions)) return out.fail(EXIT.FAILED, "deck_unreachable", deckTrouble(deck, r));
481
+ const all = r.body.sessions;
482
+ const shown = flags.all ? all : all.filter(s => s.state !== "ended");
483
+ const { sessionLines } = await load("src/server/cli-lines.mjs");
484
+ const waiting = shown.filter(s => s.state === "waiting").length;
485
+ const working = shown.filter(s => s.state === "working").length;
486
+ out.say("");
487
+ for (const line of sessionLines(shown, { ...screen(), now: r.body.now ?? Date.now() })) out.say(line);
488
+ out.say("");
489
+ const ended = all.length - shown.length;
490
+ const parts = [`${shown.length} session${shown.length === 1 ? "" : "s"}`];
491
+ if (waiting) parts.push(`${P.warn}${waiting} waiting on you${P.reset}${P.muted}`);
492
+ if (working) parts.push(`${working} working`);
493
+ out.say(` ${P.muted}${parts.join(` ${G.bullet} `)}${ended && !flags.all ? ` ${G.bullet} \`--all\` adds ${ended} ended` : ""}${P.reset}`);
494
+ out.say("");
495
+ return out.done({ deck: { port: deck.port, version: deck.version ?? null }, sessions: shown });
496
+ }
497
+
498
+ // ── open ────────────────────────────────────────────────────────────────────
499
+
500
+ /**
501
+ * Is this terminal somewhere a browser cannot be opened for its reader?
502
+ *
503
+ * Over SSH the browser `xdg-open` would start is on the remote machine, if
504
+ * anywhere — not in front of the person who typed the command. And a Linux box
505
+ * with no display has nowhere to open one at all.
506
+ */
507
+ export function remoteShell(env = process.env, platform = process.platform, wsl = () => isWsl(platform, env)) {
508
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
509
+ // WSL without WSLg has no DISPLAY either, and opens the Windows browser —
510
+ // open-url.mjs knows how.
511
+ return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY && !wsl();
512
+ }
513
+
514
+ /** The port the reader's end of the tunnel listens on: never the deck's own,
515
+ * because the reader of this is a ccdeck user, whose own deck very likely
516
+ * has that port on their machine — and a forward that cannot bind it would
517
+ * leave the URL opening their deck instead of this one. */
518
+ export function tunnelPort(port) {
519
+ return port + 10_000 <= 65_535 ? port + 10_000 : port - 10_000;
520
+ }
521
+
522
+ /**
523
+ * The `ssh -L` line that brings a remote deck to the reader's own browser, and
524
+ * the URL it is then at.
525
+ *
526
+ * The address is the server's own end of the current connection, the third
527
+ * field of SSH_CONNECTION — the one name for this machine that is known to be
528
+ * reachable from where they are, since they just came in through it — and the
529
+ * fourth is the port sshd took it on, which is not always 22. The deck's host
530
+ * check admits the forwarded page on any local port: it arrives as 127.0.0.1
531
+ * and its Origin and Host agree, which is the deck's own-tab rule. And
532
+ * `ExitOnForwardFailure`, so a local port that is taken ends ssh with a reason
533
+ * instead of leaving it connected and forwarding nothing.
534
+ */
535
+ export function tunnelCommand(port, env = process.env, user = safeUser(), host = safeHost()) {
536
+ // Without SSH_CONNECTION — a headless box reached some other way — the
537
+ // machine's own name is the best guess there is, and the reader can see
538
+ // whether it is one their side resolves.
539
+ const [, , address, sshPort] = String(env.SSH_CONNECTION ?? "").trim().split(/\s+/);
540
+ const server = address || host || "<this-machine>";
541
+ const at = user ? `${user}@${server}` : server;
542
+ const local = tunnelPort(port);
543
+ const p = sshPort && sshPort !== "22" ? ` -p ${sshPort}` : "";
544
+ return {
545
+ command: `ssh -N -o ExitOnForwardFailure=yes -L ${local}:127.0.0.1:${port}${p} ${at}`,
546
+ url: `http://127.0.0.1:${local}`,
547
+ };
548
+ }
549
+
550
+ function safeUser() {
551
+ try { return userInfo().username; } catch { return ""; }
552
+ }
553
+
554
+ function safeHost() {
555
+ try { return hostname(); } catch { return ""; }
556
+ }
557
+
558
+ async function openCommand(flags, out) {
559
+ const { deck, port } = await deckFor(flags);
560
+ if (!deck) {
561
+ return out.fail(EXIT.NO_DECK, "no_deck", port != null
562
+ ? `no deck is listening on ${port} ${G.dash} \`${COMMAND} status\` lists them`
563
+ : `no deck is running ${G.dash} \`${COMMAND}\` starts one`);
564
+ }
565
+ const url = `http://127.0.0.1:${deck.port}`;
566
+ if (remoteShell()) {
567
+ const tunnel = tunnelCommand(deck.port);
568
+ out.say("");
569
+ out.say(` ${P.muted}the deck is running here, on${P.reset} ${P.accent}${P.bold}${url}${P.reset}`);
570
+ out.say(` ${P.muted}to open it on your own computer, run this there and keep it open:${P.reset}`);
571
+ out.say("");
572
+ out.say(` ${tunnel.command}`);
573
+ out.say("");
574
+ out.say(` ${P.muted}then open${P.reset} ${P.accent}${tunnel.url}${P.reset} ${P.muted}in your browser${P.reset}`);
575
+ out.say("");
576
+ return out.done({ url, opened: false, tunnel: tunnel.command, tunnelUrl: tunnel.url });
577
+ }
578
+ if (await openAndSettle(url)) {
579
+ out.say(`\n ${P.ok}${G.ok}${P.reset} opened ${P.accent}${url}${P.reset}\n`);
580
+ return out.done({ url, opened: true, tunnel: null });
581
+ }
582
+ return out.fail(EXIT.FAILED, "no_browser", `could not open a browser here ${G.dash} the deck is at ${url}`, { url, opened: false });
583
+ }
584
+
585
+ /**
586
+ * openUrl, and the time its fallbacks need. A launcher that is missing or
587
+ * finds no handler hands over to the next only while this process is still
588
+ * here to see it go, so a command that exited at once never reached the
589
+ * second. True at the first launcher that exits cleanly or outlives the grace
590
+ * period openUrl itself judges by; false when every one of them failed.
591
+ */
592
+ async function openAndSettle(url) {
593
+ const { openUrl, LAUNCH_GRACE_MS } = await load("src/server/open-url.mjs");
594
+ return new Promise(resolve => {
595
+ let timer = null;
596
+ let alive = 0;
597
+ const settle = (opened) => { clearTimeout(timer); resolve(opened); };
598
+ const arm = () => { clearTimeout(timer); timer = setTimeout(() => settle(alive > 0), LAUNCH_GRACE_MS + 100); };
599
+ arm();
600
+ openUrl(url, {
601
+ spawnFn: (file, args, opts) => {
602
+ const child = spawn(file, args, opts);
603
+ alive++;
604
+ let gone = false;
605
+ const end = () => { if (!gone) { gone = true; alive--; } };
606
+ child.on("error", end);
607
+ child.on("exit", code => { end(); if (code === 0) settle(true); });
608
+ arm();
609
+ return child;
610
+ },
611
+ });
612
+ });
613
+ }
package/bin/cli/help.js CHANGED
@@ -10,7 +10,29 @@ export function printHelp() {
10
10
  process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
11
11
 
12
12
  Usage:
13
- ${PRODUCT} [options]
13
+ ${PRODUCT} [options] Start the deck (or open the one already running)
14
+ ${PRODUCT} <command> [options] Ask the running deck from any terminal, SSH included
15
+
16
+ Commands:
17
+ accounts Every Claude account, with 5h / 7d usage and resets.
18
+ Aliases: ls, account
19
+ switch [n|email|alias] Move the live Claude login to that account. With no
20
+ account named, pick one from a list (on a terminal).
21
+ --best picks what Auto-switch would: the home
22
+ account or soonest weekly reset when one is set,
23
+ else the most room.
24
+ Aliases: swap, use
25
+ usage The active account's limits, every lane
26
+ sessions What every session is doing, the ones waiting on
27
+ you first. --all adds the ended ones. Alias: ps
28
+ open Open the deck in a browser. Over SSH, print the
29
+ \`ssh -L\` line that opens it on your own computer
30
+ status, stop, logs The same as --status, --stop and --logs
31
+
32
+ Every command takes --json (one object on stdout, with schemaVersion) and
33
+ --port <n> to name a deck. Without a running deck, accounts, switch and usage
34
+ read claude-swap's store directly. Exit codes: 0 done, 1 failed, 2 the
35
+ command line was wrong, 3 no deck is running.
14
36
 
15
37
  Options:
16
38
  -p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
@@ -59,8 +81,9 @@ Options:
59
81
  -h, --help Show this help
60
82
  -v, --version Print the version and exit
61
83
 
62
- Anything else on the command line is reported as an unknown option and then
63
- ignored: the deck still starts.
84
+ A first word that is not a command is refused, so \`${PRODUCT} acounts\` never
85
+ starts a deck by mistake. Anything else on the command line is reported as an
86
+ unknown option and then ignored: the deck still starts.
64
87
 
65
88
  A flag that takes a value never swallows the next flag. If the value is missing,
66
89
  empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
@@ -48,7 +48,7 @@ export async function oneShot(flags) {
48
48
  }
49
49
 
50
50
  /** The screen's glyphs and palette, by the names the one-shots use, and `say`. */
51
- function oneShotVoice() {
51
+ export function oneShotVoice() {
52
52
  const { dash, ok: gOk, warn: gWarn, bullet, arrow, ellipsis: gEllipsis } = G;
53
53
  const tone = P;
54
54
  const say = (line) => process.stdout.write(`${line}\n`);
@@ -72,7 +72,7 @@ const pidOf = (d) => (d.restarting ? d.parent : d.pid);
72
72
  // line is reported as an unknown option and then ignored" — held for a boot
73
73
  // and for none of the nine one-shots: `ccdeck --status --prot 4317` printed
74
74
  // the status and never mentioned the flag.
75
- function reportFlagRows(flags, { tone, gWarn, dash }) {
75
+ export function reportFlagRows(flags, { tone, gWarn, dash }) {
76
76
  for (const token of flags.unknown ?? []) {
77
77
  process.stderr.write(` ${tone.warn}${gWarn} unknown option${tone.reset} ${token} ${dash} see \`${COMMAND} --help\`\n`);
78
78
  }