@modelprofile.com/authswitch 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +63 -13
  3. package/dist_ts/accounts.js +199 -31
  4. package/dist_ts/classes.accountlist.d.ts +0 -24
  5. package/dist_ts/classes.accountlist.js +2 -213
  6. package/dist_ts/classes.claudecodeharness.d.ts +61 -3
  7. package/dist_ts/classes.claudecodeharness.js +130 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +35 -0
  9. package/dist_ts/classes.claudecodelocks.js +117 -0
  10. package/dist_ts/classes.claudestatus.d.ts +17 -4
  11. package/dist_ts/classes.claudestatus.js +89 -73
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +29 -7
  15. package/dist_ts/classes.cli.js +172 -78
  16. package/dist_ts/classes.codexharness.d.ts +6 -2
  17. package/dist_ts/classes.codexharness.js +7 -3
  18. package/dist_ts/classes.codexstatus.d.ts +5 -4
  19. package/dist_ts/classes.codexstatus.js +36 -19
  20. package/dist_ts/classes.credentialstore.d.ts +28 -2
  21. package/dist_ts/classes.credentialstore.js +41 -12
  22. package/dist_ts/classes.fileharness.d.ts +39 -19
  23. package/dist_ts/classes.fileharness.js +58 -24
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +106 -32
  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 +6 -2
  31. package/dist_ts/classes.opencodeharness.js +9 -5
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +10 -6
  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 +76 -15
  49. package/dist_ts/interfaces.list.d.ts +7 -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 +360 -106
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +202 -34
  60. package/ts/classes.accountlist.ts +2 -216
  61. package/ts/classes.claudecodeharness.ts +126 -13
  62. package/ts/classes.claudecodelocks.ts +132 -0
  63. package/ts/classes.claudestatus.ts +88 -54
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +163 -63
  66. package/ts/classes.codexharness.ts +7 -3
  67. package/ts/classes.codexstatus.ts +29 -18
  68. package/ts/classes.credentialstore.ts +53 -9
  69. package/ts/classes.fileharness.ts +69 -31
  70. package/ts/classes.limits.ts +126 -33
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +9 -5
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +9 -5
  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 +78 -6
  84. package/ts/interfaces.list.ts +7 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -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> => {
@@ -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
 
@@ -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,8 +51,8 @@ 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));
54
+ return beginSavedOpenAiLogin(async result => {
55
+ const saved = await this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
52
56
  const account = 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);
@@ -100,12 +104,12 @@ export class OpenCodeHarness extends FileHarness {
100
104
  writeSecretFileAtomically(this.file, raw);
101
105
  if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
102
106
  }
103
- public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
107
+ public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
104
108
  const account = this.readCredential(id);
105
109
  if (account.slotId === 'openai' && account.credential.type === 'oauth') {
106
110
  const info = this.openAiInfo(account.credential);
107
111
  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);
112
+ return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
109
113
  }
110
114
  return { facts: [
111
115
  { section: 'Authentication', label: 'Provider', value: account.slotId },
@@ -1,4 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
+ import { authSwitchHome } from './classes.credentialstore.js';
2
3
  import type { IAuthHarness, IHarnessOutcome } from './interfaces.harness.js';
3
4
 
4
5
  export type TAuthSwitchMutation = { harnessId: string } & (
@@ -30,7 +31,7 @@ export type TAuthSwitchLocalPreparation = () => Promise<void>;
30
31
 
31
32
  export const authSwitchEnvironmentId = (env: NodeJS.ProcessEnv = process.env): string => plugins.crypto.createHash('sha256').update(JSON.stringify({
32
33
  home: plugins.os.homedir(),
33
- stash: plugins.path.resolve(env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')),
34
+ stash: plugins.path.resolve(authSwitchHome(env)),
34
35
  codex: plugins.path.resolve(env.CODEX_HOME || plugins.path.join(plugins.os.homedir(), '.codex')),
35
36
  data: plugins.path.resolve(env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local/share')),
36
37
  claude: plugins.path.resolve(env.CLAUDE_CONFIG_DIR || plugins.path.join(plugins.os.homedir(), '.claude')),
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';
@@ -65,11 +65,12 @@ export class AuthSwitchTui {
65
65
  *
66
66
  * The dashboard makes the same offer the command line does, with the same rule: only pids
67
67
  * enumerated for this user are signalled, the session running this dashboard is never one of
68
- * them, and declining still performs the switch.
68
+ * them, and declining still performs the switch. A harness that picks up swaps live is never
69
+ * offered a stop here; the dashboard has no explicit stop request.
69
70
  */
70
71
  const settleInstances = (screen: plugins.smartconsole.ITuiContext) => async (): Promise<void> => {
71
72
  const control = harness.processes;
72
- if (!control) return;
73
+ if (!control || harness.liveSwap === true) return;
73
74
  let running: IHarnessProcess[];
74
75
  try { running = control.list(); }
75
76
  catch { ui.logs.append(`Could not check for running ${plainText(harness.label)} processes.`); return; }
@@ -115,11 +116,14 @@ export class AuthSwitchTui {
115
116
  const verified = await harness.readState();
116
117
  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
118
  }
118
- if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}?`, { confirmLabel: 'Switch' })) return;
119
+ // The dashboard already holds this account's status, so the badge costs no further lookup.
120
+ const badge = accountBadge(rows.find(row => row.account.id === target.id)?.status);
121
+ if (!await screen.confirm(`Switch ${plainText(harness.label)} to ${plainText(target.label)}${badge}?`, { confirmLabel: 'Switch' })) return;
119
122
  if (screen.signal.aborted) return;
120
123
  const outcome = await this.operations.run(harness, { harnessId: harness.id, action: 'switch', accountId: target.id },
121
124
  message => screen.confirm(message), settleInstances(screen));
122
- await refresh(screen); showOutcome(outcome);
125
+ await refresh(screen);
126
+ if (showOutcome(outcome)) ui.logs.append(`Now active: ${plainText(target.label)}${badge}`);
123
127
  };
124
128
  const remove = async (screen: plugins.smartconsole.ITuiContext): Promise<void> => {
125
129
  const selected = table.selected;