ccdeck 3.43.4 → 3.45.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 (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -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 +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -0,0 +1,138 @@
1
+ // Auto-switch away from an active login that has died.
2
+ //
3
+ // WHY THE ENGINE CANNOT DO THIS UNDER THE DECK. claude-swap fails over only
4
+ // after the active account's usage has been unreadable for `unhealthyTicks`
5
+ // polls in a row (3 by default), and it counts them on the engine instance. The
6
+ // deck runs the engine as `cswap auto --once`, a new process per tick, so the
7
+ // count is 1 every time: each tick reports `active-usage-unknown 1/3` and the
8
+ // user stays on an account they can no longer use. Its idle hold has the same
9
+ // shape — `_idle_hold_since` starts again with every process, so its
10
+ // thirty-minute cap is never reached either. (claude-swap 0.26.0: cli.py builds
11
+ // a fresh AutoSwitchEngine for each --once run; autoswitch.py keeps both
12
+ // counters on it.)
13
+ //
14
+ // So the deck finishes that one decision itself, and nothing else: every other
15
+ // switch is still the engine's.
16
+ //
17
+ // WHAT COUNTS AS DEAD. Three answers from three places, all required:
18
+ // - the engine could not read the active account this tick;
19
+ // - claude-swap's stored copy of it is not alive (account-health.mjs);
20
+ // - the `claude` CLI itself says nobody is signed in. That is what Claude
21
+ // Code leaves behind once a refresh is refused: "Login expired · Please run
22
+ // /login" clears the saved credentials.
23
+ // Each alone has a known false alarm. An unreadable usage endpoint is a network
24
+ // blip; a dead stored copy beside a working session is #721's stale copy; and a
25
+ // signed-out CLI whose stored copy is alive is a /logout somebody meant, or one
26
+ // claude-swap can still repair. A CLI that could not be asked is no answer, so
27
+ // it never counts as signed out.
28
+ //
29
+ // WHERE IT GOES. An account claude-swap can still use, not disabled, under the
30
+ // threshold the tick ran with, and not one of the two the deck's own rules
31
+ // never land on either: set aside by claude-swap's engine (its refresh token is
32
+ // dead, the very thing being left) or in use by a local `cswap run` session.
33
+ // The freshest reading first, then the most headroom. A reading can be old —
34
+ // claude-swap collects idle accounts on its own schedule — and an old reading
35
+ // is still better than staying on a login that cannot answer at all; once the
36
+ // new account is active, the engine reads it and moves on by its own rules if
37
+ // it is full.
38
+ //
39
+ // AND THE DECK'S RULES. With a home or the weekly rule set, the loop answers a
40
+ // tick inside their cooldown without running the engine — except for an
41
+ // emergency, and an active account the engine could not read is one: it is
42
+ // the only way this failover hears of a dead login. The move it makes is
43
+ // recorded as the tick's (noteEngineTick), so the rules do not mistake it for
44
+ // a switch made by hand.
45
+ //
46
+ // HOW OFTEN. Once per FAILOVER_COOLDOWN_MS. A target whose login dies too is
47
+ // found the same way a tick later, and the wait keeps that from becoming a walk
48
+ // through every dead account in the store inside a minute.
49
+ import { fetchClaudeAccounts, switchClaudeAccount } from "./claude-accounts.mjs";
50
+ import { cliSignIn } from "./claude-identity.mjs";
51
+
52
+ /** The engine's two ways of saying it could not read the active account. */
53
+ export const LOST_ACTIVE_REASONS = new Set(["active-usage-unknown", "active-idle"]);
54
+
55
+ /** claude-swap's own cooldown after a switch, used here between failovers. */
56
+ export const FAILOVER_COOLDOWN_MS = 5 * 60_000;
57
+
58
+ /** What `failOver` names the switch, and the stuck tick, after. */
59
+ export const LOGIN_EXPIRED = "login-expired";
60
+
61
+ /** Whether a tick's answer could be a dead active login worth asking about. */
62
+ export function engineLostActive(tick) {
63
+ return tick?.ok === true && tick.event === "no-switch" && LOST_ACTIVE_REASONS.has(tick.reason);
64
+ }
65
+
66
+ /** The active roster row when its stored login is gone, else null. A stale
67
+ * copy is a working session the collector cannot see (#721), never this. */
68
+ export function deadActiveRow(accounts) {
69
+ const active = Array.isArray(accounts) ? accounts.find(a => a?.active) : null;
70
+ if (!active || active.staleCopy || active.alive !== false) return null;
71
+ return active;
72
+ }
73
+
74
+ /** The accounts a failover may land on, best first. `held` is the slots no
75
+ * move may land on whatever the rule (cswap-auto-choice.mjs slotsHeldBack):
76
+ * ones claude-swap's engine set aside, and ones a local `cswap run` session
77
+ * is using — the same two the deck's own rules skip. */
78
+ export function failoverTargets(accounts, threshold, held = new Set()) {
79
+ const limit = typeof threshold === "number" && Number.isFinite(threshold) ? threshold : 90;
80
+ return (Array.isArray(accounts) ? accounts : [])
81
+ .filter(a => a && !a.active && !a.disabled && a.alive === true && !a.staleCopy && !a.stopped
82
+ && !held.has(a.num)
83
+ && typeof a.headroom === "number" && 100 - a.headroom < limit)
84
+ .sort((a, b) => Number(a.stale === true) - Number(b.stale === true) || b.headroom - a.headroom);
85
+ }
86
+
87
+ const refOf = a => ({ number: a.num, email: a.email ?? "" });
88
+
89
+ /**
90
+ * Finish a tick the engine could not finish: switch away from the active
91
+ * account when its login is dead, or say why not.
92
+ *
93
+ * Null when this tick is not about a dead login at all — the tick then stands
94
+ * as the engine reported it. Otherwise a tick-shaped answer to merge over it:
95
+ * a `switch` with `trigger: "login-expired"`, or a `no-switch` whose reason is
96
+ * `login-expired` and whose detail says what stood in the way.
97
+ *
98
+ * `before` runs inside the store lock, ahead of the switch, so the loop can
99
+ * stand down if it was turned off or a terminal engine started meanwhile.
100
+ * `heldBack(accounts)` names the slots no move may land on (failoverTargets);
101
+ * one it cannot read holds nothing back.
102
+ */
103
+ export async function failOver(tick, {
104
+ lastAt = null,
105
+ now = Date.now(),
106
+ before,
107
+ roster = () => fetchClaudeAccounts({ force: true }),
108
+ signIn = cliSignIn,
109
+ switchTo = switchClaudeAccount,
110
+ heldBack = async () => new Set(),
111
+ } = {}) {
112
+ if (!engineLostActive(tick)) return null;
113
+ const read = await roster().catch(() => null);
114
+ const dead = deadActiveRow(read?.ok ? read.accounts : null);
115
+ if (!dead) return null;
116
+ const cli = await signIn().catch(() => null);
117
+ if (cli?.loggedIn !== false) return null;
118
+
119
+ const from = refOf(dead);
120
+ if (lastAt != null && now - lastAt < FAILOVER_COOLDOWN_MS) {
121
+ return { event: "no-switch", reason: LOGIN_EXPIRED, detail: "waiting after the last switch away from an expired login", from };
122
+ }
123
+ const held = await Promise.resolve().then(() => heldBack(read.accounts)).catch(() => null);
124
+ const [target] = failoverTargets(read.accounts, tick.threshold, held instanceof Set ? held : new Set());
125
+ if (!target) {
126
+ // `stuck`: the one answer the notifier says, and the panel shows.
127
+ return { event: "no-switch", reason: LOGIN_EXPIRED, detail: "no other account is ready", stuck: true, from };
128
+ }
129
+ // Forced: the current login is the dead one, and Claude Code has already
130
+ // cleared it. A plain switch would first back that empty login up, and
131
+ // claude-swap refuses to (see switchClaudeAccount).
132
+ const r = await switchTo(target.num, { before, force: true });
133
+ if (r?.skipped) return { event: "skipped", reason: r.skipped };
134
+ if (!r?.ok) {
135
+ return { ok: false, event: "no-switch", reason: "failover_failed", detail: r?.output || r?.reason || "switch failed", from };
136
+ }
137
+ return { event: "switch", switched: true, trigger: LOGIN_EXPIRED, from, to: refOf(target) };
138
+ }
@@ -39,10 +39,11 @@ import { currentIdentity } from "./claude-identity.mjs";
39
39
  // cycle is a lock that may not be there yet when a mutation wants it.
40
40
  import { withStoreLock } from "./store-lock.mjs";
41
41
  // claude-swap's own per-slot verdicts, which have to be ASKED for rather than
42
- // read off the store. The roster puts them on each row; verdictNow and
43
- // verdictsNow are re-exported because cswap-admin.mjs and lan-deck.mjs reach
44
- // them through this module.
45
- import { verdictFor, verdictsAskedAt } from "./claude-verdicts.mjs";
42
+ // read off the store. The roster puts them on each row, and a switch asks for a
43
+ // collection through the same queue before it reads its target (#2184);
44
+ // verdictNow and verdictsNow are re-exported because cswap-admin.mjs and
45
+ // lan-deck.mjs reach them through this module.
46
+ import { verdictFor, verdictsAskedAt, verdictsNow } from "./claude-verdicts.mjs";
46
47
  export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
47
48
  // Asking claude-swap to collect, which every roster read does when something is
48
49
  // due, and when each account will next be read.
@@ -51,6 +52,13 @@ import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
51
52
  // (#1893). Pure, and keyed the way LAN sync keys accounts.
52
53
  import { hasRecovered, reauthFor } from "./account-origins.mjs";
53
54
  import { accountKey } from "./lan-copies.mjs";
55
+ // What each organization's Claude settings were last seen to turn on, so a row
56
+ // can say it before anyone switches to the account. See its header for when the
57
+ // shared remote-settings.json can be pinned on an organization at all.
58
+ import { observeOrgTelemetry } from "./org-telemetry.mjs";
59
+ // Whether a switch target's stored login is known dead, and whether a
60
+ // collection would read it first (#2184). Pure, and imports nothing.
61
+ import { collectionWouldRead, deadLoginOf } from "./login-guard.mjs";
54
62
  import { dirname, join } from "node:path";
55
63
  import { existsSync } from "node:fs";
56
64
  import { homedir, platform } from "node:os";
@@ -463,6 +471,7 @@ async function readRoster(now, gen) {
463
471
  }
464
472
 
465
473
  const rows = await readUsageRows(root);
474
+ const orgTelemetry = await observeOrgTelemetry({ seq }).catch(() => ({}));
466
475
 
467
476
  // Kick a collection for the NEXT poll if anything is due — either because
468
477
  // claude-swap's schedule says so, or because an account has never been
@@ -496,7 +505,7 @@ async function readRoster(now, gen) {
496
505
  const row = rows[num];
497
506
  const key = accountKey(acct.email, acct.organizationUuid);
498
507
  const origin = Object.hasOwn(origins, key) ? origins[key] : null;
499
- const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin });
508
+ const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin, telemetry: orgTelemetry[acct.organizationUuid] ?? null });
500
509
  accounts.push(shown);
501
510
  // A put-off incident is over once a read succeeds after it, and the
502
511
  // put-off goes with it (#1893) — said here, where the read is, rather than
@@ -523,7 +532,7 @@ function observedMs(seconds) {
523
532
  return Number.isFinite(ms) && ms > 0 && Number.isFinite(new Date(ms).getTime()) ? ms : null;
524
533
  }
525
534
 
526
- function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
535
+ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null, telemetry = null }) {
527
536
  const matches = rowIsFor(row, acct);
528
537
  const good = matches ? row.lastGood : null;
529
538
 
@@ -613,6 +622,11 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
613
622
  // claude-swap has refused its login since: `{ key, since, dismissed }`.
614
623
  // `key` and `since` together name it, for "Not now" to send back.
615
624
  reauth: reauth ? { key, ...reauth } : null,
625
+ // What this account's organization last turned on for Claude Code's
626
+ // telemetry export, when the deck has seen its settings (org-telemetry.mjs):
627
+ // `{ orgName, level, content, destinations, seenAt }`. `level` "off" was
628
+ // seen off; null was never seen, which is unknown and not "off".
629
+ telemetry: telemetry ?? null,
616
630
  };
617
631
  }
618
632
 
@@ -628,8 +642,8 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
628
642
  * It says nothing about whether the user is signed in. Measured on the machine
629
643
  * that reported this, at the same instant:
630
644
  *
631
- * claude auth status --json -> loggedIn: true, claude3@sapec.md
632
- * cswap list --json -> claude3@sapec.md: relogin_required
645
+ * claude auth status --json -> loggedIn: true, carol@example.net
646
+ * cswap list --json -> carol@example.net: relogin_required
633
647
  * GET /api/quota -> source: cli, 5h 33%, 7d 37%
634
648
  *
635
649
  * The user had signed in again in a terminal, which refreshes the LIVE
@@ -796,6 +810,82 @@ export function slotNumber(accountNum) {
796
810
  return Number.isInteger(num) && num >= 1 && num <= 999 ? num : null;
797
811
  }
798
812
 
813
+ // ── before a switch: is the target's login known dead? (#2184) ──────────────
814
+
815
+ /** How long a switch waits for claude-swap to read its target first. A cold
816
+ * collection can take a minute; a switch that waited that long would read as
817
+ * a hang, and what it could not learn in time it does not hold against the
818
+ * target. */
819
+ const SWITCH_READ_WAIT_MS = 15_000;
820
+
821
+ /** Slot `num` as the store holds it — the account, its usage row and whether
822
+ * that row is the account's own — or null when there is no such slot. */
823
+ async function switchTarget(num) {
824
+ const root = backupRoot();
825
+ const seq = await readSequence(root);
826
+ const key = String(num);
827
+ const acct = seq?.accounts?.[key];
828
+ if (!acct) return null;
829
+ const row = (await readUsageRows(root))[key];
830
+ return { acct, row, matches: rowIsFor(row, acct), active: activeSlot(seq) === key };
831
+ }
832
+
833
+ /**
834
+ * Ask claude-swap to read slot `num` before a switch onto it, when a
835
+ * collection would read it at all — see login-guard.mjs for why that is the
836
+ * only check 0.26.0 offers, and the incident it would have caught.
837
+ *
838
+ * Shares the verdict queue's `cswap list --json`, so it never starts a second
839
+ * collection beside one already running, and waits for it no longer than
840
+ * SWITCH_READ_WAIT_MS. Outside the store lock: a collection takes none of the
841
+ * deck's, and a mutation queued behind this one must not wait out somebody
842
+ * else's network. Never throws — a check that cannot be made is no answer.
843
+ */
844
+ export async function readBeforeSwitch(num, { now = Date.now(), collect = () => verdictsNow(), waitMs = SWITCH_READ_WAIT_MS } = {}) {
845
+ let timer = null;
846
+ try {
847
+ const t = await switchTarget(num);
848
+ if (!t || t.active || deadLoginOf(t.row, { matches: t.matches })) return false;
849
+ if (!collectionWouldRead(t.row, { matches: t.matches, now })) return false;
850
+ const waited = new Promise(resolve => { timer = setTimeout(resolve, waitMs); timer.unref?.(); });
851
+ await Promise.race([Promise.resolve().then(collect).catch(() => null), waited]);
852
+ return true;
853
+ } catch {
854
+ return false;
855
+ } finally {
856
+ if (timer) clearTimeout(timer);
857
+ }
858
+ }
859
+
860
+ /**
861
+ * The refusal for a switch onto slot `num`, when claude-swap's store says its
862
+ * stored login is dead — or null, which is every case it cannot say so,
863
+ * including a store it could not read.
864
+ *
865
+ * `{ ok: false, reason: "login_expired", account: { num, email, alias }, error,
866
+ * since }`, so the panel can name the account and offer the sign-in that fixes
867
+ * it. A switch to the slot that is already active is not this question: it
868
+ * writes nothing live.
869
+ */
870
+ export async function expiredSwitchTarget(num) {
871
+ try {
872
+ const t = await switchTarget(num);
873
+ if (!t || t.active) return null;
874
+ const signedIn = originsNow()[accountKey(t.acct.email, t.acct.organizationUuid)]?.signedInAt;
875
+ const dead = deadLoginOf(t.row, { matches: t.matches, signedInAt: signedIn > 0 ? signedIn : null });
876
+ if (!dead) return null;
877
+ return {
878
+ ok: false,
879
+ reason: "login_expired",
880
+ account: { num, email: t.acct.email ?? null, alias: t.acct.alias ?? null },
881
+ error: dead.error,
882
+ since: dead.since,
883
+ };
884
+ } catch {
885
+ return null;
886
+ }
887
+ }
888
+
799
889
  /**
800
890
  * Switch the active Claude account by delegating to `cswap`.
801
891
  *
@@ -817,8 +907,25 @@ export function slotNumber(accountNum) {
817
907
  * The validation stays OUTSIDE the lock. A slot number that is not one is not a
818
908
  * mutation and must not wait behind somebody else's sixty-second `cswap add`
819
909
  * to be told so.
910
+ *
911
+ * AND IT DOES NOT SWITCH ONTO A LOGIN KNOWN TO BE DEAD (#2184). Every session
912
+ * on the machine runs on the login this puts live, so a dead one stops all of
913
+ * them at once. The target is read first when a collection would read it
914
+ * (readBeforeSwitch), and judged inside the lock, on the store as it stands
915
+ * when `cswap switch` would run (expiredSwitchTarget). Anything that cannot be
916
+ * judged lets the switch go ahead.
917
+ *
918
+ * `before`, when given, is asked first thing inside the lock: a switch nobody
919
+ * pressed — the auto-switch failover — may have been turned off while it
920
+ * queued, and answers `{ skipped: <why> }` instead of moving the account.
921
+ *
922
+ * `force` is `cswap switch --force`: activate the target without backing up
923
+ * the current login first. Only for a current login already known to be dead.
924
+ * Claude Code clears a login whose refresh was refused, and a plain switch then
925
+ * refuses in turn ("Current account credential is empty …; refusing to
926
+ * overwrite its backup"), which would leave the user exactly where they were.
820
927
  */
821
- export function switchClaudeAccount(accountNum) {
928
+ export function switchClaudeAccount(accountNum, { before, force = false } = {}) {
822
929
  // Straight into an exec argument, so nothing but a slot number gets through.
823
930
  const num = slotNumber(accountNum);
824
931
  if (num == null) {
@@ -827,13 +934,17 @@ export function switchClaudeAccount(accountNum) {
827
934
 
828
935
  // Argument vector, never a shell — and resolved through exec.mjs so the
829
936
  // Windows `.exe`/`.cmd` shim is found too.
830
- return withStoreLock(() => cswapBin()
831
- .then(bin => run(bin, ["switch", String(num)], { timeout: 30_000 }))
832
- .then(r => {
833
- if (r.ok) return { ok: true, output: r.stdout.trim() };
834
- const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
835
- return { ok: false, reason, output: failureDetail(r, 500) };
836
- }));
937
+ return readBeforeSwitch(num).then(() => withStoreLock(async () => {
938
+ const skipped = before ? await before() : null;
939
+ if (skipped) return { ok: false, skipped };
940
+ const expired = await expiredSwitchTarget(num);
941
+ if (expired) return expired;
942
+ const argv = force ? ["switch", String(num), "--force"] : ["switch", String(num)];
943
+ const r = await run(await cswapBin(), argv, { timeout: 30_000 });
944
+ if (r.ok) return { ok: true, output: r.stdout.trim() };
945
+ const reason = r.code === "ENOENT" ? "no_cswap" : r.killed ? "timeout" : "switch_failed";
946
+ return { ok: false, reason, output: failureDetail(r, 500) };
947
+ }));
837
948
  }
838
949
 
839
950
  /**
@@ -19,7 +19,7 @@
19
19
  // to "where does the `claude` command live", and the deck now asks that question
20
20
  // at boot, before any quota exists, to decide whether this is a Claude machine
21
21
  // at all. One list, one caller-visible name, no second spelling to drift.
22
- import { existsSync } from "node:fs";
22
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
23
23
  import { homedir } from "node:os";
24
24
  import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
25
25
 
@@ -131,15 +131,26 @@ function claudeCliOnDisk({
131
131
  home = homedir(),
132
132
  exists = existsSync,
133
133
  } = {}) {
134
+ for (const _ of claudeCliFound({ platform, env, home, exists })) return true;
135
+ return false;
136
+ }
137
+
138
+ /**
139
+ * Every `claude` the walk above finds, in the order it finds them — the known
140
+ * install paths first, then each PATH entry. claudeCliOnDisk stops at the first;
141
+ * claudeCodeVersions below reads them all, because an old CLI anywhere on the
142
+ * machine still reads the settings file a new one does.
143
+ */
144
+ function* claudeCliFound({ platform, env, home, exists }) {
134
145
  const path = platform === "win32" ? winPath : posixPath;
135
146
  const bare = [];
136
147
  for (const candidate of claudeCliCandidates(platform, env, home)) {
137
148
  // A full path is worth a single stat; a bare name means "ask PATH", which
138
149
  // is the walk below. Same split quotaClaudeBin makes, for the same reason.
139
- if (candidate.includes(path.sep)) { if (exists(candidate)) return true; }
150
+ if (candidate.includes(path.sep)) { if (exists(candidate)) yield candidate; }
140
151
  else bare.push(candidate);
141
152
  }
142
- if (bare.length === 0) return false;
153
+ if (bare.length === 0) return;
143
154
 
144
155
  // process.env is case-insensitive on Windows, but an injected plain object in
145
156
  // a test is not, and %Path% is how the variable is actually spelled there.
@@ -156,10 +167,136 @@ function claudeCliOnDisk({
156
167
  const dir = entry.trim().replace(/^"+|"+$/g, "");
157
168
  if (dir === "") continue;
158
169
  for (const name of bare) {
159
- for (const ext of exts) if (exists(path.join(dir, name + ext))) return true;
170
+ for (const ext of exts) {
171
+ const found = path.join(dir, name + ext);
172
+ if (exists(found)) yield found;
173
+ }
160
174
  }
161
175
  }
162
- return false;
176
+ }
177
+
178
+ /** A Claude Code version as its installers spell it: three numbers, and a
179
+ * pre-release or build suffix that does not change which release it is. */
180
+ const CC_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.+-]*)?$/;
181
+
182
+ /** Whether `version` is `min` or newer, by the three numbers. */
183
+ export function claudeVersionAtLeast(version, min) {
184
+ const a = CC_VERSION_RE.exec(String(version ?? ""));
185
+ const b = CC_VERSION_RE.exec(String(min ?? ""));
186
+ if (!a || !b) return false;
187
+ for (let i = 1; i <= 3; i++) {
188
+ const d = Number(a[i]) - Number(b[i]);
189
+ if (d !== 0) return d > 0;
190
+ }
191
+ return true;
192
+ }
193
+
194
+ /** The npm package every npm install of Claude Code is. */
195
+ const CC_PACKAGE = "@anthropic-ai/claude-code";
196
+
197
+ /**
198
+ * The version of the one install `bin` belongs to, read off disk, or
199
+ * undefined when its layout does not say.
200
+ *
201
+ * npm the package's own package.json, found by walking up from the
202
+ * file the bin resolves to — or, for the `claude.cmd` shim npm
203
+ * writes on Windows, in the node_modules beside the shim
204
+ * native `~/.local/bin/claude` links to `…/claude/versions/<version>`;
205
+ * on Windows it is a copy, and the newest of those versions is the
206
+ * one the installer put there
207
+ * Homebrew the cask's binary lives at `…/Caskroom/claude-code/<version>/claude`
208
+ *
209
+ * The package.json comes first, and a version-shaped directory is believed only
210
+ * where those two layouts put one: a node manager keeps the NODE version in its
211
+ * paths (`…/installs/node/22.14.0/…`), and reading that as Claude Code's would
212
+ * let a machine with an old CLI through the gate.
213
+ */
214
+ function installVersion(bin, { platform, home, exists, realpath, readText, list }) {
215
+ const path = platform === "win32" ? winPath : posixPath;
216
+ let real = bin;
217
+ try { real = realpath(bin); } catch { /* a dangling link reads as itself */ }
218
+ const fromPackage = file => {
219
+ try {
220
+ const pkg = JSON.parse(readText(file));
221
+ return pkg?.name === CC_PACKAGE && CC_VERSION_RE.test(pkg.version) ? pkg.version : undefined;
222
+ } catch { return undefined; }
223
+ };
224
+ const beside = path.join(path.dirname(bin), "node_modules", ...CC_PACKAGE.split("/"), "package.json");
225
+ if (exists(beside)) {
226
+ const v = fromPackage(beside);
227
+ if (v) return v;
228
+ }
229
+ let dir = path.dirname(real);
230
+ for (let i = 0; i < 5; i++) {
231
+ const file = path.join(dir, "package.json");
232
+ if (exists(file)) {
233
+ const v = fromPackage(file);
234
+ if (v) return v;
235
+ }
236
+ const up = path.dirname(dir);
237
+ if (up === dir) break;
238
+ dir = up;
239
+ }
240
+ const name = path.basename(real);
241
+ const parent = path.basename(path.dirname(real));
242
+ if (CC_VERSION_RE.test(name) && parent === "versions") return name;
243
+ if (CC_VERSION_RE.test(parent) && path.basename(path.dirname(path.dirname(real))) === "claude-code") return parent;
244
+ const nativeBin = path.join(home, ".local", "bin");
245
+ if (path.dirname(real) === nativeBin) {
246
+ try {
247
+ const versions = list(path.join(home, ".local", "share", "claude", "versions"))
248
+ .filter(v => CC_VERSION_RE.test(v));
249
+ let newest;
250
+ for (const v of versions) if (!newest || claudeVersionAtLeast(v, newest)) newest = v;
251
+ if (newest) return newest;
252
+ } catch { /* no native install beside it */ }
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ /**
258
+ * The versions of every Claude Code the deck can find on this machine, read
259
+ * off disk without running any of them — the same places hasClaudeInstalled
260
+ * looks, each resolved to the install it belongs to. Distinct, and only the
261
+ * ones whose layout names a version: an install that does not say is left out
262
+ * rather than guessed at.
263
+ *
264
+ * WITHOUT A CHILD PROCESS, for the reason claudeCliOnDisk gives: the installer
265
+ * asks this at boot, and a `claude --version` is about three seconds there.
266
+ * Every dependency is injected so the suite can lay out an npm, a native and a
267
+ * Homebrew install for any platform without one on the machine.
268
+ */
269
+ export function claudeCodeVersions(deps = {}) {
270
+ const out = new Set();
271
+ for (const { version } of claudeCodeInstalls(deps)) if (version) out.add(version);
272
+ return [...out];
273
+ }
274
+
275
+ /**
276
+ * Every `claude` the deck can find, each with the version its install layout
277
+ * names, or `version: undefined` where the layout does not say. The same walk
278
+ * and the same reading as claudeCodeVersions, which keeps only the versions;
279
+ * this keeps the installs that did not answer too, for a gate that has to
280
+ * treat "a Claude Code whose version is unknown" as a reason to refuse rather
281
+ * than as one fewer Claude Code (skill-hook.mjs).
282
+ */
283
+ export function claudeCodeInstalls({
284
+ platform = process.platform,
285
+ env = process.env,
286
+ home = homedir(),
287
+ exists = existsSync,
288
+ realpath = p => realpathSync.native(p),
289
+ readText = p => readFileSync(p, "utf8"),
290
+ list = p => readdirSync(p),
291
+ } = {}) {
292
+ const out = [];
293
+ const seen = new Set();
294
+ for (const bin of claudeCliFound({ platform, env, home, exists })) {
295
+ if (seen.has(bin)) continue;
296
+ seen.add(bin);
297
+ out.push({ bin, version: installVersion(bin, { platform, home, exists, realpath, readText, list }) });
298
+ }
299
+ return out;
163
300
  }
164
301
 
165
302
  /**
@@ -120,3 +120,26 @@ export async function currentIdentity() {
120
120
  return null;
121
121
  }
122
122
  }
123
+
124
+ /**
125
+ * Whether the CLI says anybody is signed in — the answer currentIdentity folds
126
+ * away: `{ loggedIn: true, email }`, `{ loggedIn: false }`, or null when there
127
+ * is no answer to read.
128
+ *
129
+ * `claude auth status --json` exits 1 when nobody is signed in and still prints
130
+ * `{"loggedIn": false, …}` (Claude Code 2.1.302), so `r.ok` cannot tell
131
+ * "signed out" from "could not ask". currentIdentity returns null for both,
132
+ * which is right for its callers. The auto-switch failover may only act on a
133
+ * CLI that has said the login is gone, so it reads the printed answer whatever
134
+ * the exit code, and a run that timed out or printed nothing is no answer.
135
+ */
136
+ export async function cliSignIn() {
137
+ const r = await run(adminClaudeBin(), ["auth", "status", "--json"], { timeout: 20_000 });
138
+ if (r.timedOut) return null;
139
+ try {
140
+ const j = JSON.parse(r.stdout);
141
+ if (j?.loggedIn === true) return { loggedIn: true, email: j.email ?? "" };
142
+ if (j?.loggedIn === false) return { loggedIn: false };
143
+ } catch { /* nothing printed that could be read */ }
144
+ return null;
145
+ }