@modelprofile.com/authswitch 6.1.0 → 6.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.
package/ts/classes.cli.ts CHANGED
@@ -8,16 +8,17 @@ import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchMutationRepla
8
8
  import { readAccountList } from './classes.accountlist.js';
9
9
  import { AccountListRenderer } from './classes.listrenderer.js';
10
10
  import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
11
- import { consoleTable } from './consoletable.js';
12
- import { accountName, credentialDriftNote, orderedUsageWindows, readAccountBadges, until, usagePercentText } from './accounts.js';
11
+ import { consoleHeading, consoleTable } from './consoletable.js';
12
+ import { accountName, credentialDriftNote, readAccountBadges, until, usagePercentText } from './accounts.js';
13
13
  import { describeHarnessProcesses, describeStopOutcome } from './classes.harnessprocesses.js';
14
- import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
14
+ import { defaultPreusePrompt, preuseAccounts, preuseCountsText, PreuseError, preuseTargets, validatePreuseOptions,
15
+ type IPreuseRunSummary, type TPreuseAccountResult, type TPreuseOutcome, type TPreuseRunEvent } from './preuse.js';
15
16
  import { parseCommandArgs, parseDurationOption, parseIntegerOption, UsageError } from './cliargs.js';
16
17
  import { AuthSwitchWatch, watchEventText } from './classes.watch.js';
17
18
  import { WatchBusyError, WatchLock } from './classes.watchlock.js';
18
19
  import { authSwitchHome } from './classes.credentialstore.js';
19
20
  import type { CodexSwitcher } from './classes.codexswitcher.js';
20
- import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome, IHarnessPreuseResult } from './interfaces.harness.js';
21
+ import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessProcess, IHarnessState, IHarnessStopOutcome } from './interfaces.harness.js';
21
22
  import { bold, dim, green, orange, plainText, red } from './formatting.js';
22
23
 
23
24
  const canPrompt = (): boolean =>
@@ -31,9 +32,56 @@ const STOP_COMMANDS = ['use', 'stash'];
31
32
  /** Overviews across every registered harness; none of them changes which account is in use, and all support --json. */
32
33
  const OVERVIEW_COMMANDS = ['list', 'ls', 'limits', 'active'];
33
34
  const WATCH_USAGE = 'Usage: authswitch [harness] watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]';
35
+ const PREUSE_USAGE = 'Usage: authswitch [harness] preuse <account>|--all [--prompt <text>] [--model <id>]';
36
+ /** How each outcome of a preuse run is titled in its results table. */
37
+ const PREUSE_OUTCOMES: Readonly<Record<TPreuseOutcome, string>> = { completed: 'Completed', skipped: 'Skipped', failed: 'Failed', interrupted: 'Interrupted' };
38
+ /** A cell of a preuse results row that has no value because nothing was sent for that account. */
39
+ const PREUSE_UNSENT = '-';
34
40
  const WATCH_INTERVAL = { defaultMs: 120_000, minMs: 60_000, maxMs: 86_400_000 };
35
41
  const WATCH_THRESHOLD = { default: 95, min: 50, max: 100 };
36
42
 
43
+ /** How one preuse run is asked for and reported; see `AuthSwitchCli.runPreuse`. */
44
+ interface IPreuseRunOptions {
45
+ prompt: string;
46
+ model?: string;
47
+ /** Number each account as it starts and end with the results table, instead of one account's reset schedule. */
48
+ several: boolean;
49
+ /** Report a stated refusal as a failure, which is the single named account's own contract. */
50
+ strict: boolean;
51
+ }
52
+
53
+ /** One account's row in the results table of a run over several accounts. */
54
+ interface IPreuseSummaryRow {
55
+ account: string;
56
+ outcome: string;
57
+ model: string;
58
+ tokens: string;
59
+ reset: string;
60
+ }
61
+
62
+ /**
63
+ * One account's results row, from what the run already read.
64
+ *
65
+ * An account that sent no prompt has no model, no token count and no deadline of its own, which the row
66
+ * says rather than filling in a zero. A completed prompt shows the first window of the schedule the run
67
+ * read afterwards, and says when the provider reported none or the lookup did not answer.
68
+ */
69
+ const preuseSummaryRow = (resultArg: TPreuseAccountResult, nowArg: number): IPreuseSummaryRow => {
70
+ const account = accountName(resultArg.account);
71
+ if (resultArg.outcome !== 'completed') {
72
+ return { account, outcome: PREUSE_OUTCOMES[resultArg.outcome], model: PREUSE_UNSENT, tokens: PREUSE_UNSENT, reset: PREUSE_UNSENT };
73
+ }
74
+ const window = resultArg.schedule.kind === 'reported' ? resultArg.schedule.windows[0] : undefined;
75
+ return {
76
+ account,
77
+ outcome: PREUSE_OUTCOMES.completed,
78
+ model: plainText(resultArg.result.model),
79
+ tokens: [resultArg.result.inputTokens, resultArg.result.outputTokens, resultArg.result.totalTokens].map(count => count ?? 'unreported').join('/'),
80
+ reset: window ? until(window.resetAt, nowArg)
81
+ : resultArg.schedule.kind === 'reported' ? 'None reported' : resultArg.schedule.kind === 'notRead' ? 'Not read' : 'Unavailable',
82
+ };
83
+ };
84
+
37
85
  /** The account a command acts on, named as it was offered, with the badge it was offered with. */
38
86
  interface IChosenAccount {
39
87
  id: string;
@@ -76,8 +124,9 @@ ${bold('Usage')}
76
124
  authswitch list --json print all account information as JSON
77
125
  authswitch limits one row per account and limit type: used % and reset countdown
78
126
  authswitch active one row per provider: which account is in use, and since when
79
- authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
80
- send one prompt through that account without switching
127
+ authswitch <harness> preuse <account>|--all [--prompt <text>] [--model <id>]
128
+ send one prompt through that account without switching;
129
+ --all does it for every account of the harness, one at a time
81
130
  authswitch watch [harness] [--interval <duration>] [--threshold <percent>] [--dry-run] [--once] [--json]
82
131
  check usage every 2m and switch to a better saved account
83
132
  when the active one reaches the threshold (95%)
@@ -119,7 +168,10 @@ account is used up, to the one usable again first. No session of yours is stoppe
119
168
  a Codex switch restarts Codex' own app-server; --dry-run only reports, --once checks once.
120
169
  One watch runs per AUTHSWITCH_HOME.
121
170
 
122
- Preuse consumes the selected account's quota. Its default prompt is:
171
+ Preuse consumes the selected account's quota, and --all consumes every account's. Accounts run
172
+ one at a time, each with one request and no retry; Ctrl+C stops before the next account starts.
173
+ It exits 1 when a prompt failed, 130 when it was interrupted, and 2 on a usage error; --all also
174
+ exits 0 when an account was refused for a stated reason. Its default prompt is:
123
175
  "${defaultPreusePrompt}"
124
176
 
125
177
  ${bold('Environment')}
@@ -321,18 +373,28 @@ ${bold('Environment')}
321
373
  }
322
374
  }
323
375
 
376
+ /**
377
+ * Send the preuse prompt through one named account, through an account chosen here, or through every
378
+ * account of the harness.
379
+ *
380
+ * `--all` walks the harness's own account list, one request at a time, and ends with a table of what
381
+ * each account did; a single named account keeps its own contract, in which anything but a completed
382
+ * prompt is a failure. Neither form switches, refreshes or writes a login.
383
+ */
324
384
  private async commandPreuse(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
325
385
  let reference: string | undefined;
386
+ let all: boolean;
326
387
  let prompt: string;
327
388
  let model: string | undefined;
328
389
  try {
329
- const parsed = parseCommandArgs(argsArg, { values: ['--prompt', '--model'], flags: [], maxPositionals: 1,
330
- usage: 'Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]' });
390
+ const parsed = parseCommandArgs(argsArg, { values: ['--prompt', '--model'], flags: ['--all'], maxPositionals: 1, usage: PREUSE_USAGE });
331
391
  [reference] = parsed.positionals;
392
+ all = parsed.flags.has('--all');
332
393
  prompt = parsed.values.get('--prompt') ?? defaultPreusePrompt;
333
394
  model = parsed.values.get('--model');
334
395
  validatePreuseOptions({ prompt, model });
335
- if (!reference && !canPrompt()) throw new UsageError('preuse requires an account outside an interactive terminal.');
396
+ if (all && reference !== undefined) throw new UsageError(`--all preuses every account of the harness, so it takes no account. ${PREUSE_USAGE}`);
397
+ if (!all && !reference && !canPrompt()) throw new UsageError('preuse requires an account or --all outside an interactive terminal.');
336
398
  } catch (error) {
337
399
  process.stderr.write(`${error instanceof UsageError || error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
338
400
  return 2;
@@ -341,63 +403,159 @@ ${bold('Environment')}
341
403
  if (!harness) return canPrompt() ? 0 : 2;
342
404
  if (!harness.preuseAccount) { process.stderr.write(`Preuse is not supported by ${plainText(harness.label)}.\n`); return 2; }
343
405
  const state = await harness.readState();
344
- let account: IHarnessAccount | undefined;
345
- if (reference) {
346
- const matches = state.accounts.filter(item => item.id === reference || item.label === reference);
347
- if (matches.length > 1) { process.stderr.write('That account reference is ambiguous; use an exact account ID.\n'); return 1; }
348
- account = matches[0];
349
- if (!account) {
350
- const id = await this.resolveAccount(harness, reference);
351
- if (id === null) return 1;
352
- account = state.accounts.find(item => item.id === id);
353
- }
354
- } else {
355
- const answer = await this.out.prompts.ask({
356
- name: 'preuseAccount', type: 'list', message: `Which ${harness.label} account should receive the preuse prompt? This consumes quota.`,
357
- choices: [...state.accounts.map(item => ({ name: plainText(item.label), value: item.id })), { name: 'Back', value: '' }],
358
- });
359
- if (!answer) return 0;
360
- account = state.accounts.find(item => item.id === answer);
406
+ if (all) {
407
+ const accounts = preuseTargets(state.accounts);
408
+ if (!accounts.length) { process.stderr.write(`${plainText(harness.label)} has no accounts to preuse.\n`); return 1; }
409
+ return await this.runPreuse(harness, accounts, { prompt, model, several: true, strict: false });
410
+ }
411
+ if (!reference) return await this.preuseFromPicker(harness, state, { prompt, model });
412
+ const matches = state.accounts.filter(item => item.id === reference || item.label === reference);
413
+ if (matches.length > 1) { process.stderr.write('That account reference is ambiguous; use an exact account ID.\n'); return 1; }
414
+ let account: IHarnessAccount | undefined = matches[0];
415
+ if (!account) {
416
+ const id = await this.resolveAccount(harness, reference);
417
+ if (id === null) return 1;
418
+ account = state.accounts.find(item => item.id === id);
361
419
  }
362
420
  if (!account) { process.stderr.write('The selected account is no longer available.\n'); return 1; }
421
+ return await this.runPreuse(harness, [account], { prompt, model, several: false, strict: true });
422
+ }
423
+
424
+ /**
425
+ * The account picker `preuse` shows without an account reference: one account, all of them, or Back.
426
+ *
427
+ * Choosing one account here is that account's consent, exactly as naming it on the command line is, and
428
+ * it keeps that command's contract. Choosing all of them spends quota on accounts the chooser did not
429
+ * name, so that answer is confirmed once and then runs as `--all` does, table and exit status included.
430
+ */
431
+ private async preuseFromPicker(harnessArg: IAuthHarness, stateArg: IHarnessState, optionsArg: { prompt: string; model?: string }): Promise<number> {
432
+ const accounts = preuseTargets(stateArg.accounts);
433
+ const answer = await this.out.prompts.ask({
434
+ name: 'preuseAccount', type: 'list', message: `Which ${harnessArg.label} account should receive the preuse prompt? This consumes quota.`,
435
+ choices: this.preuseChoices(accounts),
436
+ });
437
+ if (!answer) return 0;
438
+ const selected = this.preuseSelection(answer, accounts);
439
+ if (!selected.length) { process.stderr.write('The selected account is no longer available.\n'); return 1; }
440
+ const several = selected.length > 1;
441
+ if (several && !await this.confirmPreuse(selected)) return 0;
442
+ return await this.runPreuse(harnessArg, selected, { ...optionsArg, several, strict: !several });
443
+ }
444
+
445
+ /**
446
+ * The choices an interactive preuse offers: every account, one of them, or Back.
447
+ *
448
+ * The guide and the bare `preuse` command offer the same list with the same wording. An account id is an
449
+ * opaque string, so every choice says which kind of answer it carries instead of relying on a sentinel an
450
+ * id could collide with. A single account is offered alone: "all" of one account is that same account.
451
+ */
452
+ private preuseChoices(accountsArg: readonly IHarnessAccount[]): { name: string; value: string }[] {
453
+ return [
454
+ ...(accountsArg.length > 1 ? [{ name: `All accounts (${accountsArg.length})`, value: 'all' }] : []),
455
+ ...accountsArg.map(account => ({ name: accountName(account), value: `one:${account.id}` })),
456
+ { name: 'Back', value: '' },
457
+ ];
458
+ }
459
+
460
+ /** The accounts an answer names; empty when the chosen account is no longer there. */
461
+ private preuseSelection(answerArg: string, accountsArg: readonly IHarnessAccount[]): IHarnessAccount[] {
462
+ return answerArg === 'all' ? [...accountsArg] : accountsArg.filter(account => `one:${account.id}` === answerArg);
463
+ }
464
+
465
+ /** One confirmation, naming what the prompt goes to and that it consumes quota. */
466
+ private async confirmPreuse(accountsArg: readonly IHarnessAccount[]): Promise<boolean> {
467
+ return await this.out.prompts.ask({
468
+ name: 'confirmPreuse', type: 'confirm', default: false,
469
+ message: accountsArg.length === 1
470
+ ? `Send one preuse prompt through ${plainText(accountsArg[0].label)}? This consumes its quota.`
471
+ : `Send one preuse prompt through each of these ${accountsArg.length} accounts? This consumes quota on every one of them.`,
472
+ }) === true;
473
+ }
474
+
475
+ /**
476
+ * One preuse run and its report, for the command line and for the guide.
477
+ *
478
+ * Ctrl+C aborts the run: the account in flight is reported as interrupted and no further account is
479
+ * started, because a prompt that may already have been sent is never repeated. The exit status is 130
480
+ * for an interrupted run, 1 when an account failed -- and, for a single named account, when it was
481
+ * refused -- and 0 otherwise. Usage errors return 2 before anything reaches this.
482
+ */
483
+ private async runPreuse(harnessArg: IAuthHarness, accountsArg: readonly IHarnessAccount[], optionsArg: IPreuseRunOptions): Promise<number> {
363
484
  const controller = new AbortController();
364
485
  const cancel = () => controller.abort();
365
486
  process.once('SIGINT', cancel);
366
487
  process.once('SIGTERM', cancel);
367
488
  try {
368
- let result: IHarnessPreuseResult;
369
- try {
370
- process.stdout.write(`Preusing ${plainText(account.label)} (${plainText(harness.label)})…\n`);
371
- result = await harness.preuseAccount(account.id, { prompt, model, signal: controller.signal });
372
- } catch (error) {
373
- process.stderr.write(`${error instanceof PreuseError ? error.message : 'Preuse failed. Tokens may already have been consumed; check account status before trying again.'}\n`);
374
- return controller.signal.aborted ? 130 : 1;
375
- }
376
- // Once inference completes, status or presentation failures must not report it as failed.
377
- try {
378
- process.stdout.write(`Prompt completed with ${plainText(result.model)}. Tokens: ${result.inputTokens ?? 'unreported'} input, ${result.outputTokens ?? 'unreported'} output, ${result.totalTokens ?? 'unreported'} total.\n`);
379
- if (controller.signal.aborted) return 0;
380
- const windows = orderedUsageWindows((await harness.readAccountStatus(account.id)).summary?.usageWindows);
381
- if (windows?.length) {
382
- const now = Date.now();
383
- process.stdout.write('Reset schedule reported after the prompt:\n');
384
- await consoleTable(this.out, windows, [
385
- // The label already names the window by its length, so the column never repeats it.
386
- { key: 'window', title: 'Window', value: row => plainText(row.label) },
387
- { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
388
- { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
389
- ]);
390
- } else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
391
- } catch {
392
- process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
393
- }
394
- return 0;
489
+ const summary = await preuseAccounts(harnessArg, accountsArg, { prompt: optionsArg.prompt, model: optionsArg.model, signal: controller.signal },
490
+ event => this.writePreuseEvent(harnessArg, event, optionsArg.several));
491
+ if (optionsArg.several) await this.writePreuseSummary(summary);
492
+ else if (summary.results[0]) await this.writePreuseSchedule(summary.results[0]);
493
+ if (summary.counts.interrupted || summary.notRun.length) return 130;
494
+ return summary.counts.failed || (optionsArg.strict && summary.counts.skipped) ? 1 : 0;
395
495
  } finally {
396
496
  process.removeListener('SIGINT', cancel);
397
497
  process.removeListener('SIGTERM', cancel);
398
498
  }
399
499
  }
400
500
 
501
+ /** One line per event; a run over several accounts numbers each account as it starts. */
502
+ private writePreuseEvent(harnessArg: IAuthHarness, eventArg: TPreuseRunEvent, severalArg: boolean): void {
503
+ if (eventArg.kind === 'started') {
504
+ const position = severalArg ? `[${eventArg.index + 1}/${eventArg.total}] ` : '';
505
+ process.stdout.write(`${position}Preusing ${plainText(eventArg.account.label)} (${plainText(harnessArg.label)})…\n`);
506
+ return;
507
+ }
508
+ if (eventArg.result.outcome !== 'completed') { process.stderr.write(`${eventArg.result.reason}\n`); return; }
509
+ const result = eventArg.result.result;
510
+ process.stdout.write(`Prompt completed with ${plainText(result.model)}. Tokens: ${result.inputTokens ?? 'unreported'} input, ${result.outputTokens ?? 'unreported'} output, ${result.totalTokens ?? 'unreported'} total.\n`);
511
+ }
512
+
513
+ /**
514
+ * The reset schedule one completed prompt reported.
515
+ *
516
+ * Once inference completes, a schedule that could not be read or displayed is reported as exactly that:
517
+ * a completed prompt is never repeated over a lookup or presentation failure.
518
+ */
519
+ private async writePreuseSchedule(resultArg: TPreuseAccountResult): Promise<void> {
520
+ if (resultArg.outcome !== 'completed' || resultArg.schedule.kind === 'notRead') return;
521
+ if (resultArg.schedule.kind === 'reported') {
522
+ const windows = [...resultArg.schedule.windows];
523
+ try {
524
+ if (!windows.length) { process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n'); return; }
525
+ const now = Date.now();
526
+ process.stdout.write('Reset schedule reported after the prompt:\n');
527
+ await consoleTable(this.out, windows, [
528
+ // The label already names the window by its length, so the column never repeats it.
529
+ { key: 'window', title: 'Window', value: row => plainText(row.label) },
530
+ { key: 'usage', title: 'Used', value: row => usagePercentText(row.usedPercent) },
531
+ { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
532
+ ]);
533
+ return;
534
+ } catch { /* A schedule that could not be displayed is reported below, like one that could not be read. */ }
535
+ }
536
+ process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
537
+ }
538
+
539
+ /** What every account of a run did, in one table. The rows repeat readings the run already took. */
540
+ private async writePreuseSummary(summaryArg: IPreuseRunSummary): Promise<void> {
541
+ const now = Date.now();
542
+ const rows: IPreuseSummaryRow[] = [
543
+ ...summaryArg.results.map(result => preuseSummaryRow(result, now)),
544
+ ...summaryArg.notRun.map((account): IPreuseSummaryRow => ({
545
+ account: accountName(account), outcome: 'Not run', model: PREUSE_UNSENT, tokens: PREUSE_UNSENT, reset: PREUSE_UNSENT,
546
+ })),
547
+ ];
548
+ consoleHeading('Preuse results');
549
+ await consoleTable(this.out, rows, [
550
+ { key: 'account', title: 'Account', value: row => row.account },
551
+ { key: 'outcome', title: 'Outcome', value: row => row.outcome },
552
+ { key: 'model', title: 'Model', value: row => row.model },
553
+ { key: 'tokens', title: 'Tokens in/out/total', value: row => row.tokens },
554
+ { key: 'reset', title: 'Next reset', value: row => row.reset },
555
+ ]);
556
+ process.stdout.write(`${preuseCountsText(summaryArg)}.\n`);
557
+ }
558
+
401
559
  /**
402
560
  * Watch usage and switch automatically; see `AuthSwitchWatch`. SIGINT and SIGTERM end it cleanly with status 0.
403
561
  * `--once` runs one check and exits 1 when a switch it attempted did not complete.
@@ -505,6 +663,7 @@ ${bold('Environment')}
505
663
  { name: 'Switch to a saved account', value: 'use' },
506
664
  { name: 'Save the current login', value: 'stash' },
507
665
  ...(harnessArg.beginLogin ? [{ name: 'Log in and save another account', value: 'login' }] : []),
666
+ ...(harnessArg.preuseAccount ? [{ name: 'Preuse an account or all accounts (consumes quota)', value: 'preuse' }] : []),
508
667
  { name: 'List accounts, subscription, usage and resets', value: 'list' },
509
668
  { name: harnessArg.diagnosticsLabel, value: 'doctor' },
510
669
  { name: 'Remove a saved account', value: 'drop' },
@@ -531,6 +690,7 @@ ${bold('Environment')}
531
690
  if (mode === 'keep' || mode === 'clear') code = this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: mode === 'keep', accountId: active.id }));
532
691
  break;
533
692
  }
693
+ case 'preuse': code = await this.guidePreuse(harnessArg); break;
534
694
  case 'list': code = await this.commandList([harnessArg]); break;
535
695
  case 'doctor': code = this.reportOutcome(await harnessArg.diagnose()); break;
536
696
  case 'drop': {
@@ -550,6 +710,28 @@ ${bold('Environment')}
550
710
  }
551
711
  }
552
712
 
713
+ /**
714
+ * Preuse from the guide: one account or all of them, confirmed once before anything is sent.
715
+ *
716
+ * The prompt consumes quota, so the confirmation names what it will be sent to, and Back or a declined
717
+ * confirmation returns to the menu having sent nothing. A refusal an adapter states is not a guide
718
+ * failure, so the guide stays open; a failed prompt ends it, as every other failed action does.
719
+ */
720
+ private async guidePreuse(harnessArg: IAuthHarness): Promise<number> {
721
+ const accounts = preuseTargets((await harnessArg.readState()).accounts);
722
+ if (!accounts.length) { process.stderr.write(`${red('no accounts to preuse')} - run \`authswitch ${harnessArg.id} stash\` first\n`); return 0; }
723
+ const answer = await this.out.prompts.ask({
724
+ name: 'preuseTarget', type: 'list', message: `Which ${harnessArg.label} account should receive the preuse prompt? This consumes quota.`,
725
+ choices: this.preuseChoices(accounts),
726
+ });
727
+ if (!answer) return 0;
728
+ const selected = this.preuseSelection(answer, accounts);
729
+ if (!selected.length) { process.stderr.write('The selected account is no longer available.\n'); return 0; }
730
+ // The guide confirms either answer: it is a menu one lands in, not a command that named its account.
731
+ if (!await this.confirmPreuse(selected)) return 0;
732
+ return await this.runPreuse(harnessArg, selected, { prompt: defaultPreusePrompt, several: selected.length > 1, strict: false });
733
+ }
734
+
553
735
  private printCurrent(harnessArg: IAuthHarness, stateArg: IHarnessState): void {
554
736
  process.stdout.write(`\n${bold(`Current ${harnessArg.label} account`)}\n`);
555
737
  const active = stateArg.accounts.filter(account => account.isActive);
@@ -56,6 +56,90 @@ const findFirstJwt = (valueArg: unknown, depthArg = 0): string | null => {
56
56
  return null;
57
57
  };
58
58
 
59
+ /**
60
+ * A short, non-reversible label for an API key so two different keys get two
61
+ * different stashes without the key itself ever being written down.
62
+ */
63
+ const fingerprint = (secretArg: string): string => {
64
+ let hash = 0x811c9dc5;
65
+ for (let index = 0; index < secretArg.length; index++) {
66
+ hash ^= secretArg.charCodeAt(index);
67
+ hash = Math.imul(hash, 0x01000193) >>> 0;
68
+ }
69
+ return hash.toString(16).padStart(8, '0');
70
+ };
71
+
72
+ /**
73
+ * Extracts the account identity from the content of an auth.json.
74
+ * Returns null when the content holds no recognisable credential.
75
+ *
76
+ * It stands outside the class because the stash store keys and guards its entries by the same identity and has
77
+ * no Codex installation to read: the credential itself is all this derivation needs.
78
+ */
79
+ export const codexIdentityFromRaw = (rawArg: string): ICodexIdentity | null => {
80
+ let parsed: unknown;
81
+ try {
82
+ parsed = JSON.parse(rawArg);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (!parsed || typeof parsed !== 'object') {
87
+ return null;
88
+ }
89
+ const record = parsed as Record<string, unknown>;
90
+
91
+ const tokens = (record.tokens ?? null) as Record<string, unknown> | null;
92
+ const idToken =
93
+ (tokens ? asString(tokens.id_token) : null) ?? findFirstJwt(record);
94
+
95
+ if (idToken) {
96
+ const claims = decodeJwtPayload(idToken) ?? {};
97
+ const authClaim = (claims['https://api.openai.com/auth'] ?? {}) as Record<string, unknown>;
98
+ const profileClaim = (claims['https://api.openai.com/profile'] ?? {}) as Record<string, unknown>;
99
+ const email =
100
+ asString(claims.email) ??
101
+ asString(profileClaim.email) ??
102
+ asString(authClaim.user_email);
103
+ const accountId =
104
+ asString(authClaim.chatgpt_account_id) ??
105
+ (tokens ? asString(tokens.account_id) : null) ??
106
+ asString(record.account_id);
107
+ if (email) {
108
+ return {
109
+ kind: 'chatgpt',
110
+ email: email.toLowerCase(),
111
+ accountId,
112
+ planType: asString(authClaim.chatgpt_plan_type),
113
+ userId: asString(authClaim.user_id) ?? asString(claims.sub),
114
+ };
115
+ }
116
+ if (accountId) {
117
+ // An OAuth credential we cannot label by email is still worth stashing;
118
+ // key it by account id so it round-trips instead of being silently dropped.
119
+ return {
120
+ kind: 'chatgpt',
121
+ email: `account:${accountId}`,
122
+ accountId,
123
+ planType: asString(authClaim.chatgpt_plan_type),
124
+ userId: asString(authClaim.user_id) ?? asString(claims.sub),
125
+ };
126
+ }
127
+ }
128
+
129
+ const apiKey = asString(record.OPENAI_API_KEY) ?? asString(record.openai_api_key);
130
+ if (apiKey) {
131
+ return {
132
+ kind: 'apikey',
133
+ email: `apikey:${fingerprint(apiKey)}`,
134
+ accountId: null,
135
+ planType: null,
136
+ userId: null,
137
+ };
138
+ }
139
+
140
+ return null;
141
+ };
142
+
59
143
  /**
60
144
  * Reads, writes and clears the Codex file credential store, and derives the
61
145
  * account identity used to key a stash.
@@ -75,81 +159,7 @@ export class CodexAuth {
75
159
  * Returns null when the file holds no recognisable credential.
76
160
  */
77
161
  public identityFromRaw(rawArg: string): ICodexIdentity | null {
78
- let parsed: unknown;
79
- try {
80
- parsed = JSON.parse(rawArg);
81
- } catch {
82
- return null;
83
- }
84
- if (!parsed || typeof parsed !== 'object') {
85
- return null;
86
- }
87
- const record = parsed as Record<string, unknown>;
88
-
89
- const tokens = (record.tokens ?? null) as Record<string, unknown> | null;
90
- const idToken =
91
- (tokens ? asString(tokens.id_token) : null) ?? findFirstJwt(record);
92
-
93
- if (idToken) {
94
- const claims = decodeJwtPayload(idToken) ?? {};
95
- const authClaim = (claims['https://api.openai.com/auth'] ?? {}) as Record<string, unknown>;
96
- const profileClaim = (claims['https://api.openai.com/profile'] ?? {}) as Record<string, unknown>;
97
- const email =
98
- asString(claims.email) ??
99
- asString(profileClaim.email) ??
100
- asString(authClaim.user_email);
101
- const accountId =
102
- asString(authClaim.chatgpt_account_id) ??
103
- (tokens ? asString(tokens.account_id) : null) ??
104
- asString(record.account_id);
105
- if (email) {
106
- return {
107
- kind: 'chatgpt',
108
- email: email.toLowerCase(),
109
- accountId,
110
- planType: asString(authClaim.chatgpt_plan_type),
111
- userId: asString(authClaim.user_id) ?? asString(claims.sub),
112
- };
113
- }
114
- if (accountId) {
115
- // An OAuth credential we cannot label by email is still worth stashing;
116
- // key it by account id so it round-trips instead of being silently dropped.
117
- return {
118
- kind: 'chatgpt',
119
- email: `account:${accountId}`,
120
- accountId,
121
- planType: asString(authClaim.chatgpt_plan_type),
122
- userId: asString(authClaim.user_id) ?? asString(claims.sub),
123
- };
124
- }
125
- }
126
-
127
- const apiKey = asString(record.OPENAI_API_KEY) ?? asString(record.openai_api_key);
128
- if (apiKey) {
129
- const fingerprint = this.fingerprint(apiKey);
130
- return {
131
- kind: 'apikey',
132
- email: `apikey:${fingerprint}`,
133
- accountId: null,
134
- planType: null,
135
- userId: null,
136
- };
137
- }
138
-
139
- return null;
140
- }
141
-
142
- /**
143
- * A short, non-reversible label for an API key so two different keys get two
144
- * different stashes without the key itself ever being written down.
145
- */
146
- private fingerprint(secretArg: string): string {
147
- let hash = 0x811c9dc5;
148
- for (let index = 0; index < secretArg.length; index++) {
149
- hash ^= secretArg.charCodeAt(index);
150
- hash = Math.imul(hash, 0x01000193) >>> 0;
151
- }
152
- return hash.toString(16).padStart(8, '0');
162
+ return codexIdentityFromRaw(rawArg);
153
163
  }
154
164
 
155
165
  /** Reads the active credential, if any. */
@@ -3,12 +3,21 @@ import { CodexAccountStatus } from './classes.codexstatus.js';
3
3
  import { CodexPreuse } from './classes.codexpreuse.js';
4
4
  import { PreuseError } from './preuse.js';
5
5
  import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessStatusOptions } from './interfaces.harness.js';
6
- import type { IStashListing } from './interfaces.js';
6
+ import type { IActiveAuth, ICodexIdentity, IStashEntry, IStashListing } from './interfaces.js';
7
7
  import { countPaired, enrollmentState } from './helpers.js';
8
8
  import { beginSavedOpenAiLogin } from './classes.login.js';
9
9
  import { plainText } from './formatting.js';
10
10
  import type { IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
11
11
 
12
+ /**
13
+ * Why a write into the stash failed, as its system error code. The message of a filesystem error names the path
14
+ * it was writing, and that path leads to a credential.
15
+ */
16
+ const writeFailureCause = (errorArg: unknown): string => {
17
+ const code = (errorArg as { code?: unknown } | null)?.code;
18
+ return typeof code === 'string' ? code : 'unknown cause';
19
+ };
20
+
12
21
  /** Codex-specific credentials, remote-control handling and service status. */
13
22
  export class CodexHarness implements IAuthHarness {
14
23
  public readonly id = 'codex';
@@ -43,21 +52,28 @@ export class CodexHarness implements IAuthHarness {
43
52
  }, options);
44
53
  }
45
54
 
55
+ /**
56
+ * The harness's accounts, with the saved copy of the active login brought up to date first.
57
+ *
58
+ * A saved login is one whose stored credential is readable and belongs to the account its record names. The
59
+ * exact tokens are not part of that: Codex' app-server refreshes the login it runs on its own schedule, so
60
+ * comparing bytes reported every saved active login as unsaved from its first refresh on, and the guide then
61
+ * offered to save what was already saved.
62
+ */
46
63
  public readState(): IHarnessState {
47
64
  const active = this.switcher.readActive();
65
+ const mirrorFailure = this.mirrorActiveLogin(active);
48
66
  const accounts: IHarnessAccount[] = this.switcher.list().map((entryArg) => {
49
67
  const raw = this.switcher.stashes.readAuth(entryArg.email);
50
- const identity = raw === null ? null : this.switcher.auth.identityFromRaw(raw);
51
- const valid = identity !== null && identity.email === entryArg.email && identity.accountId === entryArg.accountId && identity.kind === entryArg.kind;
52
- const isActive = active.identity !== null && active.identity.email === entryArg.email && active.identity.accountId === entryArg.accountId && active.identity.kind === entryArg.kind;
53
- const isStashed = valid && (!isActive || raw === active.raw);
68
+ const isStashed = this.recordOf(entryArg, raw === null ? null : this.switcher.auth.identityFromRaw(raw));
69
+ const isActive = this.recordOf(entryArg, active.identity);
54
70
  return {
55
71
  id: entryArg.email,
56
72
  label: entryArg.email,
57
73
  isActive,
58
74
  isStashed,
59
75
  savedAt: entryArg.stashedAt,
60
- details: [this.formatRemoteControl(entryArg).trim(), ...(!valid ? ['saved credential missing or does not match its metadata'] : !isStashed ? ['the current login has changed since it was saved'] : [])],
76
+ details: [this.formatRemoteControl(entryArg).trim(), ...(!isStashed ? ['saved credential missing or does not match its metadata'] : isActive && mirrorFailure !== null ? [mirrorFailure] : [])],
61
77
  };
62
78
  });
63
79
  if (active.identity && !accounts.some((accountArg) => accountArg.isActive)) {
@@ -74,6 +90,42 @@ export class CodexHarness implements IAuthHarness {
74
90
  };
75
91
  }
76
92
 
93
+ /** Whether a saved record is the record of this identity: same account, same email, same kind of login. */
94
+ private recordOf(entryArg: IStashEntry, identityArg: ICodexIdentity | null): boolean {
95
+ return identityArg !== null && entryArg.email === identityArg.email && entryArg.accountId === identityArg.accountId && entryArg.kind === identityArg.kind;
96
+ }
97
+
98
+ /**
99
+ * Writes the credential Codex is running on into that account's own saved record, and reports why it could
100
+ * not when the write failed.
101
+ *
102
+ * Codex rotates the tokens of the active login, and until now only a switch or a save wrote that rotation
103
+ * back -- `use()` re-saves the outgoing login, and re-selecting the active account updates its record before
104
+ * restoring the pairings. Every read now keeps the saved copy on the credential Codex last wrote, so the copy
105
+ * is one a switch can restore rather than one the service has moved past.
106
+ *
107
+ * The write goes only into the stash: Codex' own files are read, nothing is stopped or signalled, no daemon
108
+ * has to be down for it, and the record keeps its save time and its enrollments. A login with no saved record
109
+ * of its own is never saved implicitly -- `stash` is what saves a login -- and a record whose credential is
110
+ * missing or belongs to another account is left for `stash` to repair rather than quietly overwritten.
111
+ */
112
+ private mirrorActiveLogin(activeArg: IActiveAuth): string | null {
113
+ if (activeArg.source !== 'file' || activeArg.identity === null || activeArg.raw === null) return null;
114
+ const email = activeArg.identity.email;
115
+ const saved = this.switcher.stashes.readAuth(email);
116
+ if (saved === null || saved === activeArg.raw) return null;
117
+ const meta = this.switcher.stashes.readMeta(email);
118
+ if (meta === null || !this.recordOf(meta, activeArg.identity) || !this.recordOf(meta, this.switcher.auth.identityFromRaw(saved))) return null;
119
+ try {
120
+ this.switcher.stashes.replaceAuth(email, activeArg.raw);
121
+ return null;
122
+ } catch (errorArg) {
123
+ // A saved copy that could not be brought up to date is no reason to fail a read: the record still holds a
124
+ // credential of this account, and the next read tries again.
125
+ return `the current login could not be copied into its saved record (${writeFailureCause(errorArg)}); the record still holds the credential from the last save`;
126
+ }
127
+ }
128
+
77
129
  public async readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
78
130
  const credential = this.readAccountCredential(accountIdArg);
79
131
  if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };