@modelprofile.com/authswitch 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -188,7 +188,21 @@ the local file login, so launch-time flags, managed policies and credentials sup
188
188
  by embedding applications remain under Claude Code's control.
189
189
 
190
190
  Claude's direct OAuth profile and usage lookups report the plan, weekly and five-hour
191
- usage, model-specific windows, and available extra-usage budget information.
191
+ usage, per-model and per-surface windows, and available extra-usage budget information.
192
+ The usage response's `limits[]` rows are the authoritative window list: which
193
+ meters apply, their scope, the service's `severity` (`normal`, `warning`,
194
+ `critical`) and its headline pick are Claude's, so a new per-model weekly limit
195
+ such as **Fable weekly** appears without an authswitch release. `list --json` keeps
196
+ the rows in the service's order; the tables and `limits --json` order them as
197
+ described under **Condensed views**. Rows are classified on their
198
+ kind and reset group exactly as sent. Labels combine the service's model or surface
199
+ name with the window period, and such a scoped row is treated as a feature limit.
200
+ Rows in a reset group authswitch cannot represent (anything but the five-hour
201
+ session and the weekly window) are not shown; one **Unsupported limits**
202
+ availability fact names them. A malformed row, or a `limits` value that is not a
203
+ list, makes the whole usage lookup unavailable rather than partially guessed.
204
+ Responses without `limits[]` rows fall back to the older keyed weekly, five-hour
205
+ and model windows.
192
206
  These endpoints require the `user:profile` scope and may reject expired or restricted
193
207
  logins. A null reset timestamp is shown as **Not scheduled**. Billing renewal and
194
208
  cancellation dates and earned reset credits are unavailable from these endpoints;
@@ -240,7 +254,8 @@ an uncertain request. `--json` remains a list-only option.
240
254
  ### Management dashboard
241
255
 
242
256
  `authswitch --tui` opens a resizable account table with subscription, usage and
243
- reset availability, usage bars, scrollable account details and an activity log.
257
+ reset availability, usage bars for the lead window and the next general window,
258
+ scrollable account details and an activity log.
244
259
  With multiple registered adapters, a harness selector switches the view; account
245
260
  selection and mutations always belong to the displayed harness. Status loads
246
261
  incrementally through each account's read-only API, without activating it.
@@ -322,17 +337,25 @@ output to Codex. Each section includes saved accounts and an identifiable active
322
337
  login even if it has never been saved. `*` marks the active account, and the saved
323
338
  marker checks the credential itself rather than just the existence of metadata.
324
339
 
325
- The overview compares login state, plan provenance, weekly usage, earned resets and
326
- the weekly reset countdown. Countdowns use days, hours and minutes, such as
340
+ The overview compares login state, plan provenance, lead-window usage (the
341
+ provider's headline window, otherwise weekly), earned resets and that window's
342
+ reset countdown. Countdowns use days, hours and minutes, such as
327
343
  `7d 5h 6min`;
328
344
  `<1min` means less than a minute remains and `due` means the reported deadline has
329
345
  passed, without claiming the service has refreshed the quota. All countdowns share
330
346
  the same snapshot time.
331
- Usage and countdown refer to the same general account window, preferring weekly.
332
- If no weekly window is reported, the longest general window is shown with its
333
- actual duration. Other exhausted general windows retain their own reset warning.
334
- Feature limits such as Spark and code review have a separate table and never
335
- determine the general summary. The TUI uses the same weekly-first selection.
347
+ Usage and countdown refer to the same window. When the provider picks a headline
348
+ window (Claude does, for example its Fable weekly limit), that window leads.
349
+ Otherwise the
350
+ general account window leads, preferring weekly; if no weekly window is reported,
351
+ the longest general window is shown with its actual duration. Other exhausted
352
+ general windows retain their own reset warning. A general window is named by its
353
+ period (`Weekly`, `5h`) unless another shown general window has the same period; a
354
+ feature limit, or a general window sharing its period, is named by its label
355
+ (`Fable weekly: 100% used`, `Claude weekly (weekly_oauth) exhausted`). Feature
356
+ limits such as Spark and code review have a separate table and determine the
357
+ summary only when the provider picks one as its headline. The TUI uses the same
358
+ selection and names.
336
359
  Window durations come from the provider, not from the plan name or
337
360
  primary/secondary position; a Pro account with only a general weekly window gets
338
361
  no invented 5-hour limit.
@@ -340,34 +363,50 @@ no invented 5-hour limit.
340
363
  Email addresses identify Codex accounts throughout the overview, usage/reset
341
364
  schedules, earned reset expiry details, grouped credits and activity metrics,
342
365
  saved-login notes and availability/actions tables. Other harnesses use their
343
- account labels. Provider facts already represented by structured fields are not
344
- repeated. Narrow terminals use compact account sections. Missing
366
+ account labels. When accounts of one harness share a label, every table numbers
367
+ the later ones (`alice@example.com (2)`), so each account keeps its own column in
368
+ the metric comparisons and reads the same everywhere. Provider facts already
369
+ represented by structured fields are not repeated. Narrow terminals use compact
370
+ account sections. Missing
345
371
  data stays Unavailable or Not reported; known zeroes remain zero. Stored plans are
346
372
  marked unverified. Renewal/cancellation dates are shown only when an adapter can
347
373
  provide live billing data. Codex uses the desktop app's account-check endpoint for
348
374
  automatic renewal, explicit renewal/cancellation dates and subscription expiry.
349
375
 
376
+ Tables that list an account on several rows (the usage/reset schedule,
377
+ feature-specific limits, earned reset details, and availability/actions) draw a
378
+ divider between one account and the next. The divider follows the account id, not
379
+ the email, so two accounts that share an address stay separate blocks, and the
380
+ harness-level availability row is a block of its own. The overview and the metric
381
+ comparisons show each account once and have no dividers. Each `list` section covers
382
+ one harness, so no colour line is drawn. Narrow terminals separate the same blocks
383
+ with a rule line.
384
+
350
385
  ### Condensed views
351
386
 
352
387
  `authswitch limits` answers "how much is left, and when does it come back" in one
353
388
  table. It is one row per account **and** limit type, sorted by provider and then by
354
- account email, with a two-unit countdown (`2h 13m`, `3d 4h`, `45m`, `<1m`, `due`):
389
+ account email with each harness and each account kept together, and a two-unit countdown (`2h 13m`, `3d 4h`, `45m`, `<1m`, `due`):
355
390
 
356
391
  ```
357
392
  Account limits
358
393
  ┌──────────────────────┬─────────────────────────┬───────────────────────┬────────┬───────────────┐
359
394
  │ Provider │ Account │ Limit type │ Used % │ Resets in │
360
395
  ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
361
- │ Claude Code │ phil@example.com │ Claude weekly │ 88% │ 4d 3h │
362
- │ Claude Code │ phil@example.com │ Claude five-hour │ 63% │ 1h 2m │
363
- │ Claude Code │ phil@example.com │ Opus weekly │ 100% │ 4d 3h │
364
- │ Codex │ alice@example.com │ Codex secondary │ 12% │ 3d 4h │
365
- │ Codex │ alice@example.com │ Codex primary │ 37% │ 2h 13m │
366
- │ Codex │ alice@example.com │ Code review secondary │ 100% │ 3h 59m │
367
- │ Codex │ bob@example.com │ n/a │ n/a │ n/a │
368
- │ OpenCode / anthropic │ anthropic API key 20e75 │ n/a │ n/a │ n/a │
369
- │ │ 1c3b707 │ │ │ │
370
- │ OpenCode / openai │ alice@example.com │ Codex primary │ 0% │ not scheduled │
396
+ ┃ 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 │
399
+ ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
400
+ ┃ 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 │
405
+ ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
406
+ ┃ OpenCode / anthropic │ anthropic API key 20e75 │ n/a │ n/a │ n/a │
407
+ ┃ │ 1c3b707 │ │ │ │
408
+ ├──────────────────────┼─────────────────────────┼───────────────────────┼────────┼───────────────┤
409
+ ┃ OpenCode / openai │ alice@example.com │ Codex primary │ 0% │ not scheduled │
371
410
  └──────────────────────┴─────────────────────────┴───────────────────────┴────────┴───────────────┘
372
411
 
373
412
  n/a — Codex · bob@example.com: Subscription and limits: Login expired or was rejected.
@@ -382,9 +421,31 @@ account never blanks another's numbers. `not scheduled` means the provider repor
382
421
  usage without a reset deadline; it is not the same as `n/a`.
383
422
 
384
423
  Limit types are the provider's own window names, ordered general-before-feature and
385
- weekly-first within an account. The provider column names the harness, qualified by
424
+ weekly-first within an account. Claude's per-model and per-surface limits carry the
425
+ service's name (`Fable weekly`); an unscoped Claude meter other than the five-hour
426
+ session and the weekly total keeps its kind in parentheses
427
+ (`Claude weekly (weekly_oauth)`). The provider column names the harness, qualified by
386
428
  the credential slot when a harness owns several (`OpenCode / openai`).
387
429
 
430
+ **Used %** is coloured by the provider's own reading when it reports one: orange for
431
+ `warning`, red for `critical`, uncoloured for `normal`. Without a reading, `n/a` is
432
+ orange and an exhausted window (100% or more) is red. Colour is omitted when
433
+ `NO_COLOR` is set, output is not a terminal, or the terminal is narrower than 40
434
+ columns.
435
+
436
+ Rows are grouped in two independent ways. A divider separates one account from the
437
+ 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:
439
+ all its rows share one colour, including every credential slot of an OpenCode
440
+ installation, and the next harness takes the next colour (cyan, orange, green, pink,
441
+ blue, then again from the start). Without colour the `┃` edge still shows where the
442
+ data rows are. Groups follow harness and account ids, never labels: two accounts
443
+ that share an email are separate blocks, ordered by account id, and two harnesses
444
+ that share a label are ordered by harness id. A harness is placed by its first
445
+ 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.
448
+
388
449
  `authswitch active` answers "what am I logged in as" for every harness at once:
389
450
 
390
451
  ```
@@ -392,11 +453,11 @@ Active accounts
392
453
  ┌──────────────────────┬────────────────────────────────┬────────────┬─────────────────┐
393
454
  │ Provider │ Account │ Saved │ Source │
394
455
  ├──────────────────────┼────────────────────────────────┼────────────┼─────────────────┤
395
- │ Claude Code │ phil@example.com │ 45m ago │ credential file │
396
- │ Codex │ alice@example.com │ 2h 13m ago │ credential file │
397
- │ Flex │ none │ n/a │ none │
398
- │ OpenCode / anthropic │ anthropic API key 20e751c3b707 │ 30d ago │ credential file │
399
- │ OpenCode / openai │ alice@example.com │ not saved │ credential file │
456
+ ┃ Claude Code │ phil@example.com │ 45m ago │ credential file │
457
+ ┃ Codex │ alice@example.com │ 2h 13m ago │ credential file │
458
+ ┃ Flex │ none │ n/a │ none │
459
+ ┃ OpenCode / anthropic │ anthropic API key 20e751c3b707 │ 30d ago │ credential file │
460
+ ┃ OpenCode / openai │ alice@example.com │ not saved │ credential file │
400
461
  └──────────────────────┴────────────────────────────────┴────────────┴─────────────────┘
401
462
 
402
463
  note — OpenCode / openai · alice@example.com: This active login is not saved yet; authswitch cannot restore it after a switch.
@@ -407,6 +468,8 @@ own live credential, `stash only` means nothing is active and only saved copies
407
468
  exist, `none` means neither. **Saved** is how long ago `authswitch` last saved that
408
469
  login, or `not saved` — which is also the warning that a switch could not bring it
409
470
  back. A harness with no active login keeps its row and explains itself in a note.
471
+ Each harness has its own colour line, ordered and coloured as in `limits`; the
472
+ table has no dividers, since every row is a different credential slot.
410
473
 
411
474
  **Source** also answers whether the file still holds what the last switch wrote.
412
475
  When authswitch switches a login it records that account and the hash of the
@@ -440,7 +503,9 @@ arguments to these commands exit with 2.
440
503
  `limits --json` emits `IAccountLimits` and `active --json` emits `IActiveAccounts`:
441
504
  the same rows as the tables, plus the machine-readable fields the tables condense —
442
505
  `accountId`, `slotId`, `scope`, `windowSeconds`, the ISO `resetAt` beside the human
443
- `resetsIn`, the ISO `savedAt` beside the human `savedAgo`, the per-row
506
+ `resetsIn`, the provider's `severity` (`normal`, `warning`, `critical`, or null when
507
+ it gave none) and `headline` (true for the window the provider picks for a
508
+ single-value summary), the ISO `savedAt` beside the human `savedAgo`, the per-row
444
509
  `unavailableReason` that the footnotes summarise, and `drift`, which carries the
445
510
  expected and current account ids, their labels, whether the current one is saved,
446
511
  the ISO `switchedAt` of the switch that was undone, and the same sentence as `reason`. Both carry `schemaVersion: 1`, the
@@ -456,7 +521,9 @@ The exported `IAccountList` contract contains:
456
521
  empty list).
457
522
  - Each account's opaque `id`, `label`, `isActive`, verified `isStashed`, `savedAt`,
458
523
  `details`, and `status` containing all labelled `facts`, `problems`, and optional
459
- typed `summary` fields. Missing fields remain omitted, not replaced by zero.
524
+ typed `summary` fields. Missing fields remain omitted, not replaced by zero;
525
+ `summary.usageWindows[]` carries `severity` and `headline` only when the provider
526
+ reported them.
460
527
 
461
528
  JSON retains full fact strings and absolute ISO reset/expiry timestamps. It exports
462
529
  the credential-free adapter contract, never credentials, raw HTTP responses or
@@ -561,11 +628,18 @@ explicit zeroes remain zero. A successful reset-detail lookup supplies the summa
561
628
  count; if it fails, the limits response can still supply availability.
562
629
 
563
630
  Each usage window may set `scope: 'account' | 'feature'` (default `account`).
564
- Adapters mark model-specific or feature-specific quotas as `feature` so those
565
- quotas remain available in JSON and details without distorting the general
566
- account summary. Weekly windows are identified by `durationSeconds: 604800`;
631
+ Adapters mark model-specific or feature-specific quotas as `feature`; those quotas
632
+ appear in the feature tables, JSON and details and lead the account summary only
633
+ when the provider marks one as its headline (see below). Weekly windows are identified by `durationSeconds: 604800`;
567
634
  neither window labels nor plan names are parsed to infer limits.
568
635
 
636
+ A window may also carry the provider's own reading: `severity?: TUsageSeverity`
637
+ (`'normal' | 'warning' | 'critical'`) and `headline?: true` for the window the
638
+ provider picks for a single-value summary. Adapters set them only when the provider
639
+ reports them, never from local thresholds. A headline window leads the account
640
+ summary whatever its scope; the first one in the adapter's order wins if several are
641
+ marked.
642
+
569
643
  Optional `summary.billing` contains provider-reported `hasActiveSubscription`,
570
644
  `autoRenew`, `renewsAt`, `cancelsAt`, and `expiresAt`, independent of the plan name.
571
645
  Dates are ISO UTC and omitted
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/authswitch',
6
- version: '3.0.0',
6
+ version: '3.2.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, IHarnessStatusSummary } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessState, IHarnessUsageWindow } from './interfaces.harness.js';
2
2
  import { plainText } from './formatting.js';
3
3
 
4
4
  export interface IAccountRow {
@@ -72,20 +72,34 @@ export const accountPlan = (rowArg: IAccountRow): string => {
72
72
  const subscription = rowArg.status?.summary?.subscription;
73
73
  return subscription ? `${plainText(subscription.plan)}${subscription.source === 'stored' ? ' (stored; unverified)' : ' (live)'}` : rowArg.status ? 'Unavailable' : 'Loading';
74
74
  };
75
- type TUsageWindow = NonNullable<IHarnessStatusSummary['usageWindows']>[number];
76
75
  type TAccountStatusRow = Pick<IAccountRow, 'status'>;
77
- export const usagePeriod = (windowArg: TUsageWindow): string => windowArg.durationSeconds === 604800 ? 'Weekly' : duration(windowArg.durationSeconds);
76
+ /**
77
+ * How a single-value summary names a window among the windows it shows. An account window is named by
78
+ * its period while it is the only shown account window of that period; a feature window, or an account
79
+ * window sharing its period with another, is named by its own label, so no meter reads as another's.
80
+ */
81
+ export const usageWindowName = (windowArg: IHarnessUsageWindow, shownArg: readonly IHarnessUsageWindow[]): string =>
82
+ windowArg.scope === 'feature' || shownArg.some(other => other !== windowArg && other.scope !== 'feature' && other.durationSeconds === windowArg.durationSeconds)
83
+ ? plainText(windowArg.label)
84
+ : windowArg.durationSeconds === 604800 ? 'Weekly' : duration(windowArg.durationSeconds);
78
85
  /** Order by scope and actual duration: a provider's primary slot need not be five hours. */
79
- export const orderedUsageWindows = (windowsArg: readonly TUsageWindow[] = []): TUsageWindow[] => [...windowsArg].sort((left, right) =>
86
+ export const orderedUsageWindows = (windowsArg: readonly IHarnessUsageWindow[] = []): IHarnessUsageWindow[] => [...windowsArg].sort((left, right) =>
80
87
  Number(left.scope === 'feature') - Number(right.scope === 'feature') ||
81
88
  Number(left.durationSeconds !== 604800) - Number(right.durationSeconds !== 604800) || right.durationSeconds - left.durationSeconds);
82
- export const accountUsageWindows = (rowArg: TAccountStatusRow): TUsageWindow[] | undefined => {
89
+ /**
90
+ * The windows a single-value summary shows, lead window first: the provider's headline pick, whatever
91
+ * its scope, then the account windows in display order. Other feature quotas never lead a summary.
92
+ */
93
+ export const accountUsageWindows = (rowArg: TAccountStatusRow): IHarnessUsageWindow[] | undefined => {
83
94
  const windows = rowArg.status?.summary?.usageWindows;
84
- return windows === undefined ? undefined : orderedUsageWindows(windows.filter(window => window.scope !== 'feature'));
95
+ if (windows === undefined) return undefined;
96
+ const headline = windows.find(window => window.headline === true);
97
+ const accountWindows = orderedUsageWindows(windows.filter(window => window !== headline && window.scope !== 'feature'));
98
+ return headline === undefined ? accountWindows : [headline, ...accountWindows];
85
99
  };
86
100
  export const accountUsage = (rowArg: TAccountStatusRow): string => {
87
101
  const windows = accountUsageWindows(rowArg);
88
- return windows?.length ? `${usagePeriod(windows[0])}: ${windows[0].usedPercent}% used` : windows ? 'None reported' : rowArg.status ? 'Unavailable' : 'Loading';
102
+ return windows?.length ? `${usageWindowName(windows[0], windows)}: ${windows[0].usedPercent}% used` : windows ? 'None reported' : rowArg.status ? 'Unavailable' : 'Loading';
89
103
  };
90
104
  export const accountResets = (rowArg: IAccountRow): string => rowArg.status?.summary?.resets === undefined ? rowArg.status ? 'Unavailable' : 'Loading' : String(rowArg.status.summary.resets.available);
91
105
  export const accountNextReset = (rowArg: TAccountStatusRow, nowArg = Date.now()): string => {
@@ -96,7 +110,7 @@ export const accountQuotaSummary = (rowArg: TAccountStatusRow, nowArg: number):
96
110
  const windows = accountUsageWindows(rowArg);
97
111
  return [accountUsage(rowArg), ...(windows?.length ? [
98
112
  `Reset in: ${accountNextReset(rowArg, nowArg)}`,
99
- ...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usagePeriod(window)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
113
+ ...windows.slice(1).filter(window => window.usedPercent >= 100).map(window => `${usageWindowName(window, windows)} exhausted\nResets in: ${until(window.resetAt, nowArg)}`),
100
114
  ] : [])].join('\n');
101
115
  };
102
116
  export const accountState = (rowArg: IAccountRow): string => `${rowArg.account.isActive ? '* active, ' : ''}${rowArg.account.isStashed ? 'saved' : 'not saved'}`;
@@ -5,6 +5,7 @@ import type { IAccountList, IHarnessAccountList } from './interfaces.list.js';
5
5
  import { bold, dim, orange, plainText } from './formatting.js';
6
6
 
7
7
  type TListedAccount = IHarnessAccountList['accounts'][number];
8
+ type TNamedAccount = TListedAccount & { name: string };
8
9
  interface IDisplayFact { account: number; label: string; value: string; }
9
10
 
10
11
  /** Allowlist the public data contract: never serialize a harness or its credential-bearing internals. */
@@ -16,7 +17,7 @@ const publicStatus = (statusArg: IHarnessAccountStatus): IHarnessAccountStatus =
16
17
  ...(summary === undefined ? {} : { summary: {
17
18
  ...(summary.subscription === undefined ? {} : { subscription: { plan: summary.subscription.plan, source: summary.subscription.source } }),
18
19
  ...(summary.billing === undefined ? {} : { billing: { hasActiveSubscription: summary.billing.hasActiveSubscription, autoRenew: summary.billing.autoRenew, renewsAt: summary.billing.renewsAt, cancelsAt: summary.billing.cancelsAt, expiresAt: summary.billing.expiresAt } }),
19
- ...(summary.usageWindows === undefined ? {} : { usageWindows: summary.usageWindows.map(({ label, scope, durationSeconds, usedPercent, resetAt }) => ({ label, scope, durationSeconds, usedPercent, resetAt })) }),
20
+ ...(summary.usageWindows === undefined ? {} : { usageWindows: summary.usageWindows.map(({ label, scope, durationSeconds, usedPercent, resetAt, severity, headline }) => ({ label, scope, durationSeconds, usedPercent, resetAt, severity, headline })) }),
20
21
  ...(summary.resets === undefined ? {} : { resets: {
21
22
  available: summary.resets.available,
22
23
  ...(summary.resets.details === undefined ? {} : { details: summary.resets.details.map(({ status, kind, expiresAt }) => ({ status, kind, expiresAt })) }),
@@ -53,20 +54,57 @@ export const readAccountList = async (harnessesArg: IAuthHarness[]): Promise<IAc
53
54
  export const consoleWidth = (): number => process.stdout.columns ?? 100;
54
55
  export const consoleHeading = (textArg: string): void => { process.stdout.write(`\n${bold(textArg)}\n`); };
55
56
 
56
- /** One table implementation for every command, including the label-per-line fallback on narrow terminals. */
57
+ /**
58
+ * One table implementation for every command, including the label-per-line fallback on narrow terminals.
59
+ *
60
+ * `groupsArg` is smartconsole's optional visual grouping: a divider between runs of consecutive rows with
61
+ * different keys, and a colour line along the left edge per run. Keys are identities, never display labels,
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.
64
+ */
57
65
  export const consoleTable = async <TRow>(
58
66
  outArg: plugins.smartconsole.SmartConsole,
59
67
  rowsArg: TRow[],
60
68
  columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
69
+ groupsArg?: NoInfer<plugins.smartconsole.IBackendTableGroups<TRow>>,
61
70
  ): Promise<void> => {
62
71
  if (!rowsArg.length) return;
63
- if (consoleWidth() < 40) {
64
- for (const row of rowsArg) {
72
+ const width = consoleWidth();
73
+ if (width < 40) {
74
+ // smartconsole's run semantics: null and undefined are the same key.
75
+ const dividerKeys = rowsArg.map(row => groupsArg?.divider?.(row) ?? null);
76
+ const rule = ` ${dim('─'.repeat(Math.max(1, width - 4)))}\n\n`;
77
+ rowsArg.forEach((row, index) => {
78
+ if (index > 0 && dividerKeys[index] !== dividerKeys[index - 1]) process.stdout.write(rule);
65
79
  process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
66
- }
80
+ });
67
81
  return;
68
82
  }
69
- await outArg.table(rowsArg, { columns: columnsArg, overflow: 'wrap', theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } } });
83
+ await outArg.table(rowsArg, {
84
+ columns: columnsArg, overflow: 'wrap', groups: groupsArg,
85
+ theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } },
86
+ });
87
+ };
88
+
89
+ /** List tables that repeat one account across rows divide it from the next account by its id. */
90
+ const accountDividers: plugins.smartconsole.IBackendTableGroups<{ accountId: string | null }> = { divider: row => row.accountId };
91
+
92
+ /**
93
+ * Names every account of one harness by its label, numbering each later account that repeats a label
94
+ * (`alice@example.com (2)`), so all tables name an account alike and the metric columns stay distinct.
95
+ */
96
+ const nameAccounts = (accountsArg: TListedAccount[]): TNamedAccount[] => {
97
+ const labels = accountsArg.map(account => plainText(account.label));
98
+ const taken = new Set(labels);
99
+ return accountsArg.map((account, index) => {
100
+ const label = labels[index];
101
+ let name = label;
102
+ if (labels.indexOf(label) < index) {
103
+ for (let ordinal = 2; taken.has(name); ordinal++) name = `${label} (${ordinal})`;
104
+ taken.add(name);
105
+ }
106
+ return { ...account, name };
107
+ });
70
108
  };
71
109
 
72
110
  /** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
@@ -75,10 +113,6 @@ export class AccountListRenderer {
75
113
  private get width(): number { return consoleWidth(); }
76
114
  private heading(textArg: string): void { consoleHeading(textArg); }
77
115
 
78
- private async table<TRow>(rowsArg: TRow[], columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[]): Promise<void> {
79
- await consoleTable(this.out, rowsArg, columnsArg);
80
- }
81
-
82
116
  public async render(listArg: IAccountList): Promise<void> {
83
117
  const now = Date.parse(listArg.generatedAt);
84
118
  if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
@@ -92,55 +126,57 @@ export class AccountListRenderer {
92
126
  process.stdout.write(`${dim(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint))}\n`);
93
127
  continue;
94
128
  }
95
- await this.overview(harness.accounts, now);
96
- const schedules = harness.accounts.flatMap(account => orderedUsageWindows(account.status.summary?.usageWindows).map(window => ({ account: plainText(account.label), window })));
129
+ const accounts = nameAccounts(harness.accounts);
130
+ await this.overview(accounts, now);
131
+ const schedules = accounts.flatMap(account => orderedUsageWindows(account.status.summary?.usageWindows).map(window => ({ accountId: account.id, account: account.name, window })));
97
132
  for (const feature of [false, true]) {
98
133
  const scopedSchedules = schedules.filter(row => (row.window.scope === 'feature') === feature);
99
134
  if (!scopedSchedules.length) continue;
100
135
  this.heading(feature ? 'Feature-specific limits' : 'Usage & reset schedule');
101
- await this.table(scopedSchedules, [
136
+ await consoleTable(this.out, scopedSchedules, [
102
137
  { key: 'account', title: 'Account', value: row => row.account },
103
138
  { key: 'window', title: 'Window', value: row => `${plainText(row.window.label)} (${duration(row.window.durationSeconds)})` },
104
139
  { key: 'usage', title: 'Usage', value: row => `${row.window.usedPercent}% used\n${Math.max(0, 100 - row.window.usedPercent)}% left` },
105
140
  { key: 'reset', title: 'Reset in', value: row => until(row.window.resetAt, now) },
106
- ]);
141
+ ], accountDividers);
107
142
  }
108
- const resets = harness.accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ account: plainText(account.label), reset })));
143
+ const resets = accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ accountId: account.id, account: account.name, reset })));
109
144
  if (resets.length) {
110
145
  this.heading('Earned reset details');
111
- await this.table(resets, [
146
+ await consoleTable(this.out, resets, [
112
147
  { key: 'account', title: 'Account', value: row => row.account },
113
148
  { key: 'kind', title: 'Type', value: row => plainText(row.reset.kind) },
114
149
  { key: 'status', title: 'Status', value: row => plainText(row.reset.status) },
115
150
  { key: 'expiry', title: 'Expires (UTC)', value: row => row.reset.expiresAt === null ? 'Not reported' : plainText(row.reset.expiresAt).replace('T', '\n').replace('.000Z', '').replace(/Z$/, '') },
116
- ]);
151
+ ], accountDividers);
117
152
  }
118
153
  const groups = new Map<string, IDisplayFact[]>();
119
154
  const add = (section: string, fact: IDisplayFact) => { const group = groups.get(section) ?? []; group.push(fact); groups.set(section, group); };
120
- harness.accounts.forEach((account, index) => {
155
+ accounts.forEach((account, index) => {
121
156
  for (const fact of account.status.facts) {
122
157
  if (!this.covered(fact, account.status)) add(plainText(fact.section ?? 'Additional information'), { account: index, label: plainText(fact.label), value: plainText(fact.value) });
123
158
  }
124
159
  });
125
- for (const [section, facts] of groups) await this.facts(section, facts, harness.accounts);
126
- const saved = harness.accounts.flatMap((account, index) => [
160
+ for (const [section, facts] of groups) await this.facts(section, facts, accounts);
161
+ const saved = accounts.flatMap((account, index) => [
127
162
  ...(account.savedAt ? [{ account: index, label: 'Saved (UTC)', value: plainText(account.savedAt) }] : []),
128
163
  ...account.details.map((detail, detailIndex) => ({ account: index, label: `Note ${detailIndex + 1}`, value: plainText(detail) })),
129
164
  ]);
130
- await this.facts('Saved logins', saved, harness.accounts);
131
- const issues = harness.accounts.flatMap(account => account.status.problems.map(problem => ({ account: plainText(account.label), issue: plainText(problem) })));
132
- if (harness.saveUnavailableReason) issues.push({ account: 'Harness', issue: plainText(harness.saveUnavailableReason) });
165
+ await this.facts('Saved logins', saved, accounts);
166
+ const issues: { accountId: string | null; account: string; issue: string }[] = accounts.flatMap(account =>
167
+ account.status.problems.map(problem => ({ accountId: account.id, account: account.name, issue: plainText(problem) })));
168
+ if (harness.saveUnavailableReason) issues.push({ accountId: null, account: 'Harness', issue: plainText(harness.saveUnavailableReason) });
133
169
  if (issues.length) {
134
170
  this.heading('Availability & actions');
135
- await this.table(issues, [
171
+ await consoleTable(this.out, issues, [
136
172
  { key: 'account', title: 'Account', value: row => row.account },
137
173
  { key: 'issue', title: 'Unavailable / action needed', value: row => row.issue },
138
- ]);
174
+ ], accountDividers);
139
175
  }
140
176
  }
141
177
  }
142
178
 
143
- private async overview(accountsArg: TListedAccount[], nowArg: number): Promise<void> {
179
+ private async overview(accountsArg: TNamedAccount[], nowArg: number): Promise<void> {
144
180
  const rows = accountsArg;
145
181
  const state = (account: TListedAccount) => `${account.isActive ? '* active\n' : ''}${account.isStashed ? 'saved' : 'not saved'}${account.status.problems.length ? '\n! partial status' : ''}`;
146
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';
@@ -159,7 +195,7 @@ export class AccountListRenderer {
159
195
  const resets = (account: TListedAccount) => account.status.summary?.resets === undefined ? 'Unavailable' : String(account.status.summary.resets.available);
160
196
  const usage = (account: TListedAccount) => accountQuotaSummary(account, nowArg);
161
197
  const columns: plugins.smartconsole.IBackendTableColumn<typeof rows[number]>[] = [
162
- { key: 'account', title: 'Account', value: row => plainText(row.label), style: row => ({ bold: row.isActive }) },
198
+ { key: 'account', title: 'Account', value: row => row.name, style: row => ({ bold: row.isActive }) },
163
199
  ];
164
200
  if (this.width >= 100) columns.push(
165
201
  { key: 'state', title: 'Login', value: state, style: row => ({ foreground: row.isStashed ? 'green' : 'orange' }) },
@@ -174,7 +210,7 @@ export class AccountListRenderer {
174
210
  { key: 'usage', title: 'Usage / resets', value: row => `${usage(row)}\nEarned resets: ${resets(row)}` },
175
211
  );
176
212
  else columns.push({ key: 'status', title: 'Status', value: row => `${state(row)}\nPlan: ${plan(row)}\nBilling: ${billing(row)}\nUsage: ${usage(row)}\nEarned resets: ${resets(row)}` });
177
- await this.table(rows, columns);
213
+ await consoleTable(this.out, rows, columns);
178
214
  }
179
215
 
180
216
  private covered(factArg: IHarnessStatusFact, statusArg: IHarnessAccountStatus): boolean {
@@ -191,10 +227,10 @@ export class AccountListRenderer {
191
227
  }
192
228
 
193
229
  /** Compare like metrics across accounts, keeping duplicate labels as separate rows. */
194
- private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TListedAccount[]): Promise<void> {
230
+ private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TNamedAccount[]): Promise<void> {
195
231
  if (!factsArg.length) return;
196
232
  this.heading(titleArg);
197
- const valueBudget = Math.max(10, Math.min(40, Math.max(...factsArg.map(fact => Math.max(fact.value.length, plainText(accountsArg[fact.account].label).length))) + 3));
233
+ const valueBudget = Math.max(10, Math.min(40, Math.max(...factsArg.map(fact => Math.max(fact.value.length, accountsArg[fact.account].name.length))) + 3));
198
234
  const perPage = Math.max(1, Math.min(6, Math.floor((this.width - 30) / valueBudget)));
199
235
  const accountIndices = accountsArg.map((_, index) => index).filter(index => factsArg.some(fact => fact.account === index));
200
236
  for (let start = 0; start < accountIndices.length; start += perPage) {
@@ -212,12 +248,12 @@ export class AccountListRenderer {
212
248
  }
213
249
  if (this.width < 40) {
214
250
  for (const index of indices) {
215
- process.stdout.write(` Account: ${plainText(accountsArg[index].label)}\n`);
251
+ process.stdout.write(` Account: ${accountsArg[index].name}\n`);
216
252
  for (const metric of metrics.values()) if (metric.values.has(index)) process.stdout.write(` - ${metric.label}: ${metric.values.get(index)}\n`);
217
253
  }
218
- } else await this.table([...metrics.values()], [
254
+ } else await consoleTable(this.out, [...metrics.values()], [
219
255
  { key: 'metric', title: 'Metric', value: row => row.label },
220
- ...indices.map(index => ({ key: String(index), title: plainText(accountsArg[index].label), value: (row: { values: Map<number, string> }) => row.values.get(index) ?? 'Not reported' })),
256
+ ...indices.map(index => ({ key: String(index), title: accountsArg[index].name, value: (row: { values: Map<number, string> }) => row.values.get(index) ?? 'Not reported' })),
221
257
  ]);
222
258
  }
223
259
  }