@modelprofile.com/authswitch 3.3.0 → 4.0.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 (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +42 -15
  3. package/dist_ts/accounts.js +98 -27
  4. package/dist_ts/classes.accountlist.d.ts +0 -25
  5. package/dist_ts/classes.accountlist.js +2 -216
  6. package/dist_ts/classes.claudecodeharness.d.ts +59 -1
  7. package/dist_ts/classes.claudecodeharness.js +130 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +35 -0
  9. package/dist_ts/classes.claudecodelocks.js +117 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +88 -73
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +14 -7
  15. package/dist_ts/classes.cli.js +125 -66
  16. package/dist_ts/classes.codexharness.d.ts +4 -0
  17. package/dist_ts/classes.codexharness.js +5 -1
  18. package/dist_ts/classes.codexstatus.d.ts +2 -1
  19. package/dist_ts/classes.codexstatus.js +25 -9
  20. package/dist_ts/classes.credentialstore.d.ts +28 -2
  21. package/dist_ts/classes.credentialstore.js +41 -12
  22. package/dist_ts/classes.fileharness.d.ts +37 -17
  23. package/dist_ts/classes.fileharness.js +58 -24
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +94 -36
  26. package/dist_ts/classes.listrenderer.d.ts +17 -0
  27. package/dist_ts/classes.listrenderer.js +313 -0
  28. package/dist_ts/classes.login.d.ts +1 -1
  29. package/dist_ts/classes.login.js +1 -1
  30. package/dist_ts/classes.opencodeharness.d.ts +4 -0
  31. package/dist_ts/classes.opencodeharness.js +7 -3
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +4 -3
  34. package/dist_ts/classes.watch.d.ts +108 -0
  35. package/dist_ts/classes.watch.js +219 -0
  36. package/dist_ts/classes.watchlock.d.ts +33 -0
  37. package/dist_ts/classes.watchlock.js +118 -0
  38. package/dist_ts/claudehttp.d.ts +39 -0
  39. package/dist_ts/claudehttp.js +83 -0
  40. package/dist_ts/cliargs.d.ts +36 -0
  41. package/dist_ts/cliargs.js +60 -0
  42. package/dist_ts/consoletable.d.ts +21 -0
  43. package/dist_ts/consoletable.js +63 -0
  44. package/dist_ts/helpers.d.ts +7 -0
  45. package/dist_ts/helpers.js +16 -1
  46. package/dist_ts/index.d.ts +3 -0
  47. package/dist_ts/index.js +4 -1
  48. package/dist_ts/interfaces.harness.d.ts +60 -11
  49. package/dist_ts/interfaces.list.d.ts +3 -1
  50. package/dist_ts/plugins.d.ts +7 -0
  51. package/dist_ts/plugins.js +6 -1
  52. package/dist_ts/ratelimit.d.ts +8 -0
  53. package/dist_ts/ratelimit.js +13 -0
  54. package/dist_ts/watchpolicy.d.ts +44 -0
  55. package/dist_ts/watchpolicy.js +82 -0
  56. package/package.json +5 -3
  57. package/readme.md +315 -110
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +109 -34
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +125 -12
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +87 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +112 -53
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +17 -7
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +67 -29
  70. package/ts/classes.limits.ts +110 -36
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +6 -2
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +3 -2
  76. package/ts/classes.watch.ts +263 -0
  77. package/ts/classes.watchlock.ts +100 -0
  78. package/ts/claudehttp.ts +92 -0
  79. package/ts/cliargs.ts +71 -0
  80. package/ts/consoletable.ts +62 -0
  81. package/ts/helpers.ts +14 -0
  82. package/ts/index.ts +3 -0
  83. package/ts/interfaces.harness.ts +60 -5
  84. package/ts/interfaces.list.ts +3 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -25,20 +25,32 @@ export abstract class FileHarness implements IAuthHarness {
25
25
  protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
26
26
  /** The harness's own running instances. Detection informs the caller; it never blocks a write. */
27
27
  public abstract readonly processes: IHarnessProcessControl;
28
+ /** Whether running instances pick up a swapped login by themselves; see `IAuthHarness.liveSwap`. */
29
+ public abstract readonly liveSwap: boolean;
30
+ /** Whether `authswitch watch` may switch this harness by itself; see `IAuthHarness.autoSwitch`. */
31
+ public abstract readonly autoSwitch: boolean;
28
32
  /** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
29
33
  protected abstract nativeSources(): (string | null)[];
30
34
  protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
31
35
  public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
32
36
 
37
+ /**
38
+ * Runs a read and write of the native login under the harness's own write locks, so it never interleaves
39
+ * with a running instance's token refresh or save. A harness without such a protocol runs the action as it is.
40
+ */
41
+ protected async nativeTransaction(actionArg: () => string[]): Promise<string[]> {
42
+ return actionArg();
43
+ }
44
+
33
45
  /**
34
46
  * A snapshot proven not to have been taken across a native write.
35
47
  *
36
48
  * Saving a login without replacing it writes no native file, so it must not require the harness
37
- * to be stopped. A running harness does rotate its tokens on its own schedule, and its fallback
38
- * writer truncates the file in place, so reading the same sources before and after the snapshot
39
- * and requiring identical bytes is what keeps a torn or half-rotated credential out of the stash.
40
- * Both the retry count and the pause between attempts are bounded; an unstable file is reported,
41
- * never guessed at.
49
+ * to be stopped. A running harness does rotate its tokens on its own schedule, and a native writer
50
+ * that is not transactional can leave a partial file for a moment, so reading the same sources
51
+ * before and after the snapshot and requiring identical bytes is what keeps a torn or half-rotated
52
+ * credential out of the stash. Both the retry count and the pause between attempts are bounded; an
53
+ * unstable file is reported, never guessed at.
42
54
  */
43
55
  private stableSnapshot(): IFileHarnessSnapshot {
44
56
  let failure: unknown;
@@ -82,11 +94,11 @@ export abstract class FileHarness implements IAuthHarness {
82
94
  /**
83
95
  * Credential slots that no longer hold the account the last switch wrote.
84
96
  *
85
- * A running harness refreshes its login on its own schedule and writes the result to the same
86
- * file, so a switch performed underneath one can be undone minutes later without any error. The
87
- * recorded hash detects that the file changed at all; comparing the account distinguishes the
88
- * benign case -- the switched-in account rotating its own token -- from the previous login coming
89
- * back. Detection is advisory and never turns a read into a failure.
97
+ * The login can change outside authswitch -- a new native login, or, for a harness that does not pick
98
+ * up swaps live, a running instance writing its previous login back at its next refresh. The recorded
99
+ * hash detects that the file changed at all; comparing the account distinguishes the benign case -- the
100
+ * switched-in account rotating its own token -- from a different account. Detection is advisory and
101
+ * never turns a read into a failure.
90
102
  */
91
103
  private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
92
104
  let records: ISwitchRecord[];
@@ -111,8 +123,9 @@ export abstract class FileHarness implements IAuthHarness {
111
123
  private savedLabel(accountIdArg: string): string | null {
112
124
  try { return this.store.read(accountIdArg).label; } catch { return null; }
113
125
  }
114
- /** Instances that keep a replaced or cleared login in memory until they are restarted. */
126
+ /** Instances that keep a replaced or cleared login in memory until they are restarted; none for a live-swap harness. */
115
127
  private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
128
+ if (this.liveSwap) return [];
116
129
  let count: number;
117
130
  try { count = this.processes.list().length; }
118
131
  catch { return [`Could not check for running ${this.label} processes. If any are running, restart them: an unrestarted instance can write its previous login back.`]; }
@@ -137,16 +150,24 @@ export abstract class FileHarness implements IAuthHarness {
137
150
  return [];
138
151
  } catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
139
152
  }
153
+ /** The active login with this account id, read from the native files; undefined when the account is not active. */
154
+ protected activeCredential(id: string): IFileAccount | undefined {
155
+ return this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
156
+ }
157
+ /** The saved copy of this account, verified against its identity. */
158
+ protected savedCredential(id: string): IFileAccount {
159
+ return this.saved(this.store.read(id));
160
+ }
161
+ /** The account's current credential: the active login when it is active, which may be newer than its saved copy. */
140
162
  protected readCredential(id: string): IFileAccount {
141
- const active = this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
142
- return active ?? this.saved(this.store.read(id));
163
+ return this.activeCredential(id) ?? this.savedCredential(id);
143
164
  }
144
165
  /** Save a newly authenticated credential without changing a native harness's login. */
145
- public importCredential(slotId: string, credential: Record<string, unknown>): { accountId: string } {
166
+ public importCredential(slotId: string, credential: Record<string, unknown>): Promise<{ accountId: string }> {
146
167
  return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
147
168
  }
148
169
  /** Backend-only login handoff. Preserve the outgoing credential before replacing its slot. */
149
- public activateCredential(slotId: string, credential: Record<string, unknown>): IHarnessOutcome {
170
+ public activateCredential(slotId: string, credential: Record<string, unknown>): Promise<IHarnessOutcome> {
150
171
  return this.outcome(() => {
151
172
  const target = this.inspect(credential, slotId);
152
173
  const snapshot = this.snapshot();
@@ -154,9 +175,15 @@ export abstract class FileHarness implements IAuthHarness {
154
175
  const previous = snapshot.accounts.find(account => account.slotId === slotId);
155
176
  if (previous) this.store.save(previous);
156
177
  this.apply(slotId, previous, target);
157
- return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`,
178
+ return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`, ...this.liveSwapNote('replaced'),
158
179
  ...this.recordActivation(slotId, this.store.id(slotId, target.identity)), ...this.runningCaveat('replaced')];
159
- });
180
+ }, true);
181
+ }
182
+ /** What a live-swap harness's running sessions do with the change; they need no restart either way. */
183
+ private liveSwapNote(kindArg: 'replaced' | 'cleared'): string[] {
184
+ if (!this.liveSwap) return [];
185
+ return [kindArg === 'replaced' ? `Running ${this.label} sessions use it from their next request.`
186
+ : `Running ${this.label} sessions have no login from their next request.`];
160
187
  }
161
188
  public resolveAccount(reference: string): { id: string } | { candidates: string[] } {
162
189
  const accounts = this.store.list();
@@ -165,11 +192,18 @@ export abstract class FileHarness implements IAuthHarness {
165
192
  const matches = exact.length ? exact : accounts.filter(account => account.label.toLowerCase().startsWith(reference.toLowerCase()));
166
193
  return matches.length === 1 ? { id: matches[0].id } : { candidates: matches.map(account => `${account.slotId}:${account.label} (${account.id})`) };
167
194
  }
168
- private outcome(action: () => string | string[]): IHarnessOutcome {
169
- try { return this.store.locked(() => { const lines = action(); return { lines: Array.isArray(lines) ? lines : [lines], problems: [] }; }); }
170
- catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
195
+ /**
196
+ * Runs one credential operation as the only authswitch operation on this harness's store. An operation that reads
197
+ * or writes the native login (`nativeArg`) also runs inside the harness's native transaction.
198
+ */
199
+ private async outcome(actionArg: () => string | string[], nativeArg: boolean): Promise<IHarnessOutcome> {
200
+ const run = (): string[] => [actionArg()].flat();
201
+ try {
202
+ const lines = await this.store.locked(() => nativeArg ? this.nativeTransaction(run) : run());
203
+ return { lines, problems: [] };
204
+ } catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
171
205
  }
172
- public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
206
+ public saveCurrent(options: { keepActive: boolean; accountId?: string }): Promise<IHarnessOutcome> {
173
207
  return this.outcome(() => {
174
208
  const snapshot = this.stableSnapshot();
175
209
  if (snapshot.unavailable) throw new Error(snapshot.unavailable);
@@ -182,10 +216,13 @@ export abstract class FileHarness implements IAuthHarness {
182
216
  const cleared: string[] = [];
183
217
  try { this.store.clearSwitch(active.slotId); }
184
218
  catch { cleared.push('The login was cleared, but its switch record could not be removed.'); }
185
- return [`Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`, ...cleared, ...this.runningCaveat('cleared')];
186
- });
219
+ return [this.liveSwap
220
+ ? `Saved ${plainText(active.label)} (${plainText(active.slotId)}) and cleared the active login. ${this.loginHint}`
221
+ : `Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`,
222
+ ...this.liveSwapNote('cleared'), ...cleared, ...this.runningCaveat('cleared')];
223
+ }, true);
187
224
  }
188
- public switchAccount(id: string): IHarnessOutcome {
225
+ public switchAccount(id: string): Promise<IHarnessOutcome> {
189
226
  return this.outcome(() => {
190
227
  const target = this.saved(this.store.read(id));
191
228
  const snapshot = this.snapshot();
@@ -198,12 +235,13 @@ export abstract class FileHarness implements IAuthHarness {
198
235
  return [`${plainText(target.label)} is already active; its latest credential is saved.`, ...this.recordActivation(target.slotId, id)];
199
236
  }
200
237
  this.apply(target.slotId, previous, target);
201
- return [`Activated ${plainText(target.label)} (${plainText(target.slotId)}). Start ${this.label} to use it.`,
202
- ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
203
- });
238
+ const activated = `Activated ${plainText(target.label)} (${plainText(target.slotId)}).`;
239
+ return [this.liveSwap ? activated : `${activated} Start ${this.label} to use it.`,
240
+ ...this.liveSwapNote('replaced'), ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
241
+ }, true);
204
242
  }
205
- public removeAccount(id: string): IHarnessOutcome {
206
- return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; });
243
+ public removeAccount(id: string): Promise<IHarnessOutcome> {
244
+ return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; }, false);
207
245
  }
208
246
  public diagnose(): IHarnessOutcome {
209
247
  try {
@@ -1,15 +1,25 @@
1
1
  import * as plugins from './plugins.js';
2
- import { accountTypeText, compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
3
- import { consoleHeading, consoleTable } from './classes.accountlist.js';
2
+ import { accountTypeText, compactSince, compactUntil, credentialDriftNote, localDate, orderedUsageWindows, usagePercentText } from './accounts.js';
3
+ import { consoleHeading, consoleNote, consoleTable } from './consoletable.js';
4
4
  import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
5
- import type { IAccountType, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
5
+ import type { IAccountType, IAuthHarness, IHarnessBilling, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
6
6
  import { dim, plainText } from './formatting.js';
7
7
 
8
8
  type TListedAccount = IHarnessAccountList['accounts'][number];
9
9
 
10
- const UNAVAILABLE = 'n/a';
10
+ export const UNAVAILABLE = 'n/a';
11
11
  const NONE = 'none';
12
12
 
13
+ /** What the projections need to know about a harness beyond its account list. */
14
+ export type THarnessTraits = Pick<IAuthHarness, 'id' | 'liveSwap' | 'renewalUnavailableReason'>;
15
+
16
+ /** The declared traits of a listed harness; a list is always projected with the harnesses it was read from. */
17
+ const traitsOf = (harnessesArg: readonly THarnessTraits[], harnessArg: IHarnessAccountList): THarnessTraits => {
18
+ const traits = harnessesArg.find(harness => harness.id === harnessArg.id);
19
+ if (traits === undefined) throw new Error(`The account list names harness ${plainText(harnessArg.id)}, which was not passed with it.`);
20
+ return traits;
21
+ };
22
+
13
23
  /**
14
24
  * Provider naming stays data-driven.
15
25
  *
@@ -47,11 +57,46 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
47
57
  });
48
58
 
49
59
  /** The account's plan, sanitised like every other label and copied so that no two rows share one object. */
50
- const accountType = (accountArg: TListedAccount): IAccountType | null => {
60
+ export const accountType = (accountArg: TListedAccount): IAccountType | null => {
51
61
  const subscription = accountArg.status.summary?.subscription;
52
62
  return subscription === undefined ? null : { plan: plainText(subscription.plan), source: subscription.source };
53
63
  };
54
64
 
65
+ /** The account's reported billing fields, sanitised and copied per row; null when the provider reported none. */
66
+ export const accountBilling = (accountArg: TListedAccount): IHarnessBilling | null => {
67
+ const billing = accountArg.status.summary?.billing;
68
+ if (billing === undefined) return null;
69
+ const date = (valueArg: string | undefined) => valueArg === undefined ? undefined : plainText(valueArg);
70
+ return Object.fromEntries(Object.entries({
71
+ hasActiveSubscription: billing.hasActiveSubscription, autoRenew: billing.autoRenew,
72
+ renewsAt: date(billing.renewsAt), cancelsAt: date(billing.cancelsAt), expiresAt: date(billing.expiresAt),
73
+ }).filter(([, value]) => value !== undefined)) as IHarnessBilling;
74
+ };
75
+
76
+ /** The next billing event a provider reported: a renewal it will charge, or the end of a subscription it will not renew. */
77
+ export interface IRenewal {
78
+ at: string;
79
+ kind: 'auto-renews' | 'cancels';
80
+ }
81
+
82
+ /** Read from the reported fields only; a missing or unparseable date is no event, never an inferred one. */
83
+ export const nextRenewal = (billingArg: IHarnessBilling | null): IRenewal | null => {
84
+ if (billingArg === null) return null;
85
+ const known = (valueArg: string | undefined): valueArg is string => valueArg !== undefined && Number.isFinite(Date.parse(valueArg));
86
+ if (billingArg.autoRenew === true && known(billingArg.renewsAt)) return { at: billingArg.renewsAt, kind: 'auto-renews' };
87
+ if (known(billingArg.cancelsAt)) return { at: billingArg.cancelsAt, kind: 'cancels' };
88
+ if (billingArg.autoRenew === false && known(billingArg.expiresAt)) return { at: billingArg.expiresAt, kind: 'cancels' };
89
+ return null;
90
+ };
91
+
92
+ /** The Renewal cell: the local date, and beneath it whether the subscription renews or ends then; a dim `n/a` otherwise. */
93
+ export const renewalCell = (billingArg: IHarnessBilling | null): plugins.smartconsole.TText => {
94
+ const renewal = nextRenewal(billingArg);
95
+ return renewal === null ? plugins.smartconsole.color.text(UNAVAILABLE, { dim: true })
96
+ : plugins.smartconsole.color.concat(`${localDate(renewal.at)}\n`,
97
+ plugins.smartconsole.color.text(renewal.kind, renewal.kind === 'cancels' ? { foreground: 'orange' } : { dim: true }));
98
+ };
99
+
55
100
  const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
56
101
  harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
57
102
 
@@ -96,44 +141,49 @@ const collectNotes = (rowsArg: { provider: string; account: string | null; unava
96
141
  [...new Set(rowsArg.map(noteFor).filter((note): note is string => note !== null))];
97
142
 
98
143
  /** The drift a switch record proves, carried into the row document with both accounts named. */
99
- const activeDrift = (harnessArg: IHarnessAccountList, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
144
+ const activeDrift = (traitsArg: THarnessTraits, driftArg: IHarnessCredentialDrift): IActiveAccountDrift => ({
100
145
  expectedAccountId: driftArg.expectedAccountId, expectedAccount: driftArg.expectedLabel === null ? null : plainText(driftArg.expectedLabel),
101
146
  currentAccountId: driftArg.currentAccountId, currentAccount: driftArg.currentLabel === null ? null : plainText(driftArg.currentLabel),
102
- currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(harnessArg.id, driftArg),
147
+ currentIsSaved: driftArg.currentIsSaved, switchedAt: driftArg.switchedAt, reason: credentialDriftNote(traitsArg, driftArg),
103
148
  });
104
149
 
105
150
  /**
106
- * One row per account and limit type, projected from the account list.
151
+ * One row per account and limit type, projected from the account list and the harnesses it was read from.
107
152
  *
108
- * Nothing here talks to a provider: the list is already the read-only, per-account-isolated lookup,
109
- * so a single failing account costs its own rows a number and nothing else.
153
+ * Nothing here talks to a provider: the list is already the per-account-isolated lookup, so a single
154
+ * failing account costs its own rows a number and nothing else. A harness that declares its provider
155
+ * never reports renewal dates gets one note instead of an unexplained `n/a` per account.
110
156
  */
111
- export const accountLimits = (listArg: IAccountList): IAccountLimits => {
157
+ export const accountLimits = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IAccountLimits => {
112
158
  const now = Date.parse(listArg.generatedAt);
113
159
  const rows: IAccountLimitRow[] = [];
160
+ const renewalNotes: string[] = [];
114
161
  for (const harness of listArg.harnesses) {
115
162
  const problem = harnessProblem(harness);
116
163
  const empty = {
117
164
  limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
118
165
  } as const;
119
166
  if (problem !== null) {
120
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, ...empty, unavailableReason: problem });
167
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty, unavailableReason: problem });
121
168
  continue;
122
169
  }
123
170
  if (!harness.accounts.length) {
124
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, ...empty,
171
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, billing: null, ...empty,
125
172
  unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
126
173
  continue;
127
174
  }
175
+ const renewalUnavailable = traitsOf(harnessesArg, harness).renewalUnavailableReason;
176
+ if (renewalUnavailable !== undefined) renewalNotes.push(`${plainText(harness.label)}: ${plainText(renewalUnavailable)}`);
128
177
  for (const account of harness.accounts) {
178
+ const identity = () => ({ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account), billing: accountBilling(account) });
129
179
  const windows = orderedUsageWindows(account.status.summary?.usageWindows);
130
180
  if (!windows.length) {
131
- rows.push({ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account), ...empty, unavailableReason: missingLimitReason(account) });
181
+ rows.push({ ...identity(), ...empty, unavailableReason: missingLimitReason(account) });
132
182
  continue;
133
183
  }
134
184
  for (const window of windows) {
135
185
  rows.push({
136
- ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account),
186
+ ...identity(),
137
187
  limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
138
188
  usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
139
189
  severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
@@ -142,7 +192,7 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
142
192
  }
143
193
  }
144
194
  const limits = byProviderThenAccount(rows);
145
- return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: collectNotes(limits) };
195
+ return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, limits, notes: [...collectNotes(limits), ...renewalNotes] };
146
196
  };
147
197
 
148
198
  /**
@@ -151,16 +201,17 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
151
201
  * A harness with no active login still gets a row: "nothing is active here" is an answer, and
152
202
  * silently dropping the provider would read as "this provider does not exist".
153
203
  */
154
- export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
204
+ export const activeAccounts = (listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): IActiveAccounts => {
155
205
  const now = Date.parse(listArg.generatedAt);
156
206
  const rows: IActiveAccountRow[] = [];
157
207
  const unattached: string[] = [];
158
208
  for (const harness of listArg.harnesses) {
209
+ const traits = traitsOf(harnessesArg, harness);
159
210
  const problem = harnessProblem(harness);
160
211
  const drifts = [...harness.credentialDrift];
161
212
  const driftFor = (slotIdArg?: string): IActiveAccountDrift | null => {
162
213
  const index = drifts.findIndex(item => item.slotId === slotIdArg);
163
- return index < 0 ? null : activeDrift(harness, drifts.splice(index, 1)[0]);
214
+ return index < 0 ? null : activeDrift(traits, drifts.splice(index, 1)[0]);
164
215
  };
165
216
  if (problem !== null) {
166
217
  rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
@@ -175,7 +226,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
175
226
  unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
176
227
  : stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
177
228
  : `No login is active and none is saved. ${plainText(harness.loginHint)}`,
178
- drift: drifts.length ? activeDrift(harness, drifts.shift()!) : null,
229
+ drift: drifts.length ? activeDrift(traits, drifts.shift()!) : null,
179
230
  });
180
231
  } else {
181
232
  for (const account of active) {
@@ -189,7 +240,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
189
240
  }
190
241
  }
191
242
  // A drifted slot with no row of its own still has to be reported, not dropped.
192
- for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(harness.id, drift)}`);
243
+ for (const drift of drifts) unattached.push(`${providerName(harness)}${drift.slotId === undefined ? '' : ` / ${plainText(drift.slotId)}`}: ${credentialDriftNote(traits, drift)}`);
193
244
  }
194
245
  const active = byProviderThenAccount(rows);
195
246
  const driftNotes = active.filter(row => row.drift !== null)
@@ -200,44 +251,67 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
200
251
  /**
201
252
  * The Account cell: the label, and the account type beneath it, dim. An unknown type keeps its line as
202
253
  * `n/a`, so every account cell has the same shape; a provider row without an account has no type to name.
254
+ * A view without a Provider column names the credential slot in the type line (`openai · Pro`) when the
255
+ * harness owns several.
203
256
  */
204
- const accountCell = (rowArg: IAccountLimitRow): plugins.smartconsole.TText => rowArg.account === null ? UNAVAILABLE
205
- : plugins.smartconsole.color.concat(`${rowArg.account}\n`, plugins.smartconsole.color.text(accountTypeText(rowArg.accountType) ?? UNAVAILABLE, { dim: true, bold: false }));
257
+ export const accountCell = (rowArg: Pick<IAccountLimitRow, 'account' | 'accountType' | 'slotId'>, slotsArg: boolean): plugins.smartconsole.TText => {
258
+ if (rowArg.account === null) return UNAVAILABLE;
259
+ const type = [slotsArg ? rowArg.slotId : undefined, accountTypeText(rowArg.accountType) ?? undefined].filter(part => part !== undefined).join(' · ');
260
+ return plugins.smartconsole.color.concat(`${rowArg.account}\n`, plugins.smartconsole.color.text(type || UNAVAILABLE, { dim: true, bold: false }));
261
+ };
206
262
 
207
- const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
263
+ const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : usagePercentText(rowArg.usedPercent);
208
264
 
209
265
  const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
210
266
 
211
267
  /** The provider's own reading colours a window; without one, n/a and exhausted windows stand out. */
212
- const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined =>
213
- rowArg.severity !== null ? SEVERITY_COLORS[rowArg.severity]
214
- : rowArg.usedPercent === null ? 'orange' : rowArg.usedPercent >= 100 ? 'red' : undefined;
268
+ export const usageColor = (severityArg: TUsageSeverity | null, usedPercentArg: number | null): plugins.smartconsole.TColorName | undefined =>
269
+ severityArg !== null ? SEVERITY_COLORS[severityArg]
270
+ : usedPercentArg === null ? 'orange' : usedPercentArg >= 100 ? 'red' : undefined;
271
+ const percentColor = (rowArg: IAccountLimitRow): plugins.smartconsole.TColorName | undefined => usageColor(rowArg.severity, rowArg.usedPercent);
272
+
273
+ /** Which parts of the limits table a view shows. */
274
+ export interface ILimitsTableOptions {
275
+ /** The Provider column and the per-harness colour line, for a view across harnesses. */
276
+ provider: boolean;
277
+ renewal: boolean;
278
+ /** Whether the harness owns several credential slots, named in the account cell when there is no Provider column. */
279
+ slots: boolean;
280
+ }
215
281
 
216
- /** Condensed presentation for `authswitch limits` and `authswitch active`. */
282
+ /** Condensed presentation for `authswitch limits` and `authswitch active`, and the limits table other views embed. */
217
283
  export class CondensedRenderer {
218
284
  constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
219
285
 
220
286
  private notes(notesArg: string[], prefixArg: string): void {
221
287
  if (!notesArg.length) return;
222
288
  process.stdout.write('\n');
223
- for (const note of notesArg) process.stdout.write(`${dim(`${prefixArg}${note}`)}\n`);
289
+ for (const note of notesArg) consoleNote(note, prefixArg);
224
290
  }
225
291
 
226
292
  public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
227
293
  consoleHeading('Account limits');
294
+ if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
295
+ await this.limitsTable(limitsArg.limits, { provider: true, renewal: true, slots: false });
296
+ this.notes(limitsArg.notes, 'n/a — ');
297
+ }
298
+
299
+ /** One row per account and limit type; `rowsArg` must be ordered by `byProviderThenAccount` and is rendered as it is. */
300
+ public async limitsTable(rowsArg: IAccountLimitRow[], optionsArg: ILimitsTableOptions): Promise<void> {
228
301
  // The span callbacks record these exact rows, so the table renders this array as it is.
229
- const rows = limitsArg.limits;
230
- if (!rows.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
231
- await consoleTable(this.out, rows, [
232
- { key: 'provider', title: 'Provider', value: row => row.provider, rowSpan: plugins.smartconsole.rowSpansBy(rows, providerRuns) },
233
- { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, render: accountCell,
234
- style: row => ({ bold: row.isActive }), rowSpan: plugins.smartconsole.rowSpansBy(rows, accountRuns) },
302
+ const accountSpan = plugins.smartconsole.rowSpansBy(rowsArg, accountRuns);
303
+ const columns: plugins.smartconsole.IBackendTableColumn<IAccountLimitRow>[] = [
304
+ ...(optionsArg.provider ? [{ key: 'provider', title: 'Provider', value: (row: IAccountLimitRow) => row.provider, rowSpan: plugins.smartconsole.rowSpansBy(rowsArg, providerRuns) }] : []),
305
+ { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, render: row => accountCell(row, optionsArg.slots),
306
+ style: row => ({ bold: row.isActive }), rowSpan: accountSpan },
235
307
  { key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
236
308
  { key: 'used', title: 'Used %', value: percent, align: 'right',
237
309
  style: row => ({ foreground: percentColor(row) }) },
238
310
  { key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
239
- ], { divider: accountRuns, colorLine: harnessRuns });
240
- this.notes(limitsArg.notes, 'n/a — ');
311
+ ...(optionsArg.renewal ? [{ key: 'renewal', title: 'Renewal', value: (row: IAccountLimitRow) => nextRenewal(row.billing)?.at ?? UNAVAILABLE,
312
+ render: (row: IAccountLimitRow) => renewalCell(row.billing), rowSpan: accountSpan }] : []),
313
+ ];
314
+ await consoleTable(this.out, rowsArg, columns, optionsArg.provider ? { divider: accountRuns, colorLine: harnessRuns } : { divider: accountRuns });
241
315
  }
242
316
 
243
317
  public async renderActive(activeArg: IActiveAccounts): Promise<void> {