@modelprofile.com/authswitch 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/accounts.d.ts +25 -2
  3. package/dist_ts/accounts.js +112 -15
  4. package/dist_ts/classes.accountlist.d.ts +10 -3
  5. package/dist_ts/classes.accountlist.js +71 -34
  6. package/dist_ts/classes.claudecodeharness.d.ts +2 -2
  7. package/dist_ts/classes.claudecodeharness.js +3 -3
  8. package/dist_ts/classes.claudestatus.d.ts +2 -2
  9. package/dist_ts/classes.claudestatus.js +7 -6
  10. package/dist_ts/classes.cli.d.ts +15 -0
  11. package/dist_ts/classes.cli.js +51 -16
  12. package/dist_ts/classes.codexharness.d.ts +2 -2
  13. package/dist_ts/classes.codexharness.js +3 -3
  14. package/dist_ts/classes.codexstatus.d.ts +3 -3
  15. package/dist_ts/classes.codexstatus.js +12 -11
  16. package/dist_ts/classes.fileharness.d.ts +2 -2
  17. package/dist_ts/classes.limits.js +56 -18
  18. package/dist_ts/classes.opencodeharness.d.ts +2 -2
  19. package/dist_ts/classes.opencodeharness.js +3 -3
  20. package/dist_ts/classes.tui.js +7 -4
  21. package/dist_ts/interfaces.harness.d.ts +17 -5
  22. package/dist_ts/interfaces.list.d.ts +5 -1
  23. package/package.json +2 -2
  24. package/readme.md +102 -22
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/accounts.ts +105 -12
  27. package/ts/classes.accountlist.ts +74 -35
  28. package/ts/classes.claudecodeharness.ts +3 -3
  29. package/ts/classes.claudestatus.ts +7 -6
  30. package/ts/classes.cli.ts +56 -15
  31. package/ts/classes.codexharness.ts +3 -3
  32. package/ts/classes.codexstatus.ts +12 -11
  33. package/ts/classes.fileharness.ts +2 -2
  34. package/ts/classes.limits.ts +63 -19
  35. package/ts/classes.opencodeharness.ts +3 -3
  36. package/ts/classes.tui.ts +6 -3
  37. package/ts/interfaces.harness.ts +19 -2
  38. package/ts/interfaces.list.ts +5 -1
@@ -1,10 +1,11 @@
1
1
  import * as plugins from './plugins.js';
2
- import { accountQuotaSummary, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
2
+ import { accountQuotaSummary, accountTypeText, orderedUsageWindows, readAccountRows, duration, until } from './accounts.js';
3
3
  import type { IAuthHarness, IHarnessAccountStatus, IHarnessStatusFact } from './interfaces.harness.js';
4
4
  import type { IAccountList, IHarnessAccountList } from './interfaces.list.js';
5
5
  import { bold, dim, orange, plainText } from './formatting.js';
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. */
@@ -53,20 +54,60 @@ 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, and it repeats a
64
+ * spanning cell on every row it covers. A column's `render` content is what both layouts show.
65
+ */
57
66
  export const consoleTable = async <TRow>(
58
67
  outArg: plugins.smartconsole.SmartConsole,
59
68
  rowsArg: TRow[],
60
69
  columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[],
70
+ groupsArg?: NoInfer<plugins.smartconsole.IBackendTableGroups<TRow>>,
61
71
  ): Promise<void> => {
62
72
  if (!rowsArg.length) return;
63
- if (consoleWidth() < 40) {
64
- for (const row of rowsArg) {
65
- process.stdout.write(columnsArg.map(column => ` ${column.title}: ${String(column.value(row) ?? 'Unavailable').replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
66
- }
73
+ const width = consoleWidth();
74
+ if (width < 40) {
75
+ // smartconsole's run semantics: null and undefined are the same key.
76
+ const dividerKeys = rowsArg.map(row => groupsArg?.divider?.(row) ?? null);
77
+ const rule = ` ${dim('─'.repeat(Math.max(1, width - 4)))}\n\n`;
78
+ const content = (column: plugins.smartconsole.IBackendTableColumn<TRow>, row: TRow): string => column.render
79
+ ? plugins.smartconsole.color.plain(column.render(row)) : String(column.value(row) ?? 'Unavailable');
80
+ rowsArg.forEach((row, index) => {
81
+ if (index > 0 && dividerKeys[index] !== dividerKeys[index - 1]) process.stdout.write(rule);
82
+ process.stdout.write(columnsArg.map(column => ` ${column.title}: ${content(column, row).replace(/\n/g, '\n ')}`).join('\n') + '\n\n');
83
+ });
67
84
  return;
68
85
  }
69
- await outArg.table(rowsArg, { columns: columnsArg, overflow: 'wrap', theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } } });
86
+ await outArg.table(rowsArg, {
87
+ columns: columnsArg, overflow: 'wrap', groups: groupsArg,
88
+ theme: { header: { bold: true, foreground: 'cyan' }, border: { dim: true } },
89
+ });
90
+ };
91
+
92
+ /** List tables that repeat one account across rows divide it from the next account by its id. */
93
+ const accountDividers: plugins.smartconsole.IBackendTableGroups<{ accountId: string | null }> = { divider: row => row.accountId };
94
+
95
+ /**
96
+ * Names every account of one harness by its label, numbering each later account that repeats a label
97
+ * (`alice@example.com (2)`), so all tables name an account alike and the metric columns stay distinct.
98
+ */
99
+ const nameAccounts = (accountsArg: TListedAccount[]): TNamedAccount[] => {
100
+ const labels = accountsArg.map(account => plainText(account.label));
101
+ const taken = new Set(labels);
102
+ return accountsArg.map((account, index) => {
103
+ const label = labels[index];
104
+ let name = label;
105
+ if (labels.indexOf(label) < index) {
106
+ for (let ordinal = 2; taken.has(name); ordinal++) name = `${label} (${ordinal})`;
107
+ taken.add(name);
108
+ }
109
+ return { ...account, name };
110
+ });
70
111
  };
71
112
 
72
113
  /** Shared, provider-independent list presentation. Adapters supply grouping metadata. */
@@ -75,10 +116,6 @@ export class AccountListRenderer {
75
116
  private get width(): number { return consoleWidth(); }
76
117
  private heading(textArg: string): void { consoleHeading(textArg); }
77
118
 
78
- private async table<TRow>(rowsArg: TRow[], columnsArg: plugins.smartconsole.IBackendTableColumn<TRow>[]): Promise<void> {
79
- await consoleTable(this.out, rowsArg, columnsArg);
80
- }
81
-
82
119
  public async render(listArg: IAccountList): Promise<void> {
83
120
  const now = Date.parse(listArg.generatedAt);
84
121
  if (!listArg.harnesses.length) process.stdout.write('No harnesses registered.\n');
@@ -92,58 +129,60 @@ export class AccountListRenderer {
92
129
  process.stdout.write(`${dim(plainText(harness.saveUnavailableReason ?? 'No known accounts. ' + harness.loginHint))}\n`);
93
130
  continue;
94
131
  }
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 })));
132
+ const accounts = nameAccounts(harness.accounts);
133
+ await this.overview(accounts, now);
134
+ const schedules = accounts.flatMap(account => orderedUsageWindows(account.status.summary?.usageWindows).map(window => ({ accountId: account.id, account: account.name, window })));
97
135
  for (const feature of [false, true]) {
98
136
  const scopedSchedules = schedules.filter(row => (row.window.scope === 'feature') === feature);
99
137
  if (!scopedSchedules.length) continue;
100
138
  this.heading(feature ? 'Feature-specific limits' : 'Usage & reset schedule');
101
- await this.table(scopedSchedules, [
139
+ await consoleTable(this.out, scopedSchedules, [
102
140
  { key: 'account', title: 'Account', value: row => row.account },
103
141
  { key: 'window', title: 'Window', value: row => `${plainText(row.window.label)} (${duration(row.window.durationSeconds)})` },
104
142
  { key: 'usage', title: 'Usage', value: row => `${row.window.usedPercent}% used\n${Math.max(0, 100 - row.window.usedPercent)}% left` },
105
143
  { key: 'reset', title: 'Reset in', value: row => until(row.window.resetAt, now) },
106
- ]);
144
+ ], accountDividers);
107
145
  }
108
- const resets = harness.accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ account: plainText(account.label), reset })));
146
+ const resets = accounts.flatMap(account => (account.status.summary?.resets?.details ?? []).map(reset => ({ accountId: account.id, account: account.name, reset })));
109
147
  if (resets.length) {
110
148
  this.heading('Earned reset details');
111
- await this.table(resets, [
149
+ await consoleTable(this.out, resets, [
112
150
  { key: 'account', title: 'Account', value: row => row.account },
113
151
  { key: 'kind', title: 'Type', value: row => plainText(row.reset.kind) },
114
152
  { key: 'status', title: 'Status', value: row => plainText(row.reset.status) },
115
153
  { 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
- ]);
154
+ ], accountDividers);
117
155
  }
118
156
  const groups = new Map<string, IDisplayFact[]>();
119
157
  const add = (section: string, fact: IDisplayFact) => { const group = groups.get(section) ?? []; group.push(fact); groups.set(section, group); };
120
- harness.accounts.forEach((account, index) => {
158
+ accounts.forEach((account, index) => {
121
159
  for (const fact of account.status.facts) {
122
160
  if (!this.covered(fact, account.status)) add(plainText(fact.section ?? 'Additional information'), { account: index, label: plainText(fact.label), value: plainText(fact.value) });
123
161
  }
124
162
  });
125
- for (const [section, facts] of groups) await this.facts(section, facts, harness.accounts);
126
- const saved = harness.accounts.flatMap((account, index) => [
163
+ for (const [section, facts] of groups) await this.facts(section, facts, accounts);
164
+ const saved = accounts.flatMap((account, index) => [
127
165
  ...(account.savedAt ? [{ account: index, label: 'Saved (UTC)', value: plainText(account.savedAt) }] : []),
128
166
  ...account.details.map((detail, detailIndex) => ({ account: index, label: `Note ${detailIndex + 1}`, value: plainText(detail) })),
129
167
  ]);
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) });
168
+ await this.facts('Saved logins', saved, accounts);
169
+ const issues: { accountId: string | null; account: string; issue: string }[] = accounts.flatMap(account =>
170
+ account.status.problems.map(problem => ({ accountId: account.id, account: account.name, issue: plainText(problem) })));
171
+ if (harness.saveUnavailableReason) issues.push({ accountId: null, account: 'Harness', issue: plainText(harness.saveUnavailableReason) });
133
172
  if (issues.length) {
134
173
  this.heading('Availability & actions');
135
- await this.table(issues, [
174
+ await consoleTable(this.out, issues, [
136
175
  { key: 'account', title: 'Account', value: row => row.account },
137
176
  { key: 'issue', title: 'Unavailable / action needed', value: row => row.issue },
138
- ]);
177
+ ], accountDividers);
139
178
  }
140
179
  }
141
180
  }
142
181
 
143
- private async overview(accountsArg: TListedAccount[], nowArg: number): Promise<void> {
182
+ private async overview(accountsArg: TNamedAccount[], nowArg: number): Promise<void> {
144
183
  const rows = accountsArg;
145
184
  const state = (account: TListedAccount) => `${account.isActive ? '* active\n' : ''}${account.isStashed ? 'saved' : 'not saved'}${account.status.problems.length ? '\n! partial status' : ''}`;
146
- const plan = (account: TListedAccount) => account.status.summary?.subscription ? `${plainText(account.status.summary.subscription.plan)} (${account.status.summary.subscription.source})${account.status.summary.subscription.source === 'stored' ? '\nunverified' : ''}` : 'Unavailable';
185
+ const plan = (account: TListedAccount) => accountTypeText(account.status.summary?.subscription) ?? 'Unavailable';
147
186
  const billing = (account: TListedAccount) => {
148
187
  const info = account.status.summary?.billing;
149
188
  if (!info) return 'Unavailable';
@@ -159,7 +198,7 @@ export class AccountListRenderer {
159
198
  const resets = (account: TListedAccount) => account.status.summary?.resets === undefined ? 'Unavailable' : String(account.status.summary.resets.available);
160
199
  const usage = (account: TListedAccount) => accountQuotaSummary(account, nowArg);
161
200
  const columns: plugins.smartconsole.IBackendTableColumn<typeof rows[number]>[] = [
162
- { key: 'account', title: 'Account', value: row => plainText(row.label), style: row => ({ bold: row.isActive }) },
201
+ { key: 'account', title: 'Account', value: row => row.name, style: row => ({ bold: row.isActive }) },
163
202
  ];
164
203
  if (this.width >= 100) columns.push(
165
204
  { key: 'state', title: 'Login', value: state, style: row => ({ foreground: row.isStashed ? 'green' : 'orange' }) },
@@ -174,7 +213,7 @@ export class AccountListRenderer {
174
213
  { key: 'usage', title: 'Usage / resets', value: row => `${usage(row)}\nEarned resets: ${resets(row)}` },
175
214
  );
176
215
  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);
216
+ await consoleTable(this.out, rows, columns);
178
217
  }
179
218
 
180
219
  private covered(factArg: IHarnessStatusFact, statusArg: IHarnessAccountStatus): boolean {
@@ -191,10 +230,10 @@ export class AccountListRenderer {
191
230
  }
192
231
 
193
232
  /** Compare like metrics across accounts, keeping duplicate labels as separate rows. */
194
- private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TListedAccount[]): Promise<void> {
233
+ private async facts(titleArg: string, factsArg: IDisplayFact[], accountsArg: TNamedAccount[]): Promise<void> {
195
234
  if (!factsArg.length) return;
196
235
  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));
236
+ 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
237
  const perPage = Math.max(1, Math.min(6, Math.floor((this.width - 30) / valueBudget)));
199
238
  const accountIndices = accountsArg.map((_, index) => index).filter(index => factsArg.some(fact => fact.account === index));
200
239
  for (let start = 0; start < accountIndices.length; start += perPage) {
@@ -212,12 +251,12 @@ export class AccountListRenderer {
212
251
  }
213
252
  if (this.width < 40) {
214
253
  for (const index of indices) {
215
- process.stdout.write(` Account: ${plainText(accountsArg[index].label)}\n`);
254
+ process.stdout.write(` Account: ${accountsArg[index].name}\n`);
216
255
  for (const metric of metrics.values()) if (metric.values.has(index)) process.stdout.write(` - ${metric.label}: ${metric.values.get(index)}\n`);
217
256
  }
218
- } else await this.table([...metrics.values()], [
257
+ } else await consoleTable(this.out, [...metrics.values()], [
219
258
  { 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' })),
259
+ ...indices.map(index => ({ key: String(index), title: accountsArg[index].name, value: (row: { values: Map<number, string> }) => row.values.get(index) ?? 'Not reported' })),
221
260
  ]);
222
261
  }
223
262
  }
@@ -4,7 +4,7 @@ import { CredentialStore, credentialRecord, credentialText, readCredentialDocume
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
6
6
  import { writeSecretFileAtomically } from './helpers.js';
7
- import type { IHarnessAccountStatus, IHarnessProcessControl } from './interfaces.harness.js';
7
+ import type { IHarnessAccountStatus, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
8
8
 
9
9
  export interface IClaudeCodeHarnessOptions {
10
10
  configDir?: string;
@@ -107,8 +107,8 @@ export class ClaudeCodeHarness extends FileHarness {
107
107
  throw new Error('Claude Code switch failed while updating account metadata. The outgoing login remains saved; check doctor before restarting.');
108
108
  }
109
109
  }
110
- public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
110
+ public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
111
111
  const account = this.readCredential(id);
112
- return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount));
112
+ return this.status.read(credentialRecord(account.credential.claudeAiOauth), credentialRecord(account.credential.oauthAccount), optionsArg);
113
113
  }
114
114
  }
@@ -1,6 +1,6 @@
1
1
  import { credentialRecord, credentialText } from './classes.credentialstore.js';
2
2
  import { plainText } from './formatting.js';
3
- import type { IHarnessAccountStatus, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
3
+ import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessUsageWindow, TUsageSeverity } from './interfaces.harness.js';
4
4
 
5
5
  class ClaudeStatusError extends Error {}
6
6
 
@@ -105,8 +105,9 @@ const claudeUsageWindows = (usage: Record<string, unknown>): IClaudeUsageWindows
105
105
  /** Read-only Claude Code OAuth account endpoints. No refresh, inference, or browser session is used. */
106
106
  export class ClaudeAccountStatus {
107
107
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
108
- private async get(route: 'usage' | 'profile', accessToken: string): Promise<Record<string, unknown>> {
109
- const signal = AbortSignal.timeout(10_000);
108
+ private async get(route: 'usage' | 'profile', accessToken: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
109
+ const timeout = AbortSignal.timeout(10_000);
110
+ const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
110
111
  try {
111
112
  const response = await this.fetcher(`https://api.anthropic.com/api/oauth/${route}`, {
112
113
  method: 'GET', redirect: 'error', signal,
@@ -133,12 +134,12 @@ export class ClaudeAccountStatus {
133
134
  try { return credentialRecord(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
134
135
  catch { throw new ClaudeStatusError('The Claude account service returned invalid data.'); }
135
136
  } catch (error) {
136
- if (signal.aborted) throw new ClaudeStatusError('The Claude account request timed out.');
137
+ if (signal.aborted) throw new ClaudeStatusError(timeout.aborted ? 'The Claude account request timed out.' : 'The Claude account request was cancelled.');
137
138
  if (error instanceof ClaudeStatusError) throw error;
138
139
  throw new ClaudeStatusError('The Claude account service could not be reached.');
139
140
  }
140
141
  }
141
- public async read(oauth: Record<string, unknown>, account: Record<string, unknown>): Promise<IHarnessAccountStatus> {
142
+ public async read(oauth: Record<string, unknown>, account: Record<string, unknown>, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
142
143
  const result: IHarnessAccountStatus = { facts: [], problems: [] };
143
144
  const storedPlan = credentialText(oauth.subscriptionType);
144
145
  if (storedPlan) result.summary = { subscription: { plan: storedPlan, source: 'stored' } };
@@ -148,7 +149,7 @@ export class ClaudeAccountStatus {
148
149
  }
149
150
  const token = credentialText(oauth.accessToken);
150
151
  if (!token) return { facts: [], problems: ['No usable Claude Code access token is available.'] };
151
- const [profile, usage] = await Promise.allSettled([this.get('profile', token), this.get('usage', token)]);
152
+ const [profile, usage] = await Promise.allSettled([this.get('profile', token, optionsArg.signal), this.get('usage', token, optionsArg.signal)]);
152
153
  if (profile.status === 'fulfilled') {
153
154
  try {
154
155
  const user = credentialRecord(profile.value.account);
package/ts/classes.cli.ts CHANGED
@@ -7,7 +7,7 @@ import { AuthSwitchTui } from './classes.tui.js';
7
7
  import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
8
  import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
9
9
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
10
- import { credentialDriftNote, duration, orderedUsageWindows, until } from './accounts.js';
10
+ import { credentialDriftNote, duration, orderedUsageWindows, readAccountBadges, until } from './accounts.js';
11
11
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
12
12
  import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
13
13
  import type { CodexSwitcher } from './classes.codexswitcher.js';
@@ -25,6 +25,18 @@ const STOP_COMMANDS = ['use', 'stash'];
25
25
  /** Read-only overviews that work across every registered harness and support --json. */
26
26
  const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
27
27
 
28
+ /** An account as prompts and reports name it: its label, qualified by its credential slot when it has one. */
29
+ const accountName = (accountArg: IHarnessAccount): string =>
30
+ `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}`;
31
+
32
+ /** The account a command acts on, named as it was offered, with the badge it was offered with. */
33
+ interface IChosenAccount {
34
+ id: string;
35
+ name: string;
36
+ /** ` (type:Max) (5h:63%) (w:88%)`, empty when no part is known, or null when it was not read. */
37
+ badge: string | null;
38
+ }
39
+
28
40
  /** Harness-independent command routing and interactive account management. */
29
41
  export class AuthSwitchCli {
30
42
  private readonly harnesses = new Map<string, IAuthHarness>();
@@ -406,7 +418,7 @@ ${bold('Environment')}
406
418
  for (const current of state.accounts.filter(account => account.isActive && !account.isStashed)) {
407
419
  const answer = await this.out.prompts.ask({
408
420
  name: 'saveCurrent', type: 'confirm',
409
- message: `${plainText(current.label)}${current.slotId ? ` (${plainText(current.slotId)})` : ''} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
421
+ message: `${accountName(current)} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
410
422
  });
411
423
  if (answer !== true) return 'declined';
412
424
  if (this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: true, accountId: current.id })) !== 0) return 'failed';
@@ -457,12 +469,12 @@ ${bold('Environment')}
457
469
  case 'list': code = await this.commandList([harnessArg]); break;
458
470
  case 'doctor': code = this.reportOutcome(await harnessArg.diagnose()); break;
459
471
  case 'drop': {
460
- const id = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, true, true);
461
- if (!id) break;
472
+ const target = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, { allowBack: true, includeIncomplete: true });
473
+ if (!target) break;
462
474
  const confirmation = await this.out.prompts.ask({
463
- name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(id)}? The active login stays as it is.`, default: false,
475
+ name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(target.id)}? The active login stays as it is.`, default: false,
464
476
  });
465
- if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(id));
477
+ if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(target.id));
466
478
  break;
467
479
  }
468
480
  case 'harness': return null;
@@ -492,7 +504,7 @@ ${bold('Environment')}
492
504
  if (accounts.length === 1) return accounts[0];
493
505
  if (!accounts.length) { process.stderr.write('No matching active login to save.\n'); return undefined; }
494
506
  if (!canPrompt()) { process.stderr.write(`Several logins are active. Use authswitch ${harness.id} stash <account/provider> [--keep].\n`); return undefined; }
495
- const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: `${plainText(account.label)}${account.slotId ? ` (${plainText(account.slotId)})` : ''}`, value: account.id })) });
507
+ const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: accountName(account), value: account.id })) });
496
508
  return accounts.find(account => account.id === id);
497
509
  }
498
510
 
@@ -538,6 +550,14 @@ ${bold('Environment')}
538
550
  return null;
539
551
  }
540
552
 
553
+ /**
554
+ * Activate a saved account and report what is now active with its badge.
555
+ *
556
+ * The badge is read once per command through the bounded status read: the chooser reads it before it
557
+ * offers the accounts and the report repeats it, while a target named on the command line is read only
558
+ * after its switch, so nothing waits on it. A read that fails or does not answer in time leaves the name
559
+ * alone and never decides the outcome.
560
+ */
541
561
  private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false, stopModeArg?: TStopMode): Promise<number> {
542
562
  let id: string | null = null;
543
563
  if (referenceArg) {
@@ -548,21 +568,42 @@ ${bold('Environment')}
548
568
  const offered = await this.offerToSave(harnessArg);
549
569
  if (offered !== 'ready') return offered === 'failed' ? 1 : 0;
550
570
  }
551
- id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
552
- if (id === null) return allowBackArg ? 0 : 1;
553
- return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }, stopModeArg));
571
+ const target = id === null
572
+ ? await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, { allowBack: allowBackArg, withBadges: true })
573
+ : await this.namedAccount(harnessArg, id);
574
+ if (target === null) return allowBackArg ? 0 : 1;
575
+ const code = this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: target.id }, stopModeArg));
576
+ if (code !== 0) return code;
577
+ const badge = target.badge ?? (await readAccountBadges(harnessArg, [target.id])).get(target.id) ?? '';
578
+ process.stdout.write(`Now active: ${bold(target.name)}${badge}\n`);
579
+ return code;
580
+ }
581
+
582
+ /**
583
+ * A switch target named on the command line; its badge is not read here, so the switch never waits on it.
584
+ *
585
+ * The reference already resolved to this id, so the name is presentation only: a state read that fails
586
+ * names the account by its id and leaves the switch, which reports any real credential problem, to run.
587
+ */
588
+ private async namedAccount(harnessArg: IAuthHarness, idArg: string): Promise<IChosenAccount> {
589
+ let account: IHarnessAccount | undefined;
590
+ try { account = (await harnessArg.readState()).accounts.find(item => item.id === idArg); }
591
+ catch { account = undefined; }
592
+ return { id: idArg, name: account ? accountName(account) : plainText(idArg), badge: null };
554
593
  }
555
594
 
556
- private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, allowBackArg: boolean, includeIncompleteArg = false): Promise<string | null> {
557
- const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (includeIncompleteArg && accountArg.savedAt !== null));
595
+ private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, optionsArg: { allowBack?: boolean; includeIncomplete?: boolean; withBadges?: boolean } = {}): Promise<IChosenAccount | null> {
596
+ const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (optionsArg.includeIncomplete === true && accountArg.savedAt !== null));
558
597
  if (accounts.length === 0) { process.stderr.write(`${red('no stashed accounts')} - run \`authswitch ${harnessArg.id} stash\` first\n`); return null; }
559
598
  if (!canPrompt()) { process.stderr.write(`${red('an account is required')} without an interactive terminal: authswitch ${harnessArg.id} use <account>\n`); return null; }
599
+ const badges = optionsArg.withBadges ? await readAccountBadges(harnessArg, accounts.map((accountArg) => accountArg.id)) : null;
600
+ const offered = accounts.map((accountArg): IChosenAccount => ({ id: accountArg.id, name: accountName(accountArg), badge: badges === null ? null : badges.get(accountArg.id) ?? '' }));
560
601
  const answer = await this.out.prompts.ask({
561
602
  name: 'account', type: 'list', message: messageArg,
562
603
  default: accounts.find((accountArg) => accountArg.isActive)?.id ?? accounts[0].id,
563
- choices: [...accounts.map((accountArg) => ({ name: `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}${accountArg.isActive ? ' (active)' : ''}`, value: accountArg.id })),
564
- ...(allowBackArg ? [{ name: 'Back', value: null }] : [])],
604
+ choices: [...accounts.map((accountArg, index) => ({ name: `${offered[index].name}${accountArg.isActive ? ' (active)' : ''}${offered[index].badge ?? ''}`, value: accountArg.id })),
605
+ ...(optionsArg.allowBack ? [{ name: 'Back', value: null }] : [])],
565
606
  });
566
- return typeof answer === 'string' ? answer : null;
607
+ return offered.find((accountArg) => accountArg.id === answer) ?? null;
567
608
  }
568
609
  }
@@ -2,7 +2,7 @@ import { CodexSwitcher } from './classes.codexswitcher.js';
2
2
  import { CodexAccountStatus } from './classes.codexstatus.js';
3
3
  import { CodexPreuse } from './classes.codexpreuse.js';
4
4
  import { PreuseError } from './preuse.js';
5
- import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
5
+ import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessStatusOptions } from './interfaces.harness.js';
6
6
  import type { IStashListing } from './interfaces.js';
7
7
  import { countPaired, enrollmentState } from './helpers.js';
8
8
  import { beginSavedOpenAiLogin } from './classes.login.js';
@@ -70,10 +70,10 @@ export class CodexHarness implements IAuthHarness {
70
70
  };
71
71
  }
72
72
 
73
- public async readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus> {
73
+ public async readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
74
74
  const credential = this.readAccountCredential(accountIdArg);
75
75
  if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };
76
- return this.accountStatus.read(credential.raw, credential.identity);
76
+ return this.accountStatus.read(credential.raw, credential.identity, optionsArg);
77
77
  }
78
78
 
79
79
  public async preuseAccount(accountIdArg: string, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult> {
@@ -1,5 +1,5 @@
1
1
  import type { ICodexIdentity } from './interfaces.js';
2
- import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
2
+ import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
3
3
  import { commitinfo } from './00_commitinfo_data.js';
4
4
 
5
5
  // Verified Chromium-compatible billing request profile, independent of any locally installed browser.
@@ -39,7 +39,7 @@ class StatusRequestError extends Error {}
39
39
  export class CodexAccountStatus {
40
40
  constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
41
41
 
42
- public async read(rawArg: string, identityArg: ICodexIdentity): Promise<IHarnessAccountStatus> {
42
+ public async read(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
43
43
  if (identityArg.kind !== 'chatgpt') return {
44
44
  facts: [{ label: 'Authentication', value: 'API key' }, { label: 'Subscription and usage', value: 'Not exposed by Codex for API-key logins' }], problems: [],
45
45
  };
@@ -47,18 +47,18 @@ export class CodexAccountStatus {
47
47
  try { accessToken = text(record(record(JSON.parse(rawArg)).tokens).access_token); }
48
48
  catch { return { facts: [], problems: ['The login has no usable access token. Log in again with Codex, then save the account.'] }; }
49
49
  if (!identityArg.accountId) return { facts: [], problems: ['The login has no account ID for a status lookup.'] };
50
- return this.readCredential(accessToken, identityArg.accountId, identityArg.planType);
50
+ return this.readCredential(accessToken, identityArg.accountId, identityArg.planType, optionsArg);
51
51
  }
52
52
 
53
53
  /** Read the ChatGPT account directly, independent of a harness's on-disk format. */
54
- public async readCredential(accessToken: string, accountId: string, planType?: string | null): Promise<IHarnessAccountStatus> {
54
+ public async readCredential(accessToken: string, accountId: string, planType?: string | null, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
55
55
  const result: IHarnessAccountStatus = { facts: [], problems: [] };
56
56
  const identityArg = { accountId, planType };
57
57
  const sections = await Promise.allSettled([
58
- this.get('wham/usage', accessToken, identityArg.accountId),
59
- this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId),
60
- this.get('wham/profiles/me', accessToken, identityArg.accountId),
61
- this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId),
58
+ this.get('wham/usage', accessToken, identityArg.accountId, optionsArg.signal),
59
+ this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId, optionsArg.signal),
60
+ this.get('wham/profiles/me', accessToken, identityArg.accountId, optionsArg.signal),
61
+ this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId, optionsArg.signal),
62
62
  ]);
63
63
  const usage = sections[0];
64
64
  if (usage.status === 'fulfilled' && usage.value.account_id != null && usage.value.account_id !== identityArg.accountId) {
@@ -92,8 +92,9 @@ export class CodexAccountStatus {
92
92
  return result;
93
93
  }
94
94
 
95
- private async get(pathArg: string, tokenArg: string, accountIdArg: string): Promise<Record<string, unknown>> {
96
- const signal = AbortSignal.timeout(10_000);
95
+ private async get(pathArg: string, tokenArg: string, accountIdArg: string, cancelArg?: AbortSignal): Promise<Record<string, unknown>> {
96
+ const timeout = AbortSignal.timeout(10_000);
97
+ const signal = cancelArg ? AbortSignal.any([cancelArg, timeout]) : timeout;
97
98
  try {
98
99
  const response = await this.fetcher(`https://chatgpt.com/backend-api/${pathArg}`, {
99
100
  method: 'GET', redirect: 'error', signal,
@@ -125,7 +126,7 @@ export class CodexAccountStatus {
125
126
  catch { throw new StatusRequestError('The service returned invalid status data.'); }
126
127
  } catch (errorArg) {
127
128
  // Never surface remote bodies, request headers, token strings or arbitrary fetch errors.
128
- if (signal.aborted) throw new Error('The status request timed out.');
129
+ if (signal.aborted) throw new Error(timeout.aborted ? 'The status request timed out.' : 'The status request was cancelled.');
129
130
  if (errorArg instanceof StatusRequestError) throw errorArg;
130
131
  throw new Error('The status service could not be reached.');
131
132
  }
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
2
2
  import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
3
3
  import { pauseSynchronously } from './helpers.js';
4
4
  import { plainText } from './formatting.js';
@@ -28,7 +28,7 @@ export abstract class FileHarness implements IAuthHarness {
28
28
  /** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
29
29
  protected abstract nativeSources(): (string | null)[];
30
30
  protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
31
- public abstract readAccountStatus(id: string): Promise<IHarnessAccountStatus>;
31
+ public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
32
32
 
33
33
  /**
34
34
  * A snapshot proven not to have been taken across a native write.