@modelprofile.com/authswitch 2.1.2

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 (95) hide show
  1. package/.smartconfig.json +34 -0
  2. package/cli.js +21 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/accounts.d.ts +31 -0
  6. package/dist_ts/accounts.js +74 -0
  7. package/dist_ts/classes.accountlist.d.ts +17 -0
  8. package/dist_ts/classes.accountlist.js +214 -0
  9. package/dist_ts/classes.claudecodeharness.d.ts +34 -0
  10. package/dist_ts/classes.claudecodeharness.js +123 -0
  11. package/dist_ts/classes.claudestatus.d.ts +8 -0
  12. package/dist_ts/classes.claudestatus.js +139 -0
  13. package/dist_ts/classes.cli.d.ts +31 -0
  14. package/dist_ts/classes.cli.js +584 -0
  15. package/dist_ts/classes.codexauth.d.ts +35 -0
  16. package/dist_ts/classes.codexauth.js +184 -0
  17. package/dist_ts/classes.codexdaemon.d.ts +38 -0
  18. package/dist_ts/classes.codexdaemon.js +122 -0
  19. package/dist_ts/classes.codexharness.d.ts +35 -0
  20. package/dist_ts/classes.codexharness.js +199 -0
  21. package/dist_ts/classes.codexhome.d.ts +25 -0
  22. package/dist_ts/classes.codexhome.js +60 -0
  23. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  24. package/dist_ts/classes.codexpreuse.js +90 -0
  25. package/dist_ts/classes.codexstatus.d.ts +20 -0
  26. package/dist_ts/classes.codexstatus.js +304 -0
  27. package/dist_ts/classes.codexswitcher.d.ts +61 -0
  28. package/dist_ts/classes.codexswitcher.js +226 -0
  29. package/dist_ts/classes.credentialstore.d.ts +32 -0
  30. package/dist_ts/classes.credentialstore.js +115 -0
  31. package/dist_ts/classes.fileharness.d.ts +47 -0
  32. package/dist_ts/classes.fileharness.js +119 -0
  33. package/dist_ts/classes.login.d.ts +42 -0
  34. package/dist_ts/classes.login.js +102 -0
  35. package/dist_ts/classes.opencodeharness.d.ts +34 -0
  36. package/dist_ts/classes.opencodeharness.js +125 -0
  37. package/dist_ts/classes.operations.d.ts +44 -0
  38. package/dist_ts/classes.operations.js +97 -0
  39. package/dist_ts/classes.remotecontrol.d.ts +46 -0
  40. package/dist_ts/classes.remotecontrol.js +169 -0
  41. package/dist_ts/classes.service.d.ts +47 -0
  42. package/dist_ts/classes.service.js +142 -0
  43. package/dist_ts/classes.stashstore.d.ts +63 -0
  44. package/dist_ts/classes.stashstore.js +192 -0
  45. package/dist_ts/classes.tui.d.ts +11 -0
  46. package/dist_ts/classes.tui.js +195 -0
  47. package/dist_ts/formatting.d.ts +8 -0
  48. package/dist_ts/formatting.js +13 -0
  49. package/dist_ts/helpers.d.ts +39 -0
  50. package/dist_ts/helpers.js +92 -0
  51. package/dist_ts/index.d.ts +22 -0
  52. package/dist_ts/index.js +27 -0
  53. package/dist_ts/interfaces.d.ts +145 -0
  54. package/dist_ts/interfaces.harness.d.ts +130 -0
  55. package/dist_ts/interfaces.harness.js +2 -0
  56. package/dist_ts/interfaces.js +2 -0
  57. package/dist_ts/interfaces.list.d.ts +19 -0
  58. package/dist_ts/interfaces.list.js +2 -0
  59. package/dist_ts/plugins.d.ts +14 -0
  60. package/dist_ts/plugins.js +18 -0
  61. package/dist_ts/preuse.d.ts +6 -0
  62. package/dist_ts/preuse.js +13 -0
  63. package/license.md +21 -0
  64. package/package.json +53 -0
  65. package/readme.md +502 -0
  66. package/ts/00_commitinfo_data.ts +8 -0
  67. package/ts/accounts.ts +78 -0
  68. package/ts/classes.accountlist.ts +211 -0
  69. package/ts/classes.claudecodeharness.ts +111 -0
  70. package/ts/classes.claudestatus.ts +99 -0
  71. package/ts/classes.cli.ts +479 -0
  72. package/ts/classes.codexauth.ts +199 -0
  73. package/ts/classes.codexdaemon.ts +137 -0
  74. package/ts/classes.codexharness.ts +233 -0
  75. package/ts/classes.codexhome.ts +68 -0
  76. package/ts/classes.codexpreuse.ts +76 -0
  77. package/ts/classes.codexstatus.ts +257 -0
  78. package/ts/classes.codexswitcher.ts +272 -0
  79. package/ts/classes.credentialstore.ts +101 -0
  80. package/ts/classes.fileharness.ts +120 -0
  81. package/ts/classes.login.ts +103 -0
  82. package/ts/classes.opencodeharness.ts +114 -0
  83. package/ts/classes.operations.ts +101 -0
  84. package/ts/classes.remotecontrol.ts +208 -0
  85. package/ts/classes.service.ts +152 -0
  86. package/ts/classes.stashstore.ts +223 -0
  87. package/ts/classes.tui.ts +138 -0
  88. package/ts/formatting.ts +14 -0
  89. package/ts/helpers.ts +109 -0
  90. package/ts/index.ts +28 -0
  91. package/ts/interfaces.harness.ts +112 -0
  92. package/ts/interfaces.list.ts +19 -0
  93. package/ts/interfaces.ts +158 -0
  94. package/ts/plugins.ts +20 -0
  95. package/ts/preuse.ts +15 -0
@@ -0,0 +1,479 @@
1
+ import * as plugins from './plugins.js';
2
+ import { commitinfo } from './00_commitinfo_data.js';
3
+ import { CodexHarness } from './classes.codexharness.js';
4
+ import { OpenCodeHarness } from './classes.opencodeharness.js';
5
+ import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
6
+ import { AuthSwitchTui } from './classes.tui.js';
7
+ import { AglAuthSwitchCoordinator, AuthSwitchOperations, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
8
+ import { AccountListRenderer, readAccountList } from './classes.accountlist.js';
9
+ import { duration, orderedUsageWindows, until } from './accounts.js';
10
+ import { defaultPreusePrompt, PreuseError, validatePreuseOptions } from './preuse.js';
11
+ import type { CodexSwitcher } from './classes.codexswitcher.js';
12
+ import type { IAuthHarness, IHarnessAccount, IHarnessOutcome, IHarnessState, IHarnessPreuseResult } from './interfaces.harness.js';
13
+ import { bold, dim, green, orange, plainText, red } from './formatting.js';
14
+
15
+ const canPrompt = (): boolean =>
16
+ process.stdin.isTTY === true && process.stdout.isTTY === true && !process.env.CI;
17
+ const COMMANDS = ['login', 'stash', 'list', 'ls', 'use', 'preuse', 'current', 'drop', 'rm', 'doctor'];
18
+
19
+ /** Harness-independent command routing and interactive account management. */
20
+ export class AuthSwitchCli {
21
+ private readonly harnesses = new Map<string, IAuthHarness>();
22
+ private console?: plugins.smartconsole.SmartConsole;
23
+ private running = false;
24
+ private readonly operations: AuthSwitchOperations;
25
+ private get out(): plugins.smartconsole.SmartConsole { return this.console ??= new plugins.smartconsole.SmartConsole(); }
26
+
27
+ constructor(harnessesArg?: IAuthHarness[] | CodexSwitcher, coordinatorArg?: TAuthSwitchCoordinator) {
28
+ const coordinator = new AglAuthSwitchCoordinator();
29
+ this.operations = new AuthSwitchOperations(coordinatorArg ?? (harnessesArg === undefined ? request => coordinator.coordinate(request) : undefined));
30
+ harnessesArg ??= [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()];
31
+ // Preserve the published single-Codex constructor at the composition boundary.
32
+ const harnesses = Array.isArray(harnessesArg) ? harnessesArg : [new CodexHarness(harnessesArg)];
33
+ for (const harness of harnesses) {
34
+ if (!/^[a-z][a-z0-9-]*$/.test(harness.id) || COMMANDS.includes(harness.id) || harness.id === 'help') {
35
+ throw new Error(`invalid harness name "${plainText(harness.id)}"`);
36
+ }
37
+ if (this.harnesses.has(harness.id)) throw new Error(`duplicate harness "${harness.id}"`);
38
+ this.harnesses.set(harness.id, harness);
39
+ }
40
+ }
41
+
42
+ private usage(): string {
43
+ return `${bold('authswitch')} ${dim(`v${commitinfo.version}`)}
44
+ Manage saved accounts across harnesses.
45
+
46
+ ${bold('Usage')}
47
+ authswitch open the interactive guide
48
+ authswitch list list all accounts with live subscription, usage and resets
49
+ authswitch <harness> list list accounts for one harness
50
+ authswitch list --json print all account information as JSON
51
+ authswitch <harness> preuse <account> [--prompt <text>] [--model <id>]
52
+ send one prompt through that account without switching
53
+ authswitch <harness> stash [account/provider] [--keep]
54
+ save one active login; --keep leaves it active
55
+ authswitch <harness> use [account] activate a saved account; prompts when omitted
56
+ authswitch <harness> current show the active account
57
+ authswitch <harness> login [provider]
58
+ sign in and save a new account without switching
59
+ authswitch <harness> drop <account> remove a saved account
60
+ authswitch <harness> doctor diagnose the harness
61
+
62
+ ${bold('Harnesses')}
63
+ ${[...this.harnesses.values()].map((harnessArg) => `${harnessArg.id} (${harnessArg.label})`).join(', ') || 'none registered'}
64
+ Unqualified commands use the sole harness, or ask you to choose one.
65
+
66
+ ${bold('Options')}
67
+ -h, --help show this help
68
+ -v, --version show the version
69
+ -i, --interactive open the guided account manager
70
+ --tui open the terminal management dashboard
71
+ --json JSON output for list / ls only
72
+
73
+ Preuse consumes the selected account's quota. Its default prompt is:
74
+ "${defaultPreusePrompt}"
75
+
76
+ ${bold('Environment')}
77
+ AUTHSWITCH_HOME root directory for saved accounts (default ~/.authswitch)
78
+ NO_COLOR disable coloured output
79
+ Other credential locations and executables are configured by each harness.
80
+ `;
81
+ }
82
+
83
+ public async run(argvArg: string[]): Promise<number> {
84
+ if (this.running) throw new Error('This CLI is already running.');
85
+ this.running = true;
86
+ const args = [...argvArg];
87
+ try {
88
+ // Parse command values before global flags: a prompt may itself contain a flag.
89
+ if (args[0] === 'preuse' || (this.harnesses.has(args[0]) && args[1] === 'preuse')) {
90
+ const qualified = args[0] !== 'preuse';
91
+ return await this.commandPreuse(qualified ? this.harnesses.get(args[0]) : undefined, args.slice(qualified ? 2 : 1));
92
+ }
93
+ const jsonFlags = args.filter(value => value === '--json');
94
+ const json = jsonFlags.length === 1;
95
+ if (jsonFlags.length > 1) { process.stderr.write('Use --json only once.\n'); return 2; }
96
+ if (json) {
97
+ args.splice(args.indexOf('--json'), 1);
98
+ const route = [...args];
99
+ if (this.harnesses.has(route[0])) route.shift();
100
+ if (route.length !== 1 || !['list', 'ls'].includes(route[0])) {
101
+ process.stderr.write('--json requires list or ls, optionally qualified by a harness.\n');
102
+ return 2;
103
+ }
104
+ }
105
+ if (['-h', '--help', 'help'].includes(args[0]) || (args.length === 0 && !canPrompt())) {
106
+ process.stdout.write(`${this.usage()}\n`);
107
+ return 0;
108
+ }
109
+ if (['-v', '--version'].includes(args[0])) {
110
+ process.stdout.write(`${commitinfo.version}\n`);
111
+ return 0;
112
+ }
113
+ const modes = args.filter(value => ['-i', '--interactive', '--tui'].includes(value));
114
+ if (modes.length) {
115
+ const rest = args.filter(value => !['-i', '--interactive', '--tui'].includes(value));
116
+ if (modes.length !== 1 || rest.length > 1 || (rest.length === 1 && !this.harnesses.has(rest[0]))) {
117
+ process.stderr.write('Choose one interactive mode, optionally qualified by a harness; do not combine it with a command.\n');
118
+ return 2;
119
+ }
120
+ if (!canPrompt()) { process.stderr.write('Interactive mode requires a terminal outside CI.\n'); return 2; }
121
+ const harness = rest.length ? this.harnesses.get(rest[0]) : undefined;
122
+ if (modes[0] === '--tui') return await this.runTui(harness);
123
+ return await this.runInteractive(harness);
124
+ }
125
+ if (args.length === 0) return await this.runInteractive();
126
+ const first = args.shift()!;
127
+ let harness = this.harnesses.get(first);
128
+ const command = harness ? args.shift() ?? 'list' : first;
129
+ if (!harness && !COMMANDS.includes(command)) {
130
+ process.stderr.write(`${red('unknown harness or command')} "${plainText(first)}"\n\n${this.usage()}\n`);
131
+ return 2;
132
+ }
133
+ if (['list', 'ls'].includes(command)) {
134
+ if (args.length) { process.stderr.write('list accepts only --json; optionally put a harness before list.\n'); return 2; }
135
+ return await this.commandList(harness ? [harness] : [...this.harnesses.values()], json);
136
+ }
137
+ if (!harness) {
138
+ harness = await this.selectHarness();
139
+ if (!harness) return canPrompt() ? 0 : 2;
140
+ }
141
+ switch (command) {
142
+ case 'login':
143
+ if (args.length > 1 || args[0]?.startsWith('-')) { process.stderr.write('login accepts an optional provider ID.\n'); return 2; }
144
+ return await this.commandLogin(harness, args[0]);
145
+ case 'stash': {
146
+ const references = args.filter(value => value !== '--keep');
147
+ if (references.length > 1 || references.some(value => value.startsWith('-'))) { process.stderr.write('stash accepts one active account or provider and --keep.\n'); return 2; }
148
+ const account = await this.selectActive(harness, references[0]);
149
+ return account ? this.reportOutcome(await this.mutate(harness, { harnessId: harness.id, action: 'save', keepActive: args.includes('--keep'), accountId: account.id })) : 1;
150
+ }
151
+ case 'use': return await this.commandUse(harness, args[0]);
152
+ case 'current': this.printCurrent(harness, await harness.readState()); return 0;
153
+ case 'drop':
154
+ case 'rm': {
155
+ if (!args[0]) {
156
+ process.stderr.write(`${red('drop needs an account')}: authswitch ${harness.id} drop <account>\n`);
157
+ return 2;
158
+ }
159
+ const id = await this.resolveAccount(harness, args[0]);
160
+ return id === null ? 1 : this.reportOutcome(await harness.removeAccount(id));
161
+ }
162
+ case 'doctor': return this.reportOutcome(await harness.diagnose());
163
+ default:
164
+ process.stderr.write(`${red('unknown command')} "${plainText(command)}"\n\n${this.usage()}\n`);
165
+ return 2;
166
+ }
167
+ } catch (errorArg) {
168
+ process.stderr.write(`${red('error')} ${errorArg instanceof Error ? errorArg.message : String(errorArg)}\n`);
169
+ return 1;
170
+ } finally {
171
+ const console = this.console; this.console = undefined;
172
+ try { await console?.dispose(); } finally { this.running = false; }
173
+ }
174
+ }
175
+
176
+ private async runTui(harnessArg?: IAuthHarness): Promise<number> {
177
+ return new AuthSwitchTui([...this.harnesses.values()], this.out, this.operations).run(harnessArg);
178
+ }
179
+
180
+ private mutate(harness: IAuthHarness, mutation: TAuthSwitchMutation): Promise<IHarnessOutcome> {
181
+ return this.operations.run(harness, mutation, canPrompt() ? async message => await this.out.prompts.ask({
182
+ name: 'waitForIdle', type: 'confirm', message, default: false,
183
+ }) === true : undefined);
184
+ }
185
+
186
+ private async commandLogin(harness: IAuthHarness, providerId?: string): Promise<number> {
187
+ if (!harness.beginLogin) { process.stderr.write(`${plainText(harness.label)} does not support a hosted login flow. ${harness.loginHint}\n`); return 2; }
188
+ const providers = harness.loginProviders ?? [];
189
+ let provider = providers.find(item => item.providerId === providerId) ?? (!providerId && providers.length === 1 ? providers[0] : undefined);
190
+ if (!provider && !providerId && canPrompt()) {
191
+ const selected = await this.out.prompts.ask({ name: 'loginProvider', type: 'list', message: 'Which provider would you like to log in to?', choices: providers.map(item => ({ name: item.label, value: item.providerId })) });
192
+ provider = providers.find(item => item.providerId === selected);
193
+ }
194
+ if (!provider || !provider.flows.length) { process.stderr.write('Choose a supported provider: ' + providers.map(item => item.providerId).join(', ') + '\n'); return 2; }
195
+ let handle: Awaited<ReturnType<NonNullable<IAuthHarness['beginLogin']>>> | undefined;
196
+ let cancelled = false;
197
+ const cancel = () => { cancelled = true; void handle?.cancel().catch(() => undefined); };
198
+ process.on('SIGINT', cancel); process.on('SIGTERM', cancel);
199
+ try {
200
+ handle = await harness.beginLogin({ providerId: provider.providerId, flow: provider.flows.includes('device') ? 'device' : provider.flows[0] });
201
+ if (cancelled) await handle.cancel();
202
+ else if (handle.prompt.flow === 'device') {
203
+ process.stdout.write(`Open ${plainText(handle.prompt.verificationUrl)}\nCode: ${plainText(handle.prompt.userCode)}\nWaiting for login. Press Ctrl+C to cancel.\n`);
204
+ } else process.stdout.write(`Open ${plainText(handle.prompt.authUrl)}\nWaiting for login. Press Ctrl+C to cancel.\n`);
205
+ return this.reportOutcome((await handle.completion).outcome);
206
+ } finally {
207
+ process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel);
208
+ await handle?.close();
209
+ }
210
+ }
211
+
212
+ private async commandPreuse(harnessArg: IAuthHarness | undefined, argsArg: string[]): Promise<number> {
213
+ let reference: string | undefined;
214
+ let prompt = defaultPreusePrompt;
215
+ let model: string | undefined;
216
+ const flags = new Set<string>();
217
+ try {
218
+ for (let index = 0; index < argsArg.length; index++) {
219
+ const argument = argsArg[index];
220
+ if (argument.startsWith('--prompt=') || argument.startsWith('--model=') || argument === '--prompt' || argument === '--model') {
221
+ const separator = argument.indexOf('=');
222
+ const flag = separator < 0 ? argument : argument.slice(0, separator);
223
+ if (flags.has(flag)) throw new PreuseError(`Use ${flag} only once.`);
224
+ flags.add(flag);
225
+ const value = separator < 0 ? argsArg[++index] : argument.slice(separator + 1);
226
+ if (value === undefined) throw new PreuseError(`${flag} requires a value.`);
227
+ if (flag === '--prompt') prompt = value; else model = value;
228
+ } else if (argument.startsWith('-') || reference !== undefined) {
229
+ throw new PreuseError('Usage: authswitch [harness] preuse <account> [--prompt <text>] [--model <id>]');
230
+ } else reference = argument;
231
+ }
232
+ validatePreuseOptions({ prompt, model });
233
+ if (!reference && !canPrompt()) throw new PreuseError('preuse requires an account outside an interactive terminal.');
234
+ } catch (error) {
235
+ process.stderr.write(`${error instanceof PreuseError ? error.message : 'Invalid preuse arguments.'}\n`);
236
+ return 2;
237
+ }
238
+ const harness = harnessArg ?? await this.selectHarness();
239
+ if (!harness) return canPrompt() ? 0 : 2;
240
+ if (!harness.preuseAccount) { process.stderr.write(`Preuse is not supported by ${plainText(harness.label)}.\n`); return 2; }
241
+ const state = await harness.readState();
242
+ let account: IHarnessAccount | undefined;
243
+ if (reference) {
244
+ const matches = state.accounts.filter(item => item.id === reference || item.label === reference);
245
+ if (matches.length > 1) { process.stderr.write('That account reference is ambiguous; use an exact account ID.\n'); return 1; }
246
+ account = matches[0];
247
+ if (!account) {
248
+ const id = await this.resolveAccount(harness, reference);
249
+ if (id === null) return 1;
250
+ account = state.accounts.find(item => item.id === id);
251
+ }
252
+ } else {
253
+ const answer = await this.out.prompts.ask({
254
+ name: 'preuseAccount', type: 'list', message: `Which ${harness.label} account should receive the preuse prompt? This consumes quota.`,
255
+ choices: [...state.accounts.map(item => ({ name: plainText(item.label), value: item.id })), { name: 'Back', value: '' }],
256
+ });
257
+ if (!answer) return 0;
258
+ account = state.accounts.find(item => item.id === answer);
259
+ }
260
+ if (!account) { process.stderr.write('The selected account is no longer available.\n'); return 1; }
261
+ const controller = new AbortController();
262
+ const cancel = () => controller.abort();
263
+ process.once('SIGINT', cancel);
264
+ process.once('SIGTERM', cancel);
265
+ try {
266
+ let result: IHarnessPreuseResult;
267
+ try {
268
+ process.stdout.write(`Preusing ${plainText(account.label)} (${plainText(harness.label)})…\n`);
269
+ result = await harness.preuseAccount(account.id, { prompt, model, signal: controller.signal });
270
+ } catch (error) {
271
+ process.stderr.write(`${error instanceof PreuseError ? error.message : 'Preuse failed. Tokens may already have been consumed; check account status before trying again.'}\n`);
272
+ return controller.signal.aborted ? 130 : 1;
273
+ }
274
+ // Once inference completes, status or presentation failures must not report it as failed.
275
+ try {
276
+ process.stdout.write(`Prompt completed with ${plainText(result.model)}. Tokens: ${result.inputTokens ?? 'unreported'} input, ${result.outputTokens ?? 'unreported'} output, ${result.totalTokens ?? 'unreported'} total.\n`);
277
+ if (controller.signal.aborted) return 0;
278
+ const windows = orderedUsageWindows((await harness.readAccountStatus(account.id)).summary?.usageWindows);
279
+ if (windows?.length) {
280
+ const now = Date.now();
281
+ process.stdout.write('Reset schedule reported after the prompt:\n');
282
+ if ((process.stdout.columns ?? 100) < 40) {
283
+ for (const window of windows) process.stdout.write(`${plainText(window.label)} (${duration(window.durationSeconds)})\n Used: ${window.usedPercent}%\n Reset in: ${until(window.resetAt, now)}\n`);
284
+ } else await this.out.table(windows, { columns: [
285
+ { key: 'window', title: 'Window', value: row => `${plainText(row.label)} (${duration(row.durationSeconds)})` },
286
+ { key: 'usage', title: 'Used', value: row => `${row.usedPercent}%` },
287
+ { key: 'reset', title: 'Reset in', value: row => until(row.resetAt, now) },
288
+ ], overflow: 'wrap' });
289
+ } else process.stdout.write('Reset schedule could not be verified; the completed prompt will not be repeated.\n');
290
+ } catch {
291
+ process.stderr.write('Prompt completed, but its reset schedule could not be verified or displayed. The prompt will not be repeated.\n');
292
+ }
293
+ return 0;
294
+ } finally {
295
+ process.removeListener('SIGINT', cancel);
296
+ process.removeListener('SIGTERM', cancel);
297
+ }
298
+ }
299
+
300
+ private async selectHarness(): Promise<IAuthHarness | undefined> {
301
+ if (this.harnesses.size === 1) return this.harnesses.values().next().value;
302
+ if (this.harnesses.size === 0 || !canPrompt()) {
303
+ process.stderr.write(`${red('a harness is required')}: ${[...this.harnesses.keys()].join(', ') || 'none registered'}\n`);
304
+ return undefined;
305
+ }
306
+ const answer = await this.out.prompts.ask({
307
+ name: 'harness', type: 'list', message: 'Which harness would you like to manage?', default: [...this.harnesses.keys()][0],
308
+ choices: [...this.harnesses.values()].map((harnessArg) => ({ name: harnessArg.label, value: harnessArg.id })).concat([{ name: 'Exit', value: '' }]),
309
+ });
310
+ return this.harnesses.get(answer);
311
+ }
312
+
313
+ private async runInteractive(preferredArg?: IAuthHarness): Promise<number> {
314
+ process.stdout.write(`${bold('authswitch')} ${dim(`v${commitinfo.version}`)}\nUse the arrow keys and Enter to choose.\n`);
315
+ while (true) {
316
+ const harness = preferredArg ?? await this.selectHarness();
317
+ preferredArg = undefined;
318
+ if (!harness) return this.harnesses.size === 0 ? 2 : 0;
319
+ this.printCurrent(harness, await harness.readState());
320
+ const offered = await this.offerToSave(harness);
321
+ if (offered === 'failed') return 1;
322
+ const code = await this.runHarnessGuide(harness);
323
+ if (code !== null) return code;
324
+ }
325
+ }
326
+
327
+ /** Offer a verified save before presenting the switch picker; declining never switches. */
328
+ private async offerToSave(harnessArg: IAuthHarness): Promise<'ready' | 'declined' | 'failed'> {
329
+ const state = await harnessArg.readState();
330
+ if (state.saveUnavailableReason) return 'ready';
331
+ for (const current of state.accounts.filter(account => account.isActive && !account.isStashed)) {
332
+ const answer = await this.out.prompts.ask({
333
+ name: 'saveCurrent', type: 'confirm',
334
+ message: `${plainText(current.label)}${current.slotId ? ` (${plainText(current.slotId)})` : ''} is not saved in ${harnessArg.label}. Save this login before switching?`, default: true,
335
+ });
336
+ if (answer !== true) return 'declined';
337
+ if (this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: true, accountId: current.id })) !== 0) return 'failed';
338
+ }
339
+ return 'ready';
340
+ }
341
+
342
+ private async runHarnessGuide(harnessArg: IAuthHarness): Promise<number | null> {
343
+ while (true) {
344
+ const state = await harnessArg.readState();
345
+ this.printCurrent(harnessArg, state);
346
+ const current = state.accounts.find(account => account.isActive && !account.isStashed) ?? state.accounts.find(account => account.isActive);
347
+ const hasStashes = state.accounts.some((accountArg) => accountArg.isStashed);
348
+ if (!hasStashes) process.stdout.write(`${dim('No saved accounts yet. Choose "Save the current login" to get started.')}\n`);
349
+ const action = await this.out.prompts.ask({
350
+ name: 'action', type: 'list', message: `What would you like to do in ${harnessArg.label}?`,
351
+ default: current && !current.isStashed || !hasStashes ? 'stash' : 'use',
352
+ choices: [
353
+ { name: 'Switch to a saved account', value: 'use' },
354
+ { name: 'Save the current login', value: 'stash' },
355
+ ...(harnessArg.beginLogin ? [{ name: 'Log in and save another account', value: 'login' }] : []),
356
+ { name: 'List accounts, subscription, usage and resets', value: 'list' },
357
+ { name: harnessArg.diagnosticsLabel, value: 'doctor' },
358
+ { name: 'Remove a saved account', value: 'drop' },
359
+ ...(this.harnesses.size > 1 ? [{ name: 'Choose another harness', value: 'harness' }] : []),
360
+ { name: 'Exit', value: 'exit' },
361
+ ],
362
+ });
363
+ let code = 0;
364
+ switch (action) {
365
+ case 'login': code = await this.commandLogin(harnessArg); break;
366
+ case 'use': code = await this.commandUse(harnessArg, undefined, true); break;
367
+ case 'stash': {
368
+ if (state.saveUnavailableReason) { process.stdout.write(`${state.saveUnavailableReason}\n`); break; }
369
+ const active = await this.selectActive(harnessArg);
370
+ if (!active) break;
371
+ const mode = await this.out.prompts.ask({
372
+ name: 'stashMode', type: 'list', message: `How would you like to save ${plainText(active.label)}?`, default: 'keep',
373
+ choices: [
374
+ { name: 'Save and keep this account active', value: 'keep' },
375
+ { name: 'Save and clear the active login so I can log in to another account', value: 'clear' },
376
+ { name: 'Back', value: 'back' },
377
+ ],
378
+ });
379
+ if (mode === 'keep' || mode === 'clear') code = this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'save', keepActive: mode === 'keep', accountId: active.id }));
380
+ break;
381
+ }
382
+ case 'list': code = await this.commandList([harnessArg]); break;
383
+ case 'doctor': code = this.reportOutcome(await harnessArg.diagnose()); break;
384
+ case 'drop': {
385
+ const id = await this.promptForAccount(harnessArg, `Which saved ${harnessArg.label} account should be removed?`, true, true);
386
+ if (!id) break;
387
+ const confirmation = await this.out.prompts.ask({
388
+ name: 'confirmDrop', type: 'confirm', message: `Remove the saved login for ${plainText(id)}? The active login stays as it is.`, default: false,
389
+ });
390
+ if (confirmation === true) code = this.reportOutcome(await harnessArg.removeAccount(id));
391
+ break;
392
+ }
393
+ case 'harness': return null;
394
+ case 'exit': return 0;
395
+ default: throw new Error('no interactive action was selected');
396
+ }
397
+ if (code !== 0) return code;
398
+ }
399
+ }
400
+
401
+ private printCurrent(harnessArg: IAuthHarness, stateArg: IHarnessState): void {
402
+ process.stdout.write(`\n${bold(`Current ${harnessArg.label} account`)}\n`);
403
+ const active = stateArg.accounts.filter(account => account.isActive);
404
+ if (active.length) {
405
+ for (const current of active) process.stdout.write(`${bold(plainText(current.label))}${current.slotId ? ` (${plainText(current.slotId)})` : ''} ${current.isStashed ? green('(saved)') : orange('(not saved)')}\n`);
406
+ } else {
407
+ process.stdout.write(`${dim('no account is currently active')}\n`);
408
+ }
409
+ if (stateArg.saveUnavailableReason) process.stdout.write(`${dim(stateArg.saveUnavailableReason)}\n`);
410
+ }
411
+
412
+ private async selectActive(harness: IAuthHarness, reference?: string): Promise<IHarnessAccount | undefined> {
413
+ const state = await harness.readState();
414
+ if (state.saveUnavailableReason) { process.stderr.write(`${plainText(state.saveUnavailableReason)}\n`); return undefined; }
415
+ const accounts = state.accounts.filter(account => account.isActive && (!reference || account.id === reference || account.label === reference || account.slotId === reference));
416
+ if (accounts.length === 1) return accounts[0];
417
+ if (!accounts.length) { process.stderr.write('No matching active login to save.\n'); return undefined; }
418
+ if (!canPrompt()) { process.stderr.write(`Several logins are active. Use authswitch ${harness.id} stash <account/provider> [--keep].\n`); return undefined; }
419
+ const id = await this.out.prompts.ask({ name: 'activeAccount', type: 'list', message: 'Which active login should be saved?', choices: accounts.map(account => ({ name: `${plainText(account.label)}${account.slotId ? ` (${plainText(account.slotId)})` : ''}`, value: account.id })) });
420
+ return accounts.find(account => account.id === id);
421
+ }
422
+
423
+ private reportOutcome(outcomeArg: IHarnessOutcome): number {
424
+ for (const line of outcomeArg.lines) process.stdout.write(`${line.trimEnd()}\n`);
425
+ for (const problem of outcomeArg.problems) process.stderr.write(`${red('incomplete')} ${problem}\n`);
426
+ return outcomeArg.problems.length === 0 ? 0 : 1;
427
+ }
428
+
429
+ private async commandList(harnessesArg: IAuthHarness[], jsonArg = false): Promise<number> {
430
+ const list = await readAccountList(harnessesArg);
431
+ if (jsonArg) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`);
432
+ else await new AccountListRenderer(this.out).render(list);
433
+ return list.complete ? 0 : 1;
434
+ }
435
+
436
+ private printAccount(accountArg: IHarnessAccount): void {
437
+ const saved = accountArg.isStashed ? 'saved' : 'not saved';
438
+ process.stdout.write(`${accountArg.isActive ? green('*') : ' '} ${bold(plainText(accountArg.label))} ${dim(`(${saved})`)}\n`);
439
+ if (accountArg.savedAt) process.stdout.write(` ${dim('Saved: ' + plainText(accountArg.savedAt))}\n`);
440
+ for (const detail of accountArg.details) process.stdout.write(` ${detail}\n`);
441
+ }
442
+
443
+ private async resolveAccount(harnessArg: IAuthHarness, referenceArg: string): Promise<string | null> {
444
+ const resolved = await harnessArg.resolveAccount(referenceArg);
445
+ if ('id' in resolved) return resolved.id;
446
+ process.stderr.write(resolved.candidates.length === 0
447
+ ? `${red('no stashed account matches')} "${plainText(referenceArg)}" in ${harnessArg.label}\n`
448
+ : `${red('ambiguous account')} "${plainText(referenceArg)}" matches: ${resolved.candidates.map(plainText).join(', ')}\n`);
449
+ return null;
450
+ }
451
+
452
+ private async commandUse(harnessArg: IAuthHarness, referenceArg?: string, allowBackArg = false): Promise<number> {
453
+ let id: string | null = null;
454
+ if (referenceArg) {
455
+ id = await this.resolveAccount(harnessArg, referenceArg);
456
+ if (id === null) return 1;
457
+ }
458
+ if (canPrompt()) {
459
+ const offered = await this.offerToSave(harnessArg);
460
+ if (offered !== 'ready') return offered === 'failed' ? 1 : 0;
461
+ }
462
+ id ??= await this.promptForAccount(harnessArg, `Which ${harnessArg.label} account should be active?`, allowBackArg);
463
+ if (id === null) return allowBackArg ? 0 : 1;
464
+ return this.reportOutcome(await this.mutate(harnessArg, { harnessId: harnessArg.id, action: 'switch', accountId: id }));
465
+ }
466
+
467
+ private async promptForAccount(harnessArg: IAuthHarness, messageArg: string, allowBackArg: boolean, includeIncompleteArg = false): Promise<string | null> {
468
+ const accounts = (await harnessArg.readState()).accounts.filter((accountArg) => accountArg.isStashed || (includeIncompleteArg && accountArg.savedAt !== null));
469
+ if (accounts.length === 0) { process.stderr.write(`${red('no stashed accounts')} - run \`authswitch ${harnessArg.id} stash\` first\n`); return null; }
470
+ if (!canPrompt()) { process.stderr.write(`${red('an account is required')} without an interactive terminal: authswitch ${harnessArg.id} use <account>\n`); return null; }
471
+ const answer = await this.out.prompts.ask({
472
+ name: 'account', type: 'list', message: messageArg,
473
+ default: accounts.find((accountArg) => accountArg.isActive)?.id ?? accounts[0].id,
474
+ choices: [...accounts.map((accountArg) => ({ name: `${plainText(accountArg.label)}${accountArg.slotId ? ` (${plainText(accountArg.slotId)})` : ''}${accountArg.isActive ? ' (active)' : ''}`, value: accountArg.id })),
475
+ ...(allowBackArg ? [{ name: 'Back', value: null }] : [])],
476
+ });
477
+ return typeof answer === 'string' ? answer : null;
478
+ }
479
+ }