@modelprofile.com/authswitch 3.2.0 → 3.3.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 (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/accounts.d.ts +25 -2
  3. package/dist_ts/accounts.js +112 -15
  4. package/dist_ts/classes.accountlist.d.ts +2 -1
  5. package/dist_ts/classes.accountlist.js +8 -5
  6. package/dist_ts/classes.claudecodeharness.d.ts +2 -2
  7. package/dist_ts/classes.claudecodeharness.js +3 -3
  8. package/dist_ts/classes.claudestatus.d.ts +2 -2
  9. package/dist_ts/classes.claudestatus.js +7 -6
  10. package/dist_ts/classes.cli.d.ts +15 -0
  11. package/dist_ts/classes.cli.js +51 -16
  12. package/dist_ts/classes.codexharness.d.ts +2 -2
  13. package/dist_ts/classes.codexharness.js +3 -3
  14. package/dist_ts/classes.codexstatus.d.ts +3 -3
  15. package/dist_ts/classes.codexstatus.js +12 -11
  16. package/dist_ts/classes.fileharness.d.ts +2 -2
  17. package/dist_ts/classes.limits.js +29 -13
  18. package/dist_ts/classes.opencodeharness.d.ts +2 -2
  19. package/dist_ts/classes.opencodeharness.js +3 -3
  20. package/dist_ts/classes.tui.js +7 -4
  21. package/dist_ts/interfaces.harness.d.ts +17 -5
  22. package/dist_ts/interfaces.list.d.ts +5 -1
  23. package/package.json +2 -2
  24. package/readme.md +62 -13
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/accounts.ts +105 -12
  27. package/ts/classes.accountlist.ts +7 -4
  28. package/ts/classes.claudecodeharness.ts +3 -3
  29. package/ts/classes.claudestatus.ts +7 -6
  30. package/ts/classes.cli.ts +56 -15
  31. package/ts/classes.codexharness.ts +3 -3
  32. package/ts/classes.codexstatus.ts +12 -11
  33. package/ts/classes.fileharness.ts +2 -2
  34. package/ts/classes.limits.ts +32 -13
  35. package/ts/classes.opencodeharness.ts +3 -3
  36. package/ts/classes.tui.ts +6 -3
  37. package/ts/interfaces.harness.ts +19 -2
  38. package/ts/interfaces.list.ts +5 -1
@@ -1,4 +1,4 @@
1
- import type { IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
1
+ import type { IAccountType, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
2
2
  /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
3
3
  export interface IAccountList {
4
4
  schemaVersion: 2;
@@ -37,6 +37,8 @@ export interface IAccountLimitRow {
37
37
  accountId: string | null;
38
38
  slotId?: string;
39
39
  isActive: boolean;
40
+ /** The adapter's plan name (`max`, `pro`), sanitised but not display-formatted; null when none is known or the row names no account. */
41
+ accountType: IAccountType | null;
40
42
  /** The provider's own window name, or null when no limit data is available. */
41
43
  limitType: string | null;
42
44
  scope: 'account' | 'feature' | null;
@@ -66,6 +68,8 @@ export interface IActiveAccountRow {
66
68
  account: string | null;
67
69
  accountId: string | null;
68
70
  slotId?: string;
71
+ /** The adapter's plan name (`max`, `pro`), sanitised but not display-formatted; null when none is known or the row names no account. */
72
+ accountType: IAccountType | null;
69
73
  /** When authswitch last saved this login; null when it was never stashed. */
70
74
  savedAt: string | null;
71
75
  /** Human elapsed time for the same `savedAt`, relative to `generatedAt`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelprofile.com/authswitch",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "private": false,
5
5
  "description": "Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status",
6
6
  "main": "dist_ts/index.js",
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
32
  "@modelprofile.com/flexharness-models": "^7.0.1",
33
33
  "@modelprofile.com/flexharness-providers": "^7.0.1",
34
- "@push.rocks/smartconsole": "^1.2.0"
34
+ "@push.rocks/smartconsole": "^1.3.0"
35
35
  },
36
36
  "files": [
37
37
  "ts/**/*",
package/readme.md CHANGED
@@ -307,6 +307,34 @@ authswitch codex use
307
307
 
308
308
  The account argument also accepts the directory slug, the local part, or a unique prefix, so `authswitch codex use alice` resolves to `alice@example.com` when that is unambiguous. It is deliberately not a substring match — this argument decides which credential becomes active, and an incidental substring hit should never pick an account you did not name.
309
309
 
310
+ So you know what you switch to, every harness's switch names the account with a
311
+ badge of its type and its current usage:
312
+
313
+ ```
314
+ ? Which Claude Code account should be active?
315
+ ❯ phil@example.com (active) (type:Max) (5h:12%) (w:75%)
316
+ alice@example.com (type:Pro, unverified) (w:40%)
317
+ ...
318
+ Now active: alice@example.com (type:Pro, unverified) (w:40%)
319
+ ```
320
+
321
+ The badge appears in the account picker of `use`, in the dashboard's switch
322
+ confirmation and on the `Now active:` line that reports a completed switch (in the
323
+ dashboard, its activity log). `type` is the account type as `limits` shows it; `5h`
324
+ and `w` are the account's five-hour and weekly usage windows, rounded to whole
325
+ percent. Feature limits never appear in it, and when several account windows share a
326
+ period without the provider picking one of them, that part is left out rather than
327
+ guessed.
328
+
329
+ Usage is read live, read-only and once per command. The picker looks up the offered
330
+ accounts, at most four at a time, before it asks; a switch named on the command line
331
+ switches first and then looks up only its target for the `Now active:` line. Whatever
332
+ has not answered after 2.5 seconds is left out and its requests are cancelled, so the
333
+ command still exits at once. Every part the provider did not report is omitted, and
334
+ with none known the account is named alone. Reading usage never delays the switch
335
+ beyond that deadline and never fails it. The dashboard reuses the status it already
336
+ shows and makes no extra lookup.
337
+
310
338
  ## What a switch actually does
311
339
 
312
340
  `use` runs as one sequence, and steps 1 to 4 stop at the first failure:
@@ -368,8 +396,9 @@ the later ones (`alice@example.com (2)`), so each account keeps its own column i
368
396
  the metric comparisons and reads the same everywhere. Provider facts already
369
397
  represented by structured fields are not repeated. Narrow terminals use compact
370
398
  account sections. Missing
371
- data stays Unavailable or Not reported; known zeroes remain zero. Stored plans are
372
- marked unverified. Renewal/cancellation dates are shown only when an adapter can
399
+ data stays Unavailable or Not reported; known zeroes remain zero. Plans read as in
400
+ `limits` and the switch badge (`Max`), and stored plans are marked unverified
401
+ (`Plus, unverified`). Renewal/cancellation dates are shown only when an adapter can
373
402
  provide live billing data. Codex uses the desktop app's account-check endpoint for
374
403
  automatic renewal, explicit renewal/cancellation dates and subscription expiry.
375
404
 
@@ -394,19 +423,22 @@ Account limits
394
423
  │ Provider │ Account │ Limit type │ Used % │ Resets in │
395
424
  ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
396
425
  ┃ Claude Code │ phil@example.com │ Claude weekly │ 75% │ 4d 3h │
397
- ┃ Claude Code │ phil@example.com │ Claude five-hour │ 12% │ 1h 2m │
398
- ┃ Claude Code │ phil@example.com │ Fable weekly │ 88% │ 2d 5h │
426
+ ┃ │ Max │ Claude five-hour │ 12% │ 1h 2m │
427
+ ┃ │ │ Fable weekly │ 88% │ 2d 5h │
399
428
  ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
400
429
  ┃ Codex │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │
401
- ┃ Codex │ alice@example.com │ Codex primary │ 37% │ 2h 13m │
402
- ┃ Codex │ alice@example.com │ Code review secondary │ 100% │ 3h 59m │
403
- ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
404
- ┃ Codex │ bob@example.com │ n/a │ n/a │ n/a │
430
+ ┃ │ Pro │ Codex primary │ 37% │ 2h 13m │
431
+ ┃ │ │ Code review secondary │ 100% │ 3h 59m │
432
+ ┃ ├─────────────────────────┼───────────────────────┼────────┼───────────────┤
433
+ ┃ │ bob@example.com │ n/a │ n/a │ n/a │
434
+ ┃ │ Plus, unverified │ │ │ │
405
435
  ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
406
436
  ┃ OpenCode / anthropic │ anthropic API key 20e75 │ n/a │ n/a │ n/a │
407
437
  ┃ │ 1c3b707 │ │ │ │
438
+ ┃ │ n/a │ │ │ │
408
439
  ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
409
440
  ┃ OpenCode / openai │ alice@example.com │ Codex primary │ 0% │ not scheduled │
441
+ ┃ │ Pro │ │ │ │
410
442
  └──────────────────────┴─────────────────────────┴───────────────────────┴────────┴───────────────┘
411
443
 
412
444
  n/a — Codex · bob@example.com: Subscription and limits: Login expired or was rejected.
@@ -427,6 +459,14 @@ session and the weekly total keeps its kind in parentheses
427
459
  (`Claude weekly (weekly_oauth)`). The provider column names the harness, qualified by
428
460
  the credential slot when a harness owns several (`OpenCode / openai`).
429
461
 
462
+ Each provider and each account is named once. The **Provider** cell spans all rows of
463
+ its provider, and the **Account** cell spans the rows of its account with two lines:
464
+ the account label, and beneath it, dimmed, the **account type** — the provider's own
465
+ plan name with its first letter capitalised (`Max`, `Pro`, `Prolite`). A type read
466
+ from the stored login because the live lookup failed is marked `unverified` (`Plus, unverified`), and
467
+ an account whose provider reports no plan, such as an API-key login, shows `n/a`. A
468
+ provider row without an account has no type line.
469
+
430
470
  **Used %** is coloured by the provider's own reading when it reports one: orange for
431
471
  `warning`, red for `critical`, uncoloured for `normal`. Without a reading, `n/a` is
432
472
  orange and an exhausted window (100% or more) is red. Colour is omitted when
@@ -435,7 +475,8 @@ columns.
435
475
 
436
476
  Rows are grouped in two independent ways. A divider separates one account from the
437
477
  next, so an account's limit types read as one block; a provider row without an
438
- account is a block of its own. A line along the left edge (`┃`) marks the harness:
478
+ account is a block of its own. Inside a provider's span the divider starts at the
479
+ **Account** column, and a change of provider draws a full rule. A line along the left edge (`┃`) marks the harness:
439
480
  all its rows share one colour, including every credential slot of an OpenCode
440
481
  installation, and the next harness takes the next colour (cyan, orange, green, pink,
441
482
  blue, then again from the start). Without colour the `┃` edge still shows where the
@@ -443,8 +484,9 @@ data rows are. Groups follow harness and account ids, never labels: two accounts
443
484
  that share an email are separate blocks, ordered by account id, and two harnesses
444
485
  that share a label are ordered by harness id. A harness is placed by its first
445
486
  provider name, so no other provider sorts between its slots. Below 40 columns the
446
- table becomes labelled lines; a rule line separates the accounts there, and no
447
- colour line is drawn.
487
+ table becomes labelled lines; every row repeats its provider and its account with
488
+ the type as a second line, a rule line separates the accounts, and no colour line is
489
+ drawn.
448
490
 
449
491
  `authswitch active` answers "what am I logged in as" for every harness at once:
450
492
 
@@ -502,7 +544,10 @@ arguments to these commands exit with 2.
502
544
 
503
545
  `limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
504
546
  the same rows as the tables, plus the machine-readable fields the tables condense —
505
- `accountId`, `slotId`, `scope`, `windowSeconds`, the ISO `resetAt` beside the human
547
+ `accountId`, `slotId`, `accountType` (`{ plan, source }` with the adapter's plan
548
+ name, such as `max` or `pro`, sanitised but not display-formatted, and `source` `live`
549
+ or `stored`; null when no type is known or the row names no account; both documents
550
+ carry it), `scope`, `windowSeconds`, the ISO `resetAt` beside the human
506
551
  `resetsIn`, the provider's `severity` (`normal`, `warning`, `critical`, or null when
507
552
  it gave none) and `headline` (true for the window the provider picks for a
508
553
  single-value summary), the ISO `savedAt` beside the human `savedAgo`, the per-row
@@ -595,7 +640,11 @@ process.exitCode = await cli.run(process.argv.slice(2));
595
640
 
596
641
  Each adapter owns discovery, identity matching, save verification, credential
597
642
  storage, switching and process lifecycle, live status APIs, and diagnostics.
598
- `readState()` and `readAccountStatus()` must be read-only. `switchAccount()` must
643
+ `readState()` and `readAccountStatus()` must be read-only. `readAccountStatus(accountId,
644
+ options)` receives an optional `IHarnessStatusOptions` whose `signal` cancels the
645
+ lookup's requests; authswitch aborts it when a status is no longer wanted, such as a
646
+ switch badge past its deadline or a closed dashboard, so an adapter should pass it to
647
+ every request it makes. `switchAccount()` must
599
648
  preserve and verify the outgoing credential without relying on the interactive
600
649
  save prompt. `removeAccount()` removes only the saved copy. Status facts carry
601
650
  provider-specific labels and units; unsupported capabilities are explicit. Adapter
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '3.2.0',
6
+ version: '3.3.0',
7
7
  description: 'Manage Codex, OpenCode and Claude Code accounts with guided switching and live usage status'
8
8
  }
package/ts/accounts.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessUsageWindow } from './interfaces.harness.js';
1
+ import type { IAccountType, IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessUsageWindow } from './interfaces.harness.js';
2
2
  import { plainText } from './formatting.js';
3
3
 
4
4
  export interface IAccountRow {
@@ -68,10 +68,24 @@ export const credentialDriftNote = (harnessIdArg: string, driftArg: IHarnessCred
68
68
  + (driftArg.expectedLabel === null ? 'Switch to the intended account again.'
69
69
  : `Run authswitch ${plainText(harnessIdArg)} use ${plainText(driftArg.expectedLabel)} again.`);
70
70
 
71
- export const accountPlan = (rowArg: IAccountRow): string => {
72
- const subscription = rowArg.status?.summary?.subscription;
73
- return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
71
+ /**
72
+ * How every view marks a plan that comes from the saved login because the provider did not confirm it: the
73
+ * word both earlier plan cells used, kept short so the type line of a limits cell does not wrap.
74
+ */
75
+ const STORED_PLAN_MARK = 'unverified';
76
+ /**
77
+ * How an account type reads in every view: the provider's plan name with its first letter capitalised
78
+ * (`max` reads `Max`), and a plan taken from the stored login marked `Plus, unverified`. The text holds no
79
+ * parentheses, so it reads the same inside a badge part. Nothing is invented: an unknown type is null, and
80
+ * each view decides how to show that.
81
+ */
82
+ export const accountTypeText = (typeArg: IAccountType | null | undefined): string | null => {
83
+ if (!typeArg) return null;
84
+ const plan = plainText(typeArg.plan);
85
+ return `${plan.charAt(0).toUpperCase()}${plan.slice(1)}${typeArg.source === 'stored' ? `, ${STORED_PLAN_MARK}` : ''}`;
74
86
  };
87
+ export const accountPlan = (rowArg: IAccountRow): string =>
88
+ accountTypeText(rowArg.status?.summary?.subscription) ?? (rowArg.status ? 'Unavailable' : 'Loading');
75
89
  type TAccountStatusRow = Pick<IAccountRow, 'status'>;
76
90
  /**
77
91
  * How a single-value summary names a window among the windows it shows. An account window is named by
@@ -113,6 +127,88 @@ export const accountQuotaSummary = (rowArg: TAccountStatusRow, nowArg: number):
113
127
  ...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usageWindowName(window, windows)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
114
128
  ] : [])].join('\n');
115
129
  };
130
+ /** The badge's usage meters: the general account windows a switch decision weighs, never a feature quota. */
131
+ const BADGE_WINDOWS = [['5h', 18000], ['w', 604800]] as const;
132
+ /**
133
+ * The account window a badge part reads: the only account window of that period, or the provider's headline
134
+ * pick among several. Any other choice would be a guess, so the part is left out and no meter reads as another's.
135
+ */
136
+ const badgeWindow = (windowsArg: readonly IHarnessUsageWindow[], secondsArg: number): IHarnessUsageWindow | undefined => {
137
+ const matches = windowsArg.filter(window => window.scope !== 'feature' && window.durationSeconds === secondsArg);
138
+ const candidates = matches.length > 1 ? matches.filter(window => window.headline === true) : matches;
139
+ return candidates.length === 1 ? candidates[0] : undefined;
140
+ };
141
+ /**
142
+ * What a switch activates, in one line: ` (type:Max) (5h:63%) (w:88%)`, appended to the account label.
143
+ *
144
+ * Each part is the provider's own reading, rounded to a whole percent, and is omitted when it was not
145
+ * reported; with no part known the badge is empty, so a missing reading never reads as zero usage.
146
+ */
147
+ export const accountBadge = (statusArg: IHarnessAccountStatus | undefined): string => {
148
+ const type = accountTypeText(statusArg?.summary?.subscription);
149
+ const windows = statusArg?.summary?.usageWindows ?? [];
150
+ return [
151
+ ...(type === null ? [] : [`type:${type}`]),
152
+ ...BADGE_WINDOWS.flatMap(([name, seconds]) => {
153
+ const window = badgeWindow(windows, seconds);
154
+ return window === undefined ? [] : [`${name}:${Math.round(window.usedPercent)}%`];
155
+ }),
156
+ ].map(part => ` (${part})`).join('');
157
+ };
158
+ /** How many accounts' statuses are read at once, so a long account list never floods a provider. */
159
+ const STATUS_LOOKUP_CONCURRENCY = 4;
160
+ /**
161
+ * Read-only, bounded status lookups: at most four accounts at a time, started in order, each handed the
162
+ * caller's signal. A lookup that fails reports undefined; once the signal aborts, no further lookup starts.
163
+ * A callback that throws rejects the call, and no other lookup starts after it.
164
+ */
165
+ const lookupStatuses = async (
166
+ harnessArg: IAuthHarness,
167
+ accountIdsArg: readonly string[],
168
+ optionsArg: { signal?: AbortSignal; onStatus: (indexArg: number, statusArg: IHarnessAccountStatus | undefined) => void | Promise<void> },
169
+ ): Promise<void> => {
170
+ let next = 0;
171
+ let failed = false;
172
+ const worker = async (): Promise<void> => {
173
+ while (!failed && next < accountIdsArg.length && !optionsArg.signal?.aborted) {
174
+ const index = next++;
175
+ let status: IHarnessAccountStatus | undefined;
176
+ try { status = await harnessArg.readAccountStatus(accountIdsArg[index], { signal: optionsArg.signal }); }
177
+ catch { status = undefined; }
178
+ try { await optionsArg.onStatus(index, status); }
179
+ catch (error) { failed = true; throw error; }
180
+ }
181
+ };
182
+ await Promise.all(Array.from({ length: Math.min(STATUS_LOOKUP_CONCURRENCY, accountIdsArg.length) }, worker));
183
+ };
184
+ /** How long a switch waits for the badges of the accounts it offers or activates. */
185
+ const ACCOUNT_BADGE_TIMEOUT_MS = 2500;
186
+ /**
187
+ * The badge of every named account whose status answered before one shared deadline.
188
+ *
189
+ * Read-only and never fatal: a switch must not wait on a provider past this deadline, nor fail because
190
+ * usage could not be read, so a lookup that fails or misses the deadline contributes no badge and the
191
+ * caller shows the label alone. At the deadline every lookup still running is cancelled, so no request
192
+ * outlives the call and keeps the command from exiting.
193
+ */
194
+ export const readAccountBadges = async (
195
+ harnessArg: IAuthHarness,
196
+ accountIdsArg: readonly string[],
197
+ timeoutMsArg = ACCOUNT_BADGE_TIMEOUT_MS,
198
+ ): Promise<Map<string, string>> => {
199
+ const badges = new Map<string, string>();
200
+ if (!accountIdsArg.length) return badges;
201
+ const cancel = new AbortController();
202
+ const lookups = lookupStatuses(harnessArg, accountIdsArg, { signal: cancel.signal, onStatus: (index, status) => {
203
+ if (status !== undefined && !cancel.signal.aborted) badges.set(accountIdsArg[index], accountBadge(status));
204
+ } });
205
+ // A referenced timer: the command must stay alive until the deadline even when a stalled lookup holds nothing open.
206
+ let timer: ReturnType<typeof setTimeout> | undefined;
207
+ const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, timeoutMsArg); });
208
+ try { await Promise.race([lookups, deadline]); }
209
+ finally { clearTimeout(timer); cancel.abort(); }
210
+ return badges;
211
+ };
116
212
  export const accountState = (rowArg: IAccountRow): string => `${rowArg.account.isActive ? '* active, ' : ''}${rowArg.account.isStashed ? 'saved' : 'not saved'}`;
117
213
  export const accountDetails = (rowArg: IAccountRow): string => [
118
214
  plainText(rowArg.account.label), accountState(rowArg),
@@ -122,7 +218,7 @@ export const accountDetails = (rowArg: IAccountRow): string => [
122
218
  ...(rowArg.status?.problems.map(problemArg => `Unavailable: ${plainText(problemArg)}`) ?? []),
123
219
  ].join('\n');
124
220
 
125
- /** Read-only, bounded parallel status lookup. No account is switched to inspect it. */
221
+ /** Read-only, bounded parallel status lookup. No account is switched to inspect it; the signal cancels lookups in flight. */
126
222
  export const readAccountRows = async (
127
223
  harnessArg: IAuthHarness,
128
224
  optionsArg: { signal?: AbortSignal; onUpdate?: (rowsArg: IAccountRow[], stateArg: IHarnessState) => void | Promise<void> } = {},
@@ -130,12 +226,9 @@ export const readAccountRows = async (
130
226
  const state = await harnessArg.readState();
131
227
  const rows = state.accounts.map(account => ({ harness: harnessArg, account } as IAccountRow));
132
228
  await optionsArg.onUpdate?.(rows, state);
133
- for (let index = 0; index < rows.length && !optionsArg.signal?.aborted; index += 4) {
134
- await Promise.all(rows.slice(index, index + 4).map(async row => {
135
- try { row.status = await harnessArg.readAccountStatus(row.account.id); }
136
- catch { row.status = { facts: [], problems: ['Could not read account status.'] }; }
137
- if (!optionsArg.signal?.aborted) await optionsArg.onUpdate?.(rows, state);
138
- }));
139
- }
229
+ await lookupStatuses(harnessArg, rows.map(row => row.account.id), { signal: optionsArg.signal, onStatus: async (index, status) => {
230
+ rows[index].status = status ?? { facts: [], problems: ['Could not read account status.'] };
231
+ if (!optionsArg.signal?.aborted) await optionsArg.onUpdate?.(rows, state);
232
+ } });
140
233
  return { rows, state };
141
234
  };
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { accountQuotaSummary, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
2
+ import { accountQuotaSummary, accountTypeText, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
3
3
  import type { IAuthHarness, IHarnessAccountStatus, IHarnessStatusFact } from './interfaces.harness.js';
4
4
  import type { IAccountList, IHarnessAccountList } from './interfaces.list.js';
5
5
  import { bold, dim, orange, plainText } from './formatting.js';
@@ -60,7 +60,8 @@ export const consoleHeading = (textArg: string): void => { process.stdout.write(
60
60
  * `groupsArg` is smartconsole's optional visual grouping: a divider between runs of consecutive rows with
61
61
  * different keys, and a colour line along the left edge per run. Keys are identities, never display labels,
62
62
  * and the caller orders the rows so that every group is one run. Below 40 columns the fallback separates
63
- * divider runs with one dim rule line; it has no left edge, so it draws no colour line.
63
+ * divider runs with one dim rule line; it has no left edge, so it draws no colour line, and it repeats a
64
+ * spanning cell on every row it covers. A column's `render` content is what both layouts show.
64
65
  */
65
66
  export const consoleTable = async <TRow>(
66
67
  outArg: plugins.smartconsole.SmartConsole,
@@ -74,9 +75,11 @@ export const consoleTable = async <TRow>(
74
75
  // smartconsole's run semantics: null and undefined are the same key.
75
76
  const dividerKeys = rowsArg.map(row => groupsArg?.divider?.(row) ?? null);
76
77
  const rule = ` ${dim('─'.repeat(Math.max(1, width - 4)))}\n\n`;
78
+ const content = (column: plugins.smartconsole.IBackendTableColumn<TRow>, row: TRow): string => column.render
79
+ ? plugins.smartconsole.color.plain(column.render(row)) : String(column.value(row) ?? 'Unavailable');
77
80
  rowsArg.forEach((row, index) => {
78
81
  if (index > 0 && dividerKeys[index] !== dividerKeys[index - 1]) process.stdout.write(rule);
79
- process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
82
+ process.stdout.write(columnsArg.map(column => ` ${column.title}: ${content(column, row).replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
80
83
  });
81
84
  return;
82
85
  }
@@ -179,7 +182,7 @@ export class AccountListRenderer {
179
182
  private async overview(accountsArg: TNamedAccount[], nowArg: number): Promise<void> {
180
183
  const rows = accountsArg;
181
184
  const state = (account: TListedAccount) => `${account.isActive ? '* active\n' : ''}${account.isStashed ? 'saved' : 'not saved'}${account.status.problems.length ? '\n! partial status' : ''}`;
182
- const plan = (account: TListedAccount) => account.status.summary?.subscription ? `${plainText(account.status.summary.subscription.plan)} (${account.status.summary.subscription.source})${account.status.summary.subscription.source === 'stored' ? '\nunverified' : ''}` : 'Unavailable';
185
+ const plan = (account: TListedAccount) => accountTypeText(account.status.summary?.subscription) ?? 'Unavailable';
183
186
  const billing = (account: TListedAccount) => {
184
187
  const info = account.status.summary?.billing;
185
188
  if (!info) return 'Unavailable';
@@ -4,7 +4,7 @@ import { CredentialStore, credentialRecord, credentialText, readCredentialDocume
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
6
6
  import { writeSecretFileAtomically } from './helpers.js';
7
- import type { IHarnessAccountStatus, IHarnessProcessControl } from './interfaces.harness.js';
7
+ import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
8
8
 
9
9
  export interface IClaudeCodeHarnessOptions {
10
10
  configDir?: string;
@@ -107,8 +107,8 @@ export class ClaudeCodeHarness extends FileHarness {
107
107
  throw new Error('Claude Code switch failed while updating account metadata. The outgoing login remains saved; check doctor before restarting.');
108
108
  }
109
109
  }
110
- public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
110
+ public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
111
111
  const account = this.readCredential(id);
112
- return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount));
112
+ return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount), optionsArg);
113
113
  }
114
114
  }
@@ -1,6 +1,6 @@
1
1
  import { credentialRecord, credentialText } from './classes.credentialstore.js';
2
2
  import { plainText } from './formatting.js';
3
- import type { IHarnessAccountStatus, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
3
+ import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
4
4
 
5
5
  class ClaudeStatusError extends Error {}
6
6
 
@@ -105,8 +105,9 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
105
105
  /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
106
106
  export class ClaudeAccountStatus {
107
107
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
108
- private async get(route: 'usage' | 'profile', accessToken: string): Promise<Record<string, unknown>> {
109
- const signal = AbortSignal.timeout(10_000);
108
+ private async get(route: 'usage' | 'profile', accessToken: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
109
+ const timeout = AbortSignal.timeout(10_000);
110
+ const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
110
111
  try {
111
112
  const response = await this.fetcher(`https://api.anthropic.com/api/oauth/${route}`, {
112
113
  method: 'GET', redirect: 'error', signal,
@@ -133,12 +134,12 @@ export class ClaudeAccountStatus {
133
134
  try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
134
135
  catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
135
136
  } catch (error) {
136
- if (signal.aborted) throw new ClaudeStatusError('The Claude account request timed out.');
137
+ if (signal.aborted) throw new ClaudeStatusError(timeout.aborted ? 'The Claude account request timed out.' : 'The Claude account request was cancelled.');
137
138
  if (error instanceof ClaudeStatusError) throw error;
138
139
  throw new ClaudeStatusError('The Claude account service could not be reached.');
139
140
  }
140
141
  }
141
- public async read(oauth: Record<string, unknown>, account: Record<string, unknown>): Promise<IHarnessAccountStatus> {
142
+ public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
142
143
  const result: IHarnessAccountStatus = { facts: [], problems: [] };
143
144
  const storedPlan = credentialText(oauth.subscriptionType);
144
145
  if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
@@ -148,7 +149,7 @@ export class ClaudeAccountStatus {
148
149
  }
149
150
  const token = credentialText(oauth.accessToken);
150
151
  if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
151
- const [profile, usage] = await Promise.allSettled([this.get('profile', token), this.get('usage', token)]);
152
+ const [profile, usage] = await Promise.allSettled([this.get('profile', token, optionsArg.signal), this.get('usage', token, optionsArg.signal)]);
152
153
  if (profile.status === 'fulfilled') {
153
154
  try {
154
155
  const user = credentialRecord(profile.value.account);
package/ts/classes.cli.ts CHANGED
@@ -7,7 +7,7 @@ import { AuthSwitchTui } from './classes.tui.js';
7
7
  import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
8
  import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
9
9
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
10
- import { credentialDriftNote, duration, orderedUsageWindows, until } from './accounts.js';
10
+ import { credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until } from './accounts.js';
11
11
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
12
12
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
13
13
  import type { CodexSwitcher } from './classes.codexswitcher.js';
@@ -25,6 +25,18 @@ const STOP_COMMANDS = ['use', 'stash'];
25
25
  /** Read-only overviews that work across every registered harness and support --json. */
26
26
  const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
27
27
 
28
+ /** An account as prompts and reports name it: its label, qualified by its credential slot when it has one. */
29
+ const accountName = (accountArg: IHarnessAccount): string =>
30
+ `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
31
+
32
+ /** The account a command acts on, named as it was offered, with the badge it was offered with. */
33
+ interface IChosenAccount {
34
+ id: string;
35
+ name: string;
36
+ /** ` (type:Max) (5h:63%) (w:88%)`, empty when no part is known, or null when it was not read. */
37
+ badge: string | null;
38
+ }
39
+
28
40
  /** Harness-independent command routing and interactive account management. */
29
41
  export class AuthSwitchCli {
30
42
  private readonly harnesses = new Map<string, IAuthHarness>();
@@ -406,7 +418,7 @@ ${bold('Environment')}
406
418
  for (const current of state.accounts.filter(account => account.isActive && !account.isStashed)) {
407
419
  const answer = await this.out.prompts.ask({
408
420
  name: 'saveCurrent', type: 'confirm',
409
- message: `${plainText(current.label)}${current.slotId ? ` (${plainText(current.slotId)})` : ''} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
421
+ message: `${accountName(current)} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
410
422
  });
411
423
  if (answer !== true) return 'declined';
412
424
  if (this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: true, accountId: current.id })) !== 0) return 'failed';
@@ -457,12 +469,12 @@ ${bold('Environment')}
457
469
  case 'list': code = await this.commandList([harnessArg]); break;
458
470
  case 'doctor': code = this.reportOutcome(await harnessArg.diagnose()); break;
459
471
  case 'drop': {
460
- const id = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, true, true);
461
- if (!id) break;
472
+ const target = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, { allowBack: true, includeIncomplete: true });
473
+ if (!target) break;
462
474
  const confirmation = await this.out.prompts.ask({
463
- name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(id)}? The active login stays as it is.`, default: false,
475
+ name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(target.id)}? The active login stays as it is.`, default: false,
464
476
  });
465
- if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(id));
477
+ if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(target.id));
466
478
  break;
467
479
  }
468
480
  case 'harness': return null;
@@ -492,7 +504,7 @@ ${bold('Environment')}
492
504
  if (accounts.length === 1) return accounts[0];
493
505
  if (!accounts.length) { process.stderr.write('No matching active login to save.\n'); return undefined; }
494
506
  if (!canPrompt()) { process.stderr.write(`Several logins are active. Use authswitch ${harness.id} stash <account/provider> [--keep].\n`); return undefined; }
495
- const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: `${plainText(account.label)}${account.slotId ? ` (${plainText(account.slotId)})` : ''}`, value: account.id })) });
507
+ const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: accountName(account), value: account.id })) });
496
508
  return accounts.find(account => account.id === id);
497
509
  }
498
510
 
@@ -538,6 +550,14 @@ ${bold('Environment')}
538
550
  return null;
539
551
  }
540
552
 
553
+ /**
554
+ * Activate a saved account and report what is now active with its badge.
555
+ *
556
+ * The badge is read once per command through the bounded status read: the chooser reads it before it
557
+ * offers the accounts and the report repeats it, while a target named on the command line is read only
558
+ * after its switch, so nothing waits on it. A read that fails or does not answer in time leaves the name
559
+ * alone and never decides the outcome.
560
+ */
541
561
  private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false, stopModeArg?: TStopMode): Promise<number> {
542
562
  let id: string | null = null;
543
563
  if (referenceArg) {
@@ -548,21 +568,42 @@ ${bold('Environment')}
548
568
  const offered = await this.offerToSave(harnessArg);
549
569
  if (offered !== 'ready') return offered === 'failed' ? 1 : 0;
550
570
  }
551
- id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
552
- if (id === null) return allowBackArg ? 0 : 1;
553
- return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }, stopModeArg));
571
+ const target = id === null
572
+ ? await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, { allowBack: allowBackArg, withBadges: true })
573
+ : await this.namedAccount(harnessArg, id);
574
+ if (target === null) return allowBackArg ? 0 : 1;
575
+ const code = this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: target.id }, stopModeArg));
576
+ if (code !== 0) return code;
577
+ const badge = target.badge ?? (await readAccountBadges(harnessArg, [target.id])).get(target.id) ?? '';
578
+ process.stdout.write(`Now active: ${bold(target.name)}${badge}\n`);
579
+ return code;
580
+ }
581
+
582
+ /**
583
+ * A switch target named on the command line; its badge is not read here, so the switch never waits on it.
584
+ *
585
+ * The reference already resolved to this id, so the name is presentation only: a state read that fails
586
+ * names the account by its id and leaves the switch, which reports any real credential problem, to run.
587
+ */
588
+ private async namedAccount(harnessArg: IAuthHarness, idArg: string): Promise<IChosenAccount> {
589
+ let account: IHarnessAccount | undefined;
590
+ try { account = (await harnessArg.readState()).accounts.find(item => item.id === idArg); }
591
+ catch { account = undefined; }
592
+ return { id: idArg, name: account ? accountName(account) : plainText(idArg), badge: null };
554
593
  }
555
594
 
556
- private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, allowBackArg: boolean, includeIncompleteArg = false): Promise<string | null> {
557
- const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (includeIncompleteArg && accountArg.savedAt !== null));
595
+ private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, optionsArg: { allowBack?: boolean; includeIncomplete?: boolean; withBadges?: boolean } = {}): Promise<IChosenAccount | null> {
596
+ const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (optionsArg.includeIncomplete === true && accountArg.savedAt !== null));
558
597
  if (accounts.length === 0) { process.stderr.write(`${red('no stashed accounts')} - run \`authswitch ${harnessArg.id} stash\` first\n`); return null; }
559
598
  if (!canPrompt()) { process.stderr.write(`${red('an account is required')} without an interactive terminal: authswitch ${harnessArg.id} use <account>\n`); return null; }
599
+ const badges = optionsArg.withBadges ? await readAccountBadges(harnessArg, accounts.map((accountArg) => accountArg.id)) : null;
600
+ const offered = accounts.map((accountArg): IChosenAccount => ({ id: accountArg.id, name: accountName(accountArg), badge: badges === null ? null : badges.get(accountArg.id) ?? '' }));
560
601
  const answer = await this.out.prompts.ask({
561
602
  name: 'account', type: 'list', message: messageArg,
562
603
  default: accounts.find((accountArg) => accountArg.isActive)?.id ?? accounts[0].id,
563
- choices: [...accounts.map((accountArg) => ({ name: `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}${accountArg.isActive ? ' (active)' : ''}`, value: accountArg.id })),
564
- ...(allowBackArg ? [{ name: 'Back', value: null }] : [])],
604
+ choices: [...accounts.map((accountArg, index) => ({ name: `${offered[index].name}${accountArg.isActive ? ' (active)' : ''}${offered[index].badge ?? ''}`, value: accountArg.id })),
605
+ ...(optionsArg.allowBack ? [{ name: 'Back', value: null }] : [])],
565
606
  });
566
- return typeof answer === 'string' ? answer : null;
607
+ return offered.find((accountArg) => accountArg.id === answer) ?? null;
567
608
  }
568
609
  }
@@ -2,7 +2,7 @@ import { CodexSwitcher } from './classes.codexswitcher.js';
2
2
  import { CodexAccountStatus } from './classes.codexstatus.js';
3
3
  import { CodexPreuse } from './classes.codexpreuse.js';
4
4
  import { PreuseError } from './preuse.js';
5
- import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
5
+ import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessStatusOptions } from './interfaces.harness.js';
6
6
  import type { IStashListing } from './interfaces.js';
7
7
  import { countPaired, enrollmentState } from './helpers.js';
8
8
  import { beginSavedOpenAiLogin } from './classes.login.js';
@@ -70,10 +70,10 @@ export class CodexHarness implements IAuthHarness {
70
70
  };
71
71
  }
72
72
 
73
- public async readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus> {
73
+ public async readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
74
74
  const credential = this.readAccountCredential(accountIdArg);
75
75
  if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };
76
- return this.accountStatus.read(credential.raw, credential.identity);
76
+ return this.accountStatus.read(credential.raw, credential.identity, optionsArg);
77
77
  }
78
78
 
79
79
  public async preuseAccount(accountIdArg: string, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult> {