@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,8 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
- import { compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
2
+ import { accountTypeText, compactSince, compactUntil, credentialDriftNote, orderedUsageWindows } from './accounts.js';
3
3
  import { consoleHeading, consoleTable } from './classes.accountlist.js';
4
4
  import type { IAccountList, IAccountLimitRow, IAccountLimits, IActiveAccountDrift, IActiveAccountRow, IActiveAccounts, IHarnessAccountList } from './interfaces.list.js';
5
- import type { IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
5
+ import type { IAccountType, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
6
6
  import { dim, plainText } from './formatting.js';
7
7
 
8
8
  type TListedAccount = IHarnessAccountList['accounts'][number];
@@ -46,13 +46,47 @@ const accountKeys = (harnessArg: IHarnessAccountList, accountArg: TListedAccount
46
46
  ...(accountArg.slotId === undefined ? {} : { slotId: plainText(accountArg.slotId) }),
47
47
  });
48
48
 
49
+ /** 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 => {
51
+ const subscription = accountArg.status.summary?.subscription;
52
+ return subscription === undefined ? null : { plan: plainText(subscription.plan), source: subscription.source };
53
+ };
54
+
49
55
  const harnessProblem = (harnessArg: IHarnessAccountList): string | null =>
50
56
  harnessArg.problems.length ? harnessArg.problems.map(plainText).join(' ') : null;
51
57
 
52
- /** Provider then account, keeping each account's windows in the order the adapter ranked them. */
53
- const byProviderThenAccount = <TRow extends { provider: string; account: string | null }>(rowsArg: TRow[]): TRow[] =>
54
- [...rowsArg].sort((left, right) => left.provider.localeCompare(right.provider)
55
- || (left.account ?? '').localeCompare(right.account ?? ''));
58
+ /** The identity fields both condensed row types share; labels are for display, these are for order and grouping. */
59
+ type TCondensedRow = Pick<IAccountLimitRow, 'harnessId' | 'provider' | 'account' | 'accountId' | 'slotId'>;
60
+
61
+ /**
62
+ * Provider then account, keeping each account's windows in the order the adapter ranked them.
63
+ *
64
+ * Every harness and every account stays one consecutive run for the table's grouping: a harness is placed
65
+ * by its first provider name, so no other label sorts between its slots, and ids break label ties, so two
66
+ * harnesses sharing a label or two accounts sharing an email never interleave.
67
+ */
68
+ const byProviderThenAccount = <TRow extends TCondensedRow>(rowsArg: TRow[]): TRow[] => {
69
+ const firstProvider = new Map<string, string>();
70
+ for (const row of rowsArg) {
71
+ const first = firstProvider.get(row.harnessId);
72
+ if (first === undefined || row.provider.localeCompare(first) < 0) firstProvider.set(row.harnessId, row.provider);
73
+ }
74
+ return [...rowsArg].sort((left, right) => firstProvider.get(left.harnessId)!.localeCompare(firstProvider.get(right.harnessId)!)
75
+ || left.harnessId.localeCompare(right.harnessId)
76
+ || left.provider.localeCompare(right.provider)
77
+ || (left.account ?? '').localeCompare(right.account ?? '')
78
+ || (left.slotId ?? '').localeCompare(right.slotId ?? '')
79
+ || (left.accountId ?? '').localeCompare(right.accountId ?? ''));
80
+ };
81
+
82
+ /** One colour line per harness: an OpenCode installation's slots share it. */
83
+ const harnessRuns = (rowArg: TCondensedRow): string => rowArg.harnessId;
84
+
85
+ /** One Provider cell per provider name within a harness; two harnesses that share a label stay apart. */
86
+ const providerRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.provider]);
87
+
88
+ /** One divider run per account identity; a provider-level row without an account is its own run. */
89
+ const accountRuns = (rowArg: TCondensedRow): string => JSON.stringify([rowArg.harnessId, rowArg.slotId ?? null, rowArg.accountId]);
56
90
 
57
91
  const noteFor = (rowArg: { provider: string; account: string | null; unavailableReason: string | null }): string | null =>
58
92
  rowArg.unavailableReason === null ? null
@@ -83,23 +117,23 @@ export const accountLimits = (listArg: IAccountList): IAccountLimits => {
83
117
  limitType: null, scope: null, windowSeconds: null, usedPercent: null, resetAt: null, resetsIn: UNAVAILABLE, severity: null, headline: false,
84
118
  } as const;
85
119
  if (problem !== null) {
86
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty, unavailableReason: problem });
120
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, ...empty, unavailableReason: problem });
87
121
  continue;
88
122
  }
89
123
  if (!harness.accounts.length) {
90
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, ...empty,
124
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, isActive: false, accountType: null, ...empty,
91
125
  unavailableReason: harness.saveUnavailableReason === null ? 'No accounts are known for this harness.' : plainText(harness.saveUnavailableReason) });
92
126
  continue;
93
127
  }
94
128
  for (const account of harness.accounts) {
95
129
  const windows = orderedUsageWindows(account.status.summary?.usageWindows);
96
130
  if (!windows.length) {
97
- rows.push({ ...accountKeys(harness, account), isActive: account.isActive, ...empty, unavailableReason: missingLimitReason(account) });
131
+ rows.push({ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account), ...empty, unavailableReason: missingLimitReason(account) });
98
132
  continue;
99
133
  }
100
134
  for (const window of windows) {
101
135
  rows.push({
102
- ...accountKeys(harness, account), isActive: account.isActive,
136
+ ...accountKeys(harness, account), isActive: account.isActive, accountType: accountType(account),
103
137
  limitType: plainText(window.label), scope: window.scope ?? 'account', windowSeconds: window.durationSeconds,
104
138
  usedPercent: window.usedPercent, resetAt: window.resetAt, resetsIn: compactUntil(window.resetAt, now, UNAVAILABLE),
105
139
  severity: window.severity ?? null, headline: window.headline === true, unavailableReason: null,
@@ -129,14 +163,14 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
129
163
  return index < 0 ? null : activeDrift(harness, drifts.splice(index, 1)[0]);
130
164
  };
131
165
  if (problem !== null) {
132
- rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
166
+ rows.push({ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null, savedAt: null, savedAgo: UNAVAILABLE, source: 'none', unavailableReason: problem, drift: null });
133
167
  continue;
134
168
  }
135
169
  const active = harness.accounts.filter(account => account.isActive);
136
170
  if (!active.length) {
137
171
  const stashed = harness.accounts.filter(account => account.isStashed);
138
172
  rows.push({
139
- harnessId: harness.id, provider: providerName(harness), account: null, accountId: null,
173
+ harnessId: harness.id, provider: providerName(harness), account: null, accountId: null, accountType: null,
140
174
  savedAt: null, savedAgo: UNAVAILABLE, source: stashed.length ? 'stash only' : 'none',
141
175
  unavailableReason: harness.saveUnavailableReason !== null ? plainText(harness.saveUnavailableReason)
142
176
  : stashed.length ? `No login is active; ${stashed.length} saved account(s) can be activated with authswitch ${plainText(harness.id)} use.`
@@ -146,7 +180,7 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
146
180
  } else {
147
181
  for (const account of active) {
148
182
  rows.push({
149
- ...accountKeys(harness, account),
183
+ ...accountKeys(harness, account), accountType: accountType(account),
150
184
  savedAt: account.savedAt, savedAgo: compactSince(account.savedAt, now, UNAVAILABLE),
151
185
  source: 'credential file',
152
186
  unavailableReason: account.isStashed ? null : 'This active login is not saved yet; authswitch cannot restore it after a switch.',
@@ -163,6 +197,13 @@ export const activeAccounts = (listArg: IAccountList): IActiveAccounts => {
163
197
  return { schemaVersion: 1, generatedAt: listArg.generatedAt, complete: listArg.complete, active, notes: [...collectNotes(active), ...driftNotes, ...unattached] };
164
198
  };
165
199
 
200
+ /**
201
+ * The Account cell: the label, and the account type beneath it, dim. An unknown type keeps its line as
202
+ * `n/a`, so every account cell has the same shape; a provider row without an account has no type to name.
203
+ */
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 }));
206
+
166
207
  const percent = (rowArg: IAccountLimitRow): string => rowArg.usedPercent === null ? UNAVAILABLE : `${rowArg.usedPercent}%`;
167
208
 
168
209
  const SEVERITY_COLORS: Record<TUsageSeverity, plugins.smartconsole.TColorName | undefined> = { normal: undefined, warning: 'orange', critical: 'red' };
@@ -184,15 +225,18 @@ export class CondensedRenderer {
184
225
 
185
226
  public async renderLimits(limitsArg: IAccountLimits): Promise<void> {
186
227
  consoleHeading('Account limits');
187
- if (!limitsArg.limits.length) { process.stdout.write(`${dim('No harnesses registered.')}\n`); return; }
188
- await consoleTable(this.out, limitsArg.limits, [
189
- { key: 'provider', title: 'Provider', value: row => row.provider },
190
- { key: 'account', title: 'Account', value: row => row.account ?? UNAVAILABLE, style: row => ({ bold: row.isActive }) },
228
+ // 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) },
191
235
  { key: 'limit', title: 'Limit type', value: row => row.limitType ?? UNAVAILABLE },
192
236
  { key: 'used', title: 'Used %', value: percent, align: 'right',
193
237
  style: row => ({ foreground: percentColor(row) }) },
194
238
  { key: 'reset', title: 'Resets in', value: row => row.resetsIn, align: 'right' },
195
- ]);
239
+ ], { divider: accountRuns, colorLine: harnessRuns });
196
240
  this.notes(limitsArg.notes, 'n/a — ');
197
241
  }
198
242
 
@@ -210,7 +254,7 @@ export class CondensedRenderer {
210
254
  style: row => ({ foreground: row.savedAt === null && row.account !== null ? 'orange' : undefined }) },
211
255
  { key: 'source', title: 'Source', value: row => row.drift === null ? row.source : `${row.source} (changed)`,
212
256
  style: row => ({ foreground: row.drift !== null ? 'red' : row.source === 'credential file' ? 'green' : 'orange' }) },
213
- ]);
257
+ ], { colorLine: harnessRuns });
214
258
  this.notes(activeArg.notes, 'note — ');
215
259
  }
216
260
  }
@@ -4,7 +4,7 @@ import { CredentialStore, credentialHash, credentialRecord, credentialText, read
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
5
  import { CodexAccountStatus } from './classes.codexstatus.js';
6
6
  import { writeSecretFileAtomically } from './helpers.js';
7
- import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl } from './interfaces.harness.js';
7
+ import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
8
8
  import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
9
9
  import { plainText } from './formatting.js';
10
10
 
@@ -100,12 +100,12 @@ export class OpenCodeHarness extends FileHarness {
100
100
  writeSecretFileAtomically(this.file, raw);
101
101
  if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
102
102
  }
103
- public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
103
+ public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
104
104
  const account = this.readCredential(id);
105
105
  if (account.slotId === 'openai' && account.credential.type === 'oauth') {
106
106
  const info = this.openAiInfo(account.credential);
107
107
  if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
108
- return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan);
108
+ return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
109
109
  }
110
110
  return { facts: [
111
111
  { section: 'Authentication', label: 'Provider', value: account.slotId },
package/ts/classes.tui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import type { IAuthHarness, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome } from './interfaces.harness.js';
3
- import { accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usageWindowName, until, readAccountRows, type IAccountRow } from './accounts.js';
3
+ import { accountBadge, accountDetails, accountPlan, accountQuotaSummary, accountResets, accountState, accountUsageWindows, usageWindowName, until, readAccountRows, type IAccountRow } from './accounts.js';
4
4
  import { plainText } from './formatting.js';
5
5
  import { AuthSwitchOperations } from './classes.operations.js';
6
6
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
@@ -115,11 +115,14 @@ export class AuthSwitchTui {
115
115
  const verified = await harness.readState();
116
116
  if (!verified.accounts.some(account => account.isActive && account.isStashed && account.id === active.id)) { footer.setText('The current login could not be verified as saved. Switching was cancelled.'); return; }
117
117
  }
118
- if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}?`, { confirmLabel: 'Switch' })) return;
118
+ // The dashboard already holds this account's status, so the badge costs no further lookup.
119
+ const badge = accountBadge(rows.find(row => row.account.id === target.id)?.status);
120
+ if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}${badge}?`, { confirmLabel: 'Switch' })) return;
119
121
  if (screen.signal.aborted) return;
120
122
  const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id },
121
123
  message => screen.confirm(message), settleInstances(screen));
122
- await refresh(screen); showOutcome(outcome);
124
+ await refresh(screen);
125
+ if (showOutcome(outcome)) ui.logs.append(`Now active: ${plainText(target.label)}${badge}`);
123
126
  };
124
127
  const remove = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
125
128
  const selected = table.selected;
@@ -127,9 +127,20 @@ export interface IHarnessUsageWindow {
127
127
  headline?: true;
128
128
  }
129
129
 
130
+ /**
131
+ * The provider's own name for an account's plan, and where that name comes from.
132
+ *
133
+ * `live` was read from the provider in this lookup; `stored` is the last name the saved login
134
+ * carries, which is the only answer available while a provider cannot be reached.
135
+ */
136
+ export interface IAccountType {
137
+ plan: string;
138
+ source: 'live' | 'stored';
139
+ }
140
+
130
141
  export interface IHarnessStatusSummary {
131
142
  /** A plan name does not imply an active billing status. */
132
- subscription?: { plan: string; source: 'live' | 'stored' };
143
+ subscription?: IAccountType;
133
144
  /** Live billing information only. Never infer these fields from a plan or entitlement expiry. */
134
145
  billing?: { hasActiveSubscription?: boolean; autoRenew?: boolean; renewsAt?: string; cancelsAt?: string; expiresAt?: string };
135
146
  /** Missing windows are unknown, not unused. Times are ISO 8601 UTC. */
@@ -146,6 +157,12 @@ export interface IHarnessOutcome {
146
157
  problems: string[];
147
158
  }
148
159
 
160
+ /** A read-only status lookup. */
161
+ export interface IHarnessStatusOptions {
162
+ /** Cancels the lookup's requests; an adapter still bounds each request with its own timeout. */
163
+ signal?: AbortSignal;
164
+ }
165
+
149
166
  /** A single text-only inference request. Credentials remain inside the adapter. */
150
167
  export interface IHarnessPreuseOptions {
151
168
  prompt: string;
@@ -171,7 +188,7 @@ export interface IAuthHarness {
171
188
  readState(): THarnessResult<IHarnessState>;
172
189
  /** Running instances of this harness, when the adapter owns their lifecycle. */
173
190
  readonly processes?: IHarnessProcessControl;
174
- readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus>;
191
+ readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
175
192
  readonly loginProviders?: IHarnessLoginProvider[];
176
193
  beginLogin?(options: IHarnessLoginOptions): Promise<IHarnessLoginHandle>;
177
194
  /** Optional quota-consuming capability; never changes the active login. */
@@ -1,4 +1,4 @@
1
- import type { IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
1
+ import type { IAccountType, IHarnessAccount, IHarnessAccountStatus, IHarnessCredentialDrift, TUsageSeverity } from './interfaces.harness.js';
2
2
 
3
3
  /** Credential-free, versioned output of list --json. Missing status fields stay omitted. */
4
4
  export interface IAccountList {
@@ -39,6 +39,8 @@ export interface IAccountLimitRow {
39
39
  accountId: string | null;
40
40
  slotId?: string;
41
41
  isActive: boolean;
42
+ /** The adapter's plan name (`max`, `pro`), sanitised but not display-formatted; null when none is known or the row names no account. */
43
+ accountType: IAccountType | null;
42
44
  /** The provider's own window name, or null when no limit data is available. */
43
45
  limitType: string | null;
44
46
  scope: 'account' | 'feature' | null;
@@ -70,6 +72,8 @@ export interface IActiveAccountRow {
70
72
  account: string | null;
71
73
  accountId: string | null;
72
74
  slotId?: string;
75
+ /** The adapter's plan name (`max`, `pro`), sanitised but not display-formatted; null when none is known or the row names no account. */
76
+ accountType: IAccountType | null;
73
77
  /** When authswitch last saved this login; null when it was never stashed. */
74
78
  savedAt: string | null;
75
79
  /** Human elapsed time for the same `savedAt`, relative to `generatedAt`. */