@modelprofile.com/authswitch 3.3.0 → 5.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 +52 -15
  3. package/dist_ts/accounts.js +110 -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 +81 -2
  7. package/dist_ts/classes.claudecodeharness.js +208 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +38 -0
  9. package/dist_ts/classes.claudecodelocks.js +118 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +100 -83
  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 +126 -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 +7 -2
  19. package/dist_ts/classes.codexstatus.js +49 -21
  20. package/dist_ts/classes.credentialstore.d.ts +43 -2
  21. package/dist_ts/classes.credentialstore.js +60 -13
  22. package/dist_ts/classes.fileharness.d.ts +45 -21
  23. package/dist_ts/classes.fileharness.js +64 -26
  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 +8 -4
  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 +349 -115
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +122 -35
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +199 -13
  62. package/ts/classes.claudecodelocks.ts +133 -0
  63. package/ts/classes.claudestatus.ts +100 -65
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +114 -54
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +40 -18
  68. package/ts/classes.credentialstore.ts +78 -10
  69. package/ts/classes.fileharness.ts +76 -34
  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 +7 -3
  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
@@ -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> {
@@ -0,0 +1,328 @@
1
+ import * as plugins from './plugins.js';
2
+ import { accountUsageWindows, compactUsageLine, credentialDriftProblem, localDate, localDateTime, localiseTimestamps } from './accounts.js';
3
+ import { accountBilling, accountCell, accountLimits, accountType, CondensedRenderer, nextRenewal, renewalCell, UNAVAILABLE, usageColor, type THarnessTraits } from './classes.limits.js';
4
+ import { consoleHeading, consoleNote, consoleTable, consoleWidth } from './consoletable.js';
5
+ import type { IHarnessStatusFact, IHarnessStatusSummary } from './interfaces.harness.js';
6
+ import type { IAccountList, IAccountLimitRow, IHarnessAccountList } from './interfaces.list.js';
7
+ import { orange, plainText } from './formatting.js';
8
+
9
+ type TListedAccount = IHarnessAccountList['accounts'][number];
10
+ type TNamedAccount = TListedAccount & { name: string };
11
+ type TText = plugins.smartconsole.TText;
12
+
13
+ const { color } = plugins.smartconsole;
14
+ const dimText = (textArg: string): TText => color.text(textArg, { dim: true });
15
+ /** Cell lines, one per part; parts that are null are left out. */
16
+ const stack = (...partsArg: (TText | null)[]): TText =>
17
+ color.concat(...partsArg.filter((part): part is TText => part !== null).flatMap((part, index) => index ? ['\n', part] : [part]));
18
+
19
+ /** How many dated rows a date series keeps: the newest week, as the providers report daily totals. */
20
+ const DATED_ROWS = 7;
21
+ const DATED_LABEL = /\d{4}-\d{2}-\d{2}$/;
22
+ /** A problem that names what failed before its message: `Usage: Claude account service returned HTTP 429.` */
23
+ const TOPIC_PROBLEM = /^([^:.]{1,40}): (.+)$/s;
24
+ /** A saved-login note of the form `name: value` or `name:value`. */
25
+ const NOTE_PAIR = /^([^:]{1,40}):\s*(.+)$/s;
26
+
27
+ /**
28
+ * Names every account of one harness by its label, numbering each later account that repeats a label
29
+ * (`alice@example.com (2)`), so all tables name an account alike and the metric columns stay distinct.
30
+ */
31
+ const nameAccounts = (accountsArg: TListedAccount[]): TNamedAccount[] => {
32
+ const labels = accountsArg.map(account => plainText(account.label));
33
+ const taken = new Set(labels);
34
+ return accountsArg.map((account, index) => {
35
+ const label = labels[index];
36
+ let name = label;
37
+ if (labels.indexOf(label) < index) {
38
+ for (let ordinal = 2; taken.has(name); ordinal++) name = `${label} (${ordinal})`;
39
+ taken.add(name);
40
+ }
41
+ return { ...account, name };
42
+ });
43
+ };
44
+
45
+ /** Whether a fact restates a structured field that the Accounts or Limits table shows. */
46
+ const shownSummaryField = (factArg: IHarnessStatusFact, summaryArg: IHarnessStatusSummary): boolean => {
47
+ switch (factArg.summaryKey) {
48
+ case 'subscription': return summaryArg.subscription !== undefined;
49
+ case 'billing': return summaryArg.billing !== undefined;
50
+ case 'usageWindows': return summaryArg.usageWindows !== undefined;
51
+ case 'resets': return summaryArg.resets !== undefined;
52
+ case 'resetDetails': return summaryArg.resets?.details !== undefined;
53
+ default: return false;
54
+ }
55
+ };
56
+ /** Facts the other tables already show: a displayed structured field, or the account's own label (a profile's email). */
57
+ const covered = (factArg: IHarnessStatusFact, accountArg: TNamedAccount): boolean =>
58
+ (accountArg.status.summary !== undefined && shownSummaryField(factArg, accountArg.status.summary))
59
+ || plainText(factArg.value).toLowerCase() === plainText(accountArg.label).toLowerCase();
60
+
61
+ const isSentence = (valueArg: string): boolean => /\s/.test(valueArg) && /[.!?]$/.test(valueArg);
62
+ /** A topic continuing a list starts in lower case, unless its first word is a name (`OpenAI`). */
63
+ const lowerFirst = (textArg: string): string => /^[A-Z][a-z]*(\s|$)/.test(textArg) ? `${textArg.charAt(0).toLowerCase()}${textArg.slice(1)}` : textArg;
64
+
65
+ interface IDetailRow {
66
+ section: string;
67
+ label: string;
68
+ /** Value per account index. */
69
+ values: Map<number, string>;
70
+ }
71
+ type TDetailLine = IDetailRow | { section: string };
72
+
73
+ interface IDetails {
74
+ /** Rows grouped by section, sections in first-seen order. */
75
+ sections: Map<string, IDetailRow[]>;
76
+ /** Sentences stated once instead of per account. */
77
+ notes: string[];
78
+ }
79
+
80
+ /**
81
+ * One comparison of everything the Accounts and Limits tables do not show, by section.
82
+ *
83
+ * A metric keeps one row across accounts, and a label an account repeats keeps a row per occurrence. A section of
84
+ * dated rows (daily totals) is merged across accounts and keeps the newest week. A sentence that every account in its
85
+ * section states identically is said once, as a note. Saved-login notes form their own section; a note that only
86
+ * restates the account's credential slot is left out, since the Accounts table names the slot.
87
+ */
88
+ const collectDetails = (accountsArg: TNamedAccount[]): IDetails => {
89
+ const sections = new Map<string, Map<string, IDetailRow>>();
90
+ accountsArg.forEach((account, index) => {
91
+ const occurrences = new Map<string, number>();
92
+ const put = (sectionArg: string, labelArg: string, valueArg: string): void => {
93
+ const occurrenceKey = JSON.stringify([sectionArg, labelArg]);
94
+ const occurrence = occurrences.get(occurrenceKey) ?? 0;
95
+ occurrences.set(occurrenceKey, occurrence + 1);
96
+ const rows = sections.get(sectionArg) ?? new Map<string, IDetailRow>();
97
+ sections.set(sectionArg, rows);
98
+ const key = JSON.stringify([labelArg, occurrence]);
99
+ const row = rows.get(key) ?? { section: sectionArg, label: labelArg, values: new Map<number, string>() };
100
+ row.values.set(index, valueArg);
101
+ rows.set(key, row);
102
+ };
103
+ for (const fact of account.status.facts) {
104
+ if (!covered(fact, account)) put(plainText(fact.section ?? 'Additional information'), plainText(fact.label), localiseTimestamps(plainText(fact.value)));
105
+ }
106
+ for (const detail of account.details.map(plainText)) {
107
+ if (account.slotId !== undefined && detail === `Provider: ${plainText(account.slotId)}`) continue;
108
+ const pair = NOTE_PAIR.exec(detail);
109
+ put('Saved login', pair ? pair[1].trim() : 'Note', pair ? pair[2] : detail);
110
+ }
111
+ });
112
+ const notes: string[] = [];
113
+ const result = new Map<string, IDetailRow[]>();
114
+ for (const [section, keyed] of sections) {
115
+ const rows = [...keyed.values()];
116
+ const inSection = new Set(rows.flatMap(row => [...row.values.keys()]));
117
+ const dated = rows.filter(row => DATED_LABEL.test(row.label)).sort((left, right) => right.label.localeCompare(left.label)).slice(0, DATED_ROWS);
118
+ const kept: IDetailRow[] = [];
119
+ for (const row of [...dated, ...rows.filter(row => !DATED_LABEL.test(row.label))]) {
120
+ const values = [...new Set(row.values.values())];
121
+ if (values.length === 1 && isSentence(values[0]) && row.values.size === inSection.size) {
122
+ const names = row.values.size === accountsArg.length ? '' : `${[...row.values.keys()].map(index => accountsArg[index].name).join(', ')} — `;
123
+ notes.push(`${names}${row.label}: ${values[0]}`);
124
+ } else kept.push(row);
125
+ }
126
+ if (kept.length) result.set(section, kept);
127
+ }
128
+ return { sections: result, notes };
129
+ };
130
+
131
+ /**
132
+ * Problems with one message are merged, their topics joined: `Profile, usage: Login expired …`. Several topics take a
133
+ * line of their own and the message follows one sentence per line, so its words stay together in a narrow cell.
134
+ */
135
+ const mergeProblems = (problemsArg: string[]): string[] => {
136
+ const topics = new Map<string, string[]>();
137
+ for (const problem of problemsArg.map(plainText)) {
138
+ const match = TOPIC_PROBLEM.exec(problem);
139
+ const message = match ? match[2] : problem;
140
+ const known = topics.get(message) ?? [];
141
+ if (match && !known.includes(match[1])) known.push(match[1]);
142
+ topics.set(message, known);
143
+ }
144
+ return [...topics].map(([message, names]) => !names.length ? message
145
+ : names.length === 1 ? `${names[0]}: ${message}`
146
+ : `${names.map((name, index) => index ? lowerFirst(name) : name).join(', ')}:\n${message.replace(/(?<=[.!?]) +(?=\S)/g, '\n')}`);
147
+ };
148
+
149
+ interface IProblemRow {
150
+ group: string;
151
+ accounts: string;
152
+ problem: string;
153
+ }
154
+
155
+ /** Shared, provider-independent list presentation: per harness an Accounts, a Limits, a Details and a Problems table. */
156
+ export class AccountListRenderer {
157
+ constructor(private readonly out: plugins.smartconsole.SmartConsole) {}
158
+ private get width(): number { return consoleWidth(); }
159
+
160
+ public async render(listArg: IAccountList, harnessesArg: readonly THarnessTraits[]): Promise<void> {
161
+ const now = Date.parse(listArg.generatedAt);
162
+ if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
163
+ const limits = accountLimits(listArg, harnessesArg).limits;
164
+ // The same harness order as `limits`: by provider name.
165
+ const order = [...new Set(limits.map(row => row.harnessId))];
166
+ const harnesses = [...listArg.harnesses].sort((left, right) => order.indexOf(left.id) - order.indexOf(right.id));
167
+ for (const harness of harnesses) {
168
+ consoleHeading(plainText(harness.label));
169
+ if (harness.problems.length) {
170
+ for (const problem of harness.problems) process.stdout.write(`${orange('Unavailable')}: ${plainText(problem)}\n`);
171
+ continue;
172
+ }
173
+ if (!harness.accounts.length) {
174
+ consoleNote(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint));
175
+ continue;
176
+ }
177
+ const accounts = nameAccounts(harness.accounts);
178
+ const slots = new Set(harness.accounts.map(account => account.slotId)).size > 1;
179
+ await this.accounts(accounts, slots, now);
180
+ // The Renewal cell can only read `n/a` for such a provider, so the declaration explains it here as it does in `limits`.
181
+ const renewalUnavailable = harnessesArg.find(traits => traits.id === harness.id)?.renewalUnavailableReason;
182
+ if (renewalUnavailable !== undefined) consoleNote(`${plainText(harness.label)}: ${plainText(renewalUnavailable)}`, `Renewal ${UNAVAILABLE} — `);
183
+ const names = new Map(accounts.map(account => [account.id, account.name]));
184
+ const limitRows: IAccountLimitRow[] = limits
185
+ .filter(row => row.harnessId === harness.id && row.limitType !== null)
186
+ .map(row => ({ ...row, account: row.accountId === null ? row.account : names.get(row.accountId) ?? row.account }));
187
+ if (limitRows.length) {
188
+ process.stdout.write('\n');
189
+ await new CondensedRenderer(this.out).limitsTable(limitRows, { provider: false, renewal: false, slots });
190
+ }
191
+ await this.details(accounts);
192
+ await this.problems(harness, accounts);
193
+ }
194
+ }
195
+
196
+ private async accounts(accountsArg: TNamedAccount[], slotsArg: boolean, nowArg: number): Promise<void> {
197
+ /** Whether the account is active and saved, then when it was saved; an active login without a save says so instead. */
198
+ const status = (account: TNamedAccount): TText => stack(
199
+ account.isActive ? color.text(account.isStashed ? 'active, saved' : 'active', { foreground: 'green', bold: false })
200
+ : color.text(account.isStashed ? 'saved' : 'not saved', { foreground: account.isStashed ? undefined : 'orange', bold: false }),
201
+ account.savedAt !== null ? dimText(localDateTime(account.savedAt))
202
+ : account.isActive && !account.isStashed ? color.text('not saved', { foreground: 'orange', bold: false }) : null,
203
+ );
204
+ const usage = (account: TNamedAccount): TText => {
205
+ const windows = accountUsageWindows(account);
206
+ if (windows === undefined) return dimText(`usage ${UNAVAILABLE}`);
207
+ if (!windows.length) return dimText('no usage reported');
208
+ return stack(...windows.map(window => color.text(compactUsageLine(window, windows, nowArg), { foreground: usageColor(window.severity ?? null, window.usedPercent) })));
209
+ };
210
+ const reportsResets = accountsArg.some(account => account.status.summary?.resets !== undefined);
211
+ const resetsCell = (account: TNamedAccount): TText => {
212
+ const summary = account.status.summary?.resets;
213
+ if (summary === undefined) return dimText(UNAVAILABLE);
214
+ return stack(String(summary.available), ...(summary.details ?? []).map(detail =>
215
+ dimText(`${plainText(detail.kind)} ${plainText(detail.status)}${detail.expiresAt === null ? '' : ` · until ${localDate(detail.expiresAt)}`}`)));
216
+ };
217
+ /** Earned resets as extra cell lines, for the layouts without their own column; only where the harness reports any. */
218
+ const resets = (account: TNamedAccount): TText | null => reportsResets ? color.concat(dimText('earned resets: '), resetsCell(account)) : null;
219
+ const renewalLine = (account: TNamedAccount): TText => {
220
+ const renewal = nextRenewal(accountBilling(account));
221
+ return renewal === null ? dimText(`renewal ${UNAVAILABLE}`) : `renewal ${localDate(renewal.at)} · ${renewal.kind}`;
222
+ };
223
+ const account = (row: TNamedAccount): TText => accountCell({ account: row.name, accountType: accountType(row), slotId: row.slotId }, slotsArg);
224
+ type TColumn = plugins.smartconsole.IBackendTableColumn<TNamedAccount>;
225
+ const accountColumn = (render: (row: TNamedAccount) => TText): TColumn =>
226
+ ({ key: 'account', title: 'Account', value: row => row.name, render, style: row => ({ bold: row.isActive }) });
227
+ const statusColumn: TColumn = { key: 'status', title: 'Status', value: row => color.plain(status(row)), render: status };
228
+ const renewalColumn: TColumn = { key: 'renewal', title: 'Renewal', value: row => nextRenewal(accountBilling(row))?.at ?? UNAVAILABLE, render: row => renewalCell(accountBilling(row)) };
229
+ const usageValue = (row: TNamedAccount): string => color.plain(usage(row));
230
+ let columns: TColumn[];
231
+ if (this.width >= 120) {
232
+ columns = [accountColumn(account), statusColumn, { key: 'usage', title: 'Usage', value: usageValue, render: usage }, renewalColumn,
233
+ ...(reportsResets ? [{ key: 'resets', title: 'Earned resets', value: (row: TNamedAccount) => row.status.summary?.resets?.available, render: resetsCell }] : [])];
234
+ } else if (this.width >= 100) {
235
+ columns = [accountColumn(account), statusColumn, { key: 'usage', title: 'Usage', value: usageValue, render: row => stack(usage(row), resets(row)) }, renewalColumn];
236
+ } else if (this.width >= 60) {
237
+ columns = [accountColumn(row => stack(account(row), status(row))), { key: 'usage', title: 'Usage', value: usageValue, render: row => stack(usage(row), resets(row)) }, renewalColumn];
238
+ } else {
239
+ columns = [accountColumn(account), { ...statusColumn, render: row => stack(status(row), usage(row), renewalLine(row), resets(row)) }];
240
+ }
241
+ await consoleTable(this.out, accountsArg, columns, { divider: row => row.id });
242
+ }
243
+
244
+ /** The Details table, paged across as many account columns as the width holds, with its notes beneath. */
245
+ private async details(accountsArg: TNamedAccount[]): Promise<void> {
246
+ const { sections, notes } = collectDetails(accountsArg);
247
+ const rows = [...sections.values()].flat();
248
+ const withValues = accountsArg.map((_, index) => index).filter(index => rows.some(row => row.values.has(index)));
249
+ if (withValues.length) {
250
+ process.stdout.write('\n');
251
+ if (this.width < 40) this.narrowDetails(accountsArg, withValues, sections);
252
+ else {
253
+ // Section rows span the whole table, so only metric labels size the first column. A table is one edge plus, per
254
+ // column, its content, two spaces of padding and a rule; pages take accounts in order while they fit.
255
+ const labelWidth = Math.max('Metric'.length, ...rows.map(row => row.label.length));
256
+ const columnWidth = (indexArg: number): number => 3 + Math.min(40, Math.max(accountsArg[indexArg].name.length, ...rows.map(row => row.values.get(indexArg)?.length ?? 0)));
257
+ const pages: number[][] = [];
258
+ let used = 0;
259
+ for (const index of withValues) {
260
+ if (!pages.length || used + columnWidth(index) > this.width - 1) { pages.push([]); used = 1 + labelWidth + 3; }
261
+ pages[pages.length - 1].push(index);
262
+ used += columnWidth(index);
263
+ }
264
+ for (const page of pages) await this.detailsPage(accountsArg, page, sections);
265
+ }
266
+ }
267
+ if (notes.length) {
268
+ if (!withValues.length) process.stdout.write('\n');
269
+ for (const note of notes) consoleNote(note);
270
+ }
271
+ }
272
+
273
+ private async detailsPage(accountsArg: TNamedAccount[], indicesArg: number[], sectionsArg: Map<string, IDetailRow[]>): Promise<void> {
274
+ const lines: TDetailLine[] = [];
275
+ for (const [section, rows] of sectionsArg) {
276
+ const shown = rows.filter(row => indicesArg.some(index => row.values.has(index)));
277
+ if (shown.length) lines.push({ section }, ...shown);
278
+ }
279
+ const isRow = (lineArg: TDetailLine): lineArg is IDetailRow => 'values' in lineArg;
280
+ await consoleTable(this.out, lines, [
281
+ { key: 'metric', title: 'Metric', value: line => isRow(line) ? line.label : line.section,
282
+ colSpan: line => isRow(line) ? 1 : 1 + indicesArg.length, style: line => isRow(line) ? {} : { bold: true, foreground: 'cyan' } },
283
+ ...indicesArg.map((index): plugins.smartconsole.IBackendTableColumn<TDetailLine> => ({
284
+ key: `account-${index}`, title: accountsArg[index].name,
285
+ value: line => isRow(line) ? line.values.get(index) ?? UNAVAILABLE : line.section,
286
+ render: line => (isRow(line) ? line.values.get(index) : undefined) ?? dimText(UNAVAILABLE),
287
+ })),
288
+ ]);
289
+ }
290
+
291
+ private narrowDetails(accountsArg: TNamedAccount[], indicesArg: number[], sectionsArg: Map<string, IDetailRow[]>): void {
292
+ for (const index of indicesArg) {
293
+ process.stdout.write(` Account: ${accountsArg[index].name}\n`);
294
+ for (const [section, rows] of sectionsArg) {
295
+ for (const row of rows) if (row.values.has(index)) process.stdout.write(` - ${section} · ${row.label}: ${row.values.get(index)}\n`);
296
+ }
297
+ }
298
+ }
299
+
300
+ /** One Problems table: a changed active login first, then each account's merged problems, then the harness's own. */
301
+ private async problems(harnessArg: IHarnessAccountList, accountsArg: TNamedAccount[]): Promise<void> {
302
+ const rows: IProblemRow[] = harnessArg.credentialDrift.map((drift, index) => ({
303
+ group: `drift:${index}`,
304
+ accounts: accountsArg.find(account => account.id === drift.currentAccountId)?.name ?? (drift.currentLabel === null ? plainText(harnessArg.label) : plainText(drift.currentLabel)),
305
+ problem: credentialDriftProblem(harnessArg.id, drift),
306
+ }));
307
+ // Accounts with the same problems share one block.
308
+ const blocks = new Map<string, { names: string[]; problems: string[] }>();
309
+ for (const account of accountsArg) {
310
+ const problems = mergeProblems(account.status.problems);
311
+ if (!problems.length) continue;
312
+ const key = JSON.stringify(problems);
313
+ const block = blocks.get(key) ?? { names: [], problems };
314
+ block.names.push(account.name);
315
+ blocks.set(key, block);
316
+ }
317
+ // One line, not one name per line: a row-spanned cell lays its lines onto the rows it covers, which would read
318
+ // as "this account has this problem" for a block whose accounts all have all of them.
319
+ for (const [key, block] of blocks) rows.push(...block.problems.map(problem => ({ group: key, accounts: block.names.join(', '), problem })));
320
+ if (harnessArg.saveUnavailableReason !== null) rows.push({ group: 'harness', accounts: plainText(harnessArg.label), problem: plainText(harnessArg.saveUnavailableReason) });
321
+ if (!rows.length) return;
322
+ process.stdout.write('\n');
323
+ await consoleTable(this.out, rows, [
324
+ { key: 'account', title: 'Account', value: row => row.accounts, rowSpan: plugins.smartconsole.rowSpansBy(rows, row => row.group) },
325
+ { key: 'problem', title: 'Problem', value: row => row.problem, style: row => ({ foreground: row.group.startsWith('drift:') ? 'orange' : undefined }) },
326
+ ], { divider: row => row.group });
327
+ }
328
+ }
@@ -65,7 +65,7 @@ export class AuthSwitchLogin {
65
65
 
66
66
  /** A hosted or terminal UI sees only the prompt and saved-account outcome. */
67
67
  export const beginSavedOpenAiLogin = async (
68
- save: (result: IAuthLoginResult) => IHarnessLoginResult,
68
+ save: (result: IAuthLoginResult) => IHarnessLoginResult | Promise<IHarnessLoginResult>,
69
69
  options: IHarnessLoginOptions,
70
70
  fetcher?: typeof fetch,
71
71
  ): Promise<IHarnessLoginHandle> => {
@@ -22,6 +22,10 @@ export class OpenCodeHarness extends FileHarness {
22
22
  public readonly id = 'opencode';
23
23
  public readonly label = 'OpenCode';
24
24
  public readonly loginHint = 'Use authswitch opencode login for OpenAI; use opencode auth login for other providers.';
25
+ /** OpenCode loads provider credentials at startup (1.18.28), so a running instance keeps its login until restarted. */
26
+ public readonly liveSwap = false;
27
+ /** Only its OpenAI login reports usage, and a supervisor (AGL) restarts it around a switch, so a watch leaves it alone. */
28
+ public readonly autoSwitch = false;
25
29
  public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
26
30
  protected readonly store: CredentialStore;
27
31
  private readonly file: string;
@@ -47,9 +51,9 @@ export class OpenCodeHarness extends FileHarness {
47
51
  return account ? this.openAiInfo(account.credential).accountId : undefined;
48
52
  }
49
53
  public beginLogin(options: IHarnessLoginOptions = { providerId: 'openai', flow: 'device' }) {
50
- return beginSavedOpenAiLogin(result => {
51
- const saved = this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
52
- const account = this.readState().accounts.find(account => account.id === saved.accountId)!;
54
+ return beginSavedOpenAiLogin(async result => {
55
+ const saved = await this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
56
+ const account = (await this.readState()).accounts.find(account => account.id === saved.accountId)!;
53
57
  return { outcome: { lines: [`Saved OpenAI login for ${plainText(account.label)}. Select it to switch OpenCode.`], problems: [] }, account: { id: account.id, label: account.label, slotId: account.slotId } };
54
58
  }, options, this.fetcher);
55
59
  }