opencode-anthropic-fix 0.0.33 → 0.0.35

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/cli.mjs ADDED
@@ -0,0 +1,1779 @@
1
+ /**
2
+ * CLI for managing Anthropic multi-account OAuth configuration.
3
+ *
4
+ * Usage:
5
+ * opencode-anthropic-auth [command] [args]
6
+ *
7
+ * Auth Commands:
8
+ * login Add a new account via browser OAuth flow
9
+ * logout <N> Revoke tokens and remove account N
10
+ * logout --all Revoke all tokens and clear all accounts
11
+ * reauth <N> Re-authenticate account N with fresh OAuth tokens
12
+ * refresh <N> Attempt token refresh (no browser needed)
13
+ *
14
+ * Account Commands:
15
+ * list Show all accounts with status (default)
16
+ * status Compact one-liner for scripts/prompts
17
+ * switch <N> Set account N as active
18
+ * enable <N> Enable a disabled account
19
+ * disable <N> Disable an account (skipped in rotation)
20
+ * remove <N> Remove an account permanently
21
+ * reset <N|all> Clear rate-limit / failure tracking
22
+ * stats Show per-account usage statistics
23
+ * reset-stats [N|all] Reset usage statistics
24
+ * strategy [name] Show or change account selection strategy
25
+ * config Show current configuration and file paths
26
+ * manage Interactive account management menu
27
+ * help Show this help message
28
+ */
29
+
30
+ import { loadAccounts, saveAccounts, getStoragePath, createDefaultStats } from "./lib/storage.mjs";
31
+ import { loadConfig, saveConfig, getConfigPath, VALID_STRATEGIES, CLIENT_ID } from "./lib/config.mjs";
32
+ import { authorize, exchange, revoke } from "./lib/oauth.mjs";
33
+ import { AsyncLocalStorage } from "node:async_hooks";
34
+ import { pathToFileURL } from "node:url";
35
+ import { exec } from "node:child_process";
36
+ import { createInterface } from "node:readline/promises";
37
+ import { stdin, stdout } from "node:process";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Color helpers — zero dependencies, respects NO_COLOR / TTY
41
+ // ---------------------------------------------------------------------------
42
+
43
+ let USE_COLOR = !process.env.NO_COLOR && process.stdout.isTTY !== false;
44
+
45
+ /** @param {string} code @param {string} text @returns {string} */
46
+ const ansi = (code, text) => (USE_COLOR ? `\x1b[${code}m${text}\x1b[0m` : text);
47
+
48
+ const c = {
49
+ bold: (/** @type {string} */ t) => ansi("1", t),
50
+ dim: (/** @type {string} */ t) => ansi("2", t),
51
+ green: (/** @type {string} */ t) => ansi("32", t),
52
+ yellow: (/** @type {string} */ t) => ansi("33", t),
53
+ cyan: (/** @type {string} */ t) => ansi("36", t),
54
+ red: (/** @type {string} */ t) => ansi("31", t),
55
+ gray: (/** @type {string} */ t) => ansi("90", t),
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Formatting helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Format milliseconds as a human-readable duration.
64
+ * @param {number} ms
65
+ * @returns {string}
66
+ */
67
+ export function formatDuration(ms) {
68
+ if (ms <= 0) return "now";
69
+ const seconds = Math.floor(ms / 1000);
70
+ if (seconds < 60) return `${seconds}s`;
71
+ const minutes = Math.floor(seconds / 60);
72
+ const remainSec = seconds % 60;
73
+ if (minutes < 60) return remainSec > 0 ? `${minutes}m ${remainSec}s` : `${minutes}m`;
74
+ const hours = Math.floor(minutes / 60);
75
+ const remainMin = minutes % 60;
76
+ if (hours < 24) return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
77
+ const days = Math.floor(hours / 24);
78
+ const remainHours = hours % 24;
79
+ return remainHours > 0 ? `${days}d ${remainHours}h` : `${days}d`;
80
+ }
81
+
82
+ /**
83
+ * Format a timestamp as relative time ago.
84
+ * @param {number} timestamp
85
+ * @returns {string}
86
+ */
87
+ export function formatTimeAgo(timestamp) {
88
+ if (!timestamp || timestamp === 0) return "never";
89
+ const ms = Date.now() - timestamp;
90
+ if (ms < 0) return "just now";
91
+ return `${formatDuration(ms)} ago`;
92
+ }
93
+
94
+ /**
95
+ * Shorten a path by replacing home directory with ~.
96
+ * @param {string} p
97
+ * @returns {string}
98
+ */
99
+ function shortPath(p) {
100
+ const home = process.env.HOME || process.env.USERPROFILE || "";
101
+ if (home && p.startsWith(home)) return "~" + p.slice(home.length);
102
+ return p;
103
+ }
104
+
105
+ /**
106
+ * Strip ANSI escape codes from a string to get its visible content.
107
+ * @param {string} str
108
+ * @returns {string}
109
+ */
110
+ function stripAnsi(str) {
111
+ return str.replace(/\x1b\[[0-9;]*m/g, ""); // eslint-disable-line no-control-regex
112
+ }
113
+
114
+ /**
115
+ * Left-pad a string to a fixed visible width, accounting for ANSI escape codes.
116
+ * @param {string} str
117
+ * @param {number} width
118
+ * @returns {string}
119
+ */
120
+ function pad(str, width) {
121
+ const diff = width - stripAnsi(str).length;
122
+ return diff > 0 ? str + " ".repeat(diff) : str;
123
+ }
124
+
125
+ /**
126
+ * Right-align a string to a fixed visible width, accounting for ANSI escape codes.
127
+ * @param {string} str
128
+ * @param {number} width
129
+ * @returns {string}
130
+ */
131
+ function rpad(str, width) {
132
+ const diff = width - stripAnsi(str).length;
133
+ return diff > 0 ? " ".repeat(diff) + str : str;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Usage quota helpers
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Refresh an account's OAuth access token.
142
+ * Mutates the account object in-place and returns the new access token.
143
+ * @param {{ refreshToken: string, access?: string, expires?: number }} account
144
+ * @returns {Promise<string | null>}
145
+ */
146
+ export async function refreshAccessToken(account) {
147
+ try {
148
+ const resp = await fetch("https://platform.claude.com/v1/oauth/token", {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({
152
+ grant_type: "refresh_token",
153
+ refresh_token: account.refreshToken,
154
+ client_id: CLIENT_ID,
155
+ }),
156
+ signal: AbortSignal.timeout(5000),
157
+ });
158
+ if (!resp.ok) return null;
159
+ const json = await resp.json();
160
+ account.access = json.access_token;
161
+ account.expires = Date.now() + json.expires_in * 1000;
162
+ if (json.refresh_token) account.refreshToken = json.refresh_token;
163
+ account.token_updated_at = Date.now();
164
+ return json.access_token;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Fetch usage quotas from the Anthropic OAuth usage endpoint.
172
+ * @param {string} accessToken
173
+ * @returns {Promise<Record<string, any> | null>}
174
+ */
175
+ export async function fetchUsage(accessToken) {
176
+ try {
177
+ const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
178
+ headers: {
179
+ authorization: `Bearer ${accessToken}`,
180
+ "anthropic-beta": "oauth-2025-04-20",
181
+ accept: "application/json",
182
+ },
183
+ signal: AbortSignal.timeout(5000),
184
+ });
185
+ if (!resp.ok) return null;
186
+ return resp.json();
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Ensure an account has a valid access token and fetch its usage data.
194
+ * @param {{ refreshToken: string, access?: string, expires?: number, enabled: boolean }} account
195
+ * @returns {Promise<{ usage: Record<string, any> | null, tokenRefreshed: boolean }>}
196
+ */
197
+ export async function ensureTokenAndFetchUsage(account) {
198
+ if (!account.enabled) return { usage: null, tokenRefreshed: false };
199
+
200
+ let token = account.access;
201
+ let tokenRefreshed = false;
202
+
203
+ if (!token || !account.expires || account.expires < Date.now()) {
204
+ token = await refreshAccessToken(account);
205
+ tokenRefreshed = !!token;
206
+ if (!token) return { usage: null, tokenRefreshed: false };
207
+ }
208
+
209
+ const usage = await fetchUsage(token);
210
+ return { usage, tokenRefreshed };
211
+ }
212
+
213
+ /**
214
+ * Render a progress bar of a given width for a utilization percentage (0–100).
215
+ * @param {number} utilization - percentage (0 to 100)
216
+ * @param {number} [width=10] - bar character width
217
+ * @returns {string}
218
+ */
219
+ export function renderBar(utilization, width = 10) {
220
+ const pct = Math.max(0, Math.min(100, utilization));
221
+ const filled = Math.round((pct / 100) * width);
222
+ const empty = width - filled;
223
+
224
+ let bar;
225
+ if (pct >= 90) {
226
+ bar = c.red("█".repeat(filled)) + c.dim("░".repeat(empty));
227
+ } else if (pct >= 70) {
228
+ bar = c.yellow("█".repeat(filled)) + c.dim("░".repeat(empty));
229
+ } else {
230
+ bar = c.green("█".repeat(filled)) + c.dim("░".repeat(empty));
231
+ }
232
+ return bar;
233
+ }
234
+
235
+ /**
236
+ * Format an ISO 8601 reset timestamp as a relative duration from now.
237
+ * @param {string} isoString
238
+ * @returns {string}
239
+ */
240
+ export function formatResetTime(isoString) {
241
+ const resetMs = new Date(isoString).getTime();
242
+ const remaining = resetMs - Date.now();
243
+ if (remaining <= 0) return "now";
244
+ return formatDuration(remaining);
245
+ }
246
+
247
+ /**
248
+ * Known usage quota buckets and their display labels.
249
+ * Order determines display order.
250
+ */
251
+ const QUOTA_BUCKETS = [
252
+ { key: "five_hour", label: "5h" },
253
+ { key: "seven_day", label: "7d" },
254
+ { key: "seven_day_sonnet", label: "Sonnet 7d" },
255
+ { key: "seven_day_opus", label: "Opus 7d" },
256
+ { key: "seven_day_oauth_apps", label: "OAuth Apps 7d" },
257
+ { key: "seven_day_cowork", label: "Cowork 7d" },
258
+ ];
259
+
260
+ const USAGE_INDENT = " ";
261
+ const USAGE_LABEL_WIDTH = 13;
262
+
263
+ /**
264
+ * Render usage quota lines for an account.
265
+ * Returns an array of pre-formatted strings (one per non-null bucket).
266
+ * @param {Record<string, any>} usage
267
+ * @returns {string[]}
268
+ */
269
+ export function renderUsageLines(usage) {
270
+ const lines = [];
271
+ for (const { key, label } of QUOTA_BUCKETS) {
272
+ const bucket = usage[key];
273
+ if (!bucket || bucket.utilization == null) continue;
274
+
275
+ const pct = bucket.utilization;
276
+ const bar = renderBar(pct);
277
+ const pctStr = pad(String(Math.round(pct)) + "%", 4);
278
+ const reset = bucket.resets_at ? c.dim(`resets in ${formatResetTime(bucket.resets_at)}`) : "";
279
+
280
+ lines.push(`${USAGE_INDENT}${pad(label, USAGE_LABEL_WIDTH)} ${bar} ${pctStr}${reset ? ` ${reset}` : ""}`);
281
+ }
282
+ return lines;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Browser opener
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Open a URL in the user's default browser.
291
+ * Best-effort: uses platform-specific command, silently fails on error.
292
+ * @param {string} url
293
+ */
294
+ function openBrowser(url) {
295
+ if (process.platform === "win32") {
296
+ exec(`cmd /c start "" ${JSON.stringify(url)}`);
297
+ return;
298
+ }
299
+
300
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
301
+ exec(`${cmd} ${JSON.stringify(url)}`);
302
+ }
303
+
304
+ /**
305
+ * Run the OAuth PKCE login flow from the CLI.
306
+ * Opens browser, prompts for code, exchanges for tokens.
307
+ * @returns {Promise<{refresh: string, access: string, expires: number, email?: string} | null>}
308
+ */
309
+ async function runOAuthFlow() {
310
+ const { url, verifier } = await authorize("max");
311
+
312
+ console.log("");
313
+ console.log(c.bold("Opening browser for Anthropic OAuth login..."));
314
+ console.log("");
315
+ console.log(c.dim("If your browser didn't open, visit this URL:"));
316
+ console.log(c.cyan(url));
317
+ console.log("");
318
+
319
+ openBrowser(url);
320
+
321
+ const rl = createInterface({ input: stdin, output: stdout });
322
+ try {
323
+ const code = await rl.question("Paste the authorization code here: ");
324
+ const trimmed = code.trim();
325
+ if (!trimmed) {
326
+ console.error(c.red("Error: no authorization code provided."));
327
+ return null;
328
+ }
329
+
330
+ const credentials = await exchange(trimmed, verifier);
331
+ if (credentials.type === "failed") {
332
+ if (credentials.details) {
333
+ console.error(c.red(`Error: token exchange failed (${credentials.details}).`));
334
+ } else {
335
+ console.error(c.red("Error: token exchange failed. The code may be invalid or expired."));
336
+ }
337
+ return null;
338
+ }
339
+
340
+ return {
341
+ refresh: credentials.refresh,
342
+ access: credentials.access,
343
+ expires: credentials.expires,
344
+ email: credentials.email,
345
+ };
346
+ } finally {
347
+ rl.close();
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Auth commands (login, logout, reauth, refresh)
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Login: add a new account via browser OAuth flow.
357
+ * @returns {Promise<number>} exit code
358
+ */
359
+ export async function cmdLogin() {
360
+ if (!process.stdin.isTTY) {
361
+ console.error(c.red("Error: 'login' requires an interactive terminal."));
362
+ return 1;
363
+ }
364
+
365
+ const stored = await loadAccounts();
366
+
367
+ const credentials = await runOAuthFlow();
368
+ if (!credentials) return 1;
369
+
370
+ // Load or create storage
371
+ const storage = stored || { version: 1, accounts: [], activeIndex: 0 };
372
+
373
+ // Check for duplicate refresh token
374
+ const existingIdx = storage.accounts.findIndex((acc) => acc.refreshToken === credentials.refresh);
375
+ if (existingIdx >= 0) {
376
+ // Update existing account
377
+ storage.accounts[existingIdx].access = credentials.access;
378
+ storage.accounts[existingIdx].expires = credentials.expires;
379
+ if (credentials.email) storage.accounts[existingIdx].email = credentials.email;
380
+ storage.accounts[existingIdx].enabled = true;
381
+ await saveAccounts(storage);
382
+
383
+ const label = credentials.email || `Account ${existingIdx + 1}`;
384
+ console.log(c.green(`Updated existing account #${existingIdx + 1} (${label}).`));
385
+ return 0;
386
+ }
387
+
388
+ if (storage.accounts.length >= 10) {
389
+ console.error(c.red("Error: maximum of 10 accounts reached. Remove one first."));
390
+ return 1;
391
+ }
392
+
393
+ // Add new account
394
+ const now = Date.now();
395
+ storage.accounts.push({
396
+ id: `${now}:${credentials.refresh.slice(0, 12)}`,
397
+ email: credentials.email,
398
+ refreshToken: credentials.refresh,
399
+ access: credentials.access,
400
+ expires: credentials.expires,
401
+ token_updated_at: now,
402
+ addedAt: now,
403
+ lastUsed: 0,
404
+ enabled: true,
405
+ rateLimitResetTimes: {},
406
+ consecutiveFailures: 0,
407
+ lastFailureTime: null,
408
+ stats: createDefaultStats(now),
409
+ });
410
+
411
+ // If this is the first account, it's already active at index 0
412
+ await saveAccounts(storage);
413
+
414
+ const label = credentials.email || `Account ${storage.accounts.length}`;
415
+ console.log(c.green(`Added account #${storage.accounts.length} (${label}).`));
416
+ console.log(c.dim(`${storage.accounts.length} account(s) total.`));
417
+ return 0;
418
+ }
419
+
420
+ /**
421
+ * Logout: revoke tokens and remove an account, or all accounts.
422
+ * @param {string} arg - Account number
423
+ * @param {object} [opts]
424
+ * @param {boolean} [opts.force] Skip confirmation prompt
425
+ * @param {boolean} [opts.all] Logout all accounts
426
+ * @returns {Promise<number>} exit code
427
+ */
428
+ export async function cmdLogout(arg, opts = {}) {
429
+ if (opts.all) {
430
+ return cmdLogoutAll(opts);
431
+ }
432
+
433
+ const n = parseInt(arg, 10);
434
+ if (isNaN(n) || n < 1) {
435
+ console.error(c.red("Error: provide a valid account number (e.g., 'logout 2') or --all."));
436
+ return 1;
437
+ }
438
+
439
+ const stored = await loadAccounts();
440
+ if (!stored || stored.accounts.length === 0) {
441
+ console.error(c.red("Error: no accounts configured."));
442
+ return 1;
443
+ }
444
+
445
+ const idx = n - 1;
446
+ if (idx >= stored.accounts.length) {
447
+ console.error(c.red(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`));
448
+ return 1;
449
+ }
450
+
451
+ const label = stored.accounts[idx].email || `Account ${n}`;
452
+
453
+ // Confirm unless --force
454
+ if (!opts.force) {
455
+ if (!process.stdin.isTTY) {
456
+ console.error(c.red("Error: use --force to logout in non-interactive mode."));
457
+ return 1;
458
+ }
459
+ const rl = createInterface({ input: stdin, output: stdout });
460
+ try {
461
+ const answer = await rl.question(
462
+ `Logout account #${n} (${label})? This will revoke tokens and remove the account. [y/N]: `,
463
+ );
464
+ if (answer.trim().toLowerCase() !== "y") {
465
+ console.log(c.dim("Cancelled."));
466
+ return 0;
467
+ }
468
+ } finally {
469
+ rl.close();
470
+ }
471
+ }
472
+
473
+ // Attempt token revocation (best-effort)
474
+ const revoked = await revoke(stored.accounts[idx].refreshToken);
475
+ if (revoked) {
476
+ console.log(c.dim("Token revoked server-side."));
477
+ } else {
478
+ console.log(c.dim("Token revocation skipped (server may not support it)."));
479
+ }
480
+
481
+ // Remove the account
482
+ stored.accounts.splice(idx, 1);
483
+
484
+ // Adjust active index
485
+ if (stored.accounts.length === 0) {
486
+ stored.activeIndex = 0;
487
+ } else if (stored.activeIndex >= stored.accounts.length) {
488
+ stored.activeIndex = stored.accounts.length - 1;
489
+ } else if (stored.activeIndex > idx) {
490
+ stored.activeIndex--;
491
+ }
492
+
493
+ await saveAccounts(stored);
494
+ console.log(c.green(`Logged out account #${n} (${label}).`));
495
+
496
+ if (stored.accounts.length > 0) {
497
+ console.log(c.dim(`${stored.accounts.length} account(s) remaining.`));
498
+ } else {
499
+ console.log(c.dim("No accounts remaining. Run 'login' to add one."));
500
+ }
501
+
502
+ return 0;
503
+ }
504
+
505
+ /**
506
+ * Logout all accounts: revoke all tokens and clear storage.
507
+ * @param {object} [opts]
508
+ * @param {boolean} [opts.force] Skip confirmation prompt
509
+ * @returns {Promise<number>} exit code
510
+ */
511
+ async function cmdLogoutAll(opts = {}) {
512
+ const stored = await loadAccounts();
513
+ if (!stored || stored.accounts.length === 0) {
514
+ console.log(c.dim("No accounts to logout."));
515
+ return 0;
516
+ }
517
+
518
+ const count = stored.accounts.length;
519
+
520
+ // Confirm unless --force
521
+ if (!opts.force) {
522
+ if (!process.stdin.isTTY) {
523
+ console.error(c.red("Error: use --force to logout all in non-interactive mode."));
524
+ return 1;
525
+ }
526
+ const rl = createInterface({ input: stdin, output: stdout });
527
+ try {
528
+ const answer = await rl.question(
529
+ `Logout all ${count} account(s)? This will revoke tokens and remove all accounts. [y/N]: `,
530
+ );
531
+ if (answer.trim().toLowerCase() !== "y") {
532
+ console.log(c.dim("Cancelled."));
533
+ return 0;
534
+ }
535
+ } finally {
536
+ rl.close();
537
+ }
538
+ }
539
+
540
+ // Attempt token revocation for each account (best-effort, in parallel)
541
+ const results = await Promise.allSettled(stored.accounts.map((acc) => revoke(acc.refreshToken)));
542
+ const revokedCount = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
543
+
544
+ if (revokedCount > 0) {
545
+ console.log(c.dim(`Revoked ${revokedCount} of ${count} token(s) server-side.`));
546
+ }
547
+
548
+ // Write explicit empty state so running plugin instances reconcile immediately.
549
+ await saveAccounts({ version: 1, accounts: [], activeIndex: 0 });
550
+ console.log(c.green(`Logged out all ${count} account(s).`));
551
+
552
+ return 0;
553
+ }
554
+
555
+ /**
556
+ * Reauth: re-authenticate an existing account with fresh OAuth tokens.
557
+ * @param {string} arg - Account number
558
+ * @returns {Promise<number>} exit code
559
+ */
560
+ export async function cmdReauth(arg) {
561
+ const n = parseInt(arg, 10);
562
+ if (isNaN(n) || n < 1) {
563
+ console.error(c.red("Error: provide a valid account number (e.g., 'reauth 1')"));
564
+ return 1;
565
+ }
566
+
567
+ if (!process.stdin.isTTY) {
568
+ console.error(c.red("Error: 'reauth' requires an interactive terminal."));
569
+ return 1;
570
+ }
571
+
572
+ const stored = await loadAccounts();
573
+ if (!stored || stored.accounts.length === 0) {
574
+ console.error(c.red("Error: no accounts configured."));
575
+ return 1;
576
+ }
577
+
578
+ const idx = n - 1;
579
+ if (idx >= stored.accounts.length) {
580
+ console.error(c.red(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`));
581
+ return 1;
582
+ }
583
+
584
+ const existing = stored.accounts[idx];
585
+ const wasDisabled = !existing.enabled;
586
+ const oldLabel = existing.email || `Account ${n}`;
587
+ console.log(c.bold(`Re-authenticating account #${n} (${oldLabel})...`));
588
+
589
+ const credentials = await runOAuthFlow();
590
+ if (!credentials) return 1;
591
+
592
+ // Update the account at the target index with fresh tokens
593
+ existing.refreshToken = credentials.refresh;
594
+ existing.access = credentials.access;
595
+ existing.expires = credentials.expires;
596
+ if (credentials.email) existing.email = credentials.email;
597
+
598
+ // Re-enable and reset failure tracking
599
+ existing.enabled = true;
600
+ existing.consecutiveFailures = 0;
601
+ existing.lastFailureTime = null;
602
+ existing.rateLimitResetTimes = {};
603
+
604
+ await saveAccounts(stored);
605
+
606
+ const newLabel = credentials.email || `Account ${n}`;
607
+ console.log(c.green(`Re-authenticated account #${n} (${newLabel}).`));
608
+ if (wasDisabled) {
609
+ console.log(c.dim("Account has been re-enabled."));
610
+ }
611
+
612
+ return 0;
613
+ }
614
+
615
+ /**
616
+ * Refresh: attempt a token refresh for an account without browser interaction.
617
+ * @param {string} arg - Account number
618
+ * @returns {Promise<number>} exit code
619
+ */
620
+ export async function cmdRefresh(arg) {
621
+ const n = parseInt(arg, 10);
622
+ if (isNaN(n) || n < 1) {
623
+ console.error(c.red("Error: provide a valid account number (e.g., 'refresh 1')"));
624
+ return 1;
625
+ }
626
+
627
+ const stored = await loadAccounts();
628
+ if (!stored || stored.accounts.length === 0) {
629
+ console.error(c.red("Error: no accounts configured."));
630
+ return 1;
631
+ }
632
+
633
+ const idx = n - 1;
634
+ if (idx >= stored.accounts.length) {
635
+ console.error(c.red(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`));
636
+ return 1;
637
+ }
638
+
639
+ const account = stored.accounts[idx];
640
+ const label = account.email || `Account ${n}`;
641
+
642
+ console.log(c.dim(`Refreshing token for account #${n} (${label})...`));
643
+
644
+ const token = await refreshAccessToken(account);
645
+ if (!token) {
646
+ console.error(c.red(`Error: token refresh failed for account #${n}.`));
647
+ console.error(c.dim("The refresh token may be invalid or expired."));
648
+ console.error(c.dim(`Try: opencode-anthropic-auth reauth ${n}`));
649
+ return 1;
650
+ }
651
+
652
+ // Re-enable if disabled and reset failure tracking
653
+ const wasDisabled = !account.enabled;
654
+ account.enabled = true;
655
+ account.consecutiveFailures = 0;
656
+ account.lastFailureTime = null;
657
+ account.rateLimitResetTimes = {};
658
+
659
+ await saveAccounts(stored);
660
+
661
+ const expiresIn = account.expires ? formatDuration(account.expires - Date.now()) : "unknown";
662
+ console.log(c.green(`Token refreshed for account #${n} (${label}).`));
663
+ console.log(c.dim(`New token expires in ${expiresIn}.`));
664
+ if (wasDisabled) {
665
+ console.log(c.dim("Account has been re-enabled."));
666
+ }
667
+
668
+ return 0;
669
+ }
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Commands
673
+ // ---------------------------------------------------------------------------
674
+
675
+ /**
676
+ * List all accounts with full status table and live usage quotas.
677
+ * @returns {Promise<number>} exit code
678
+ */
679
+ export async function cmdList() {
680
+ const stored = await loadAccounts();
681
+ if (!stored || stored.accounts.length === 0) {
682
+ console.log(c.yellow("No accounts configured."));
683
+ console.log(c.dim(`Storage: ${shortPath(getStoragePath())}`));
684
+ console.log(c.dim("\nRun 'opencode auth login' and select 'Claude Pro/Max' to add accounts."));
685
+ return 1;
686
+ }
687
+
688
+ const config = loadConfig();
689
+ const now = Date.now();
690
+
691
+ // Fetch usage quotas for all enabled accounts in parallel
692
+ const usageResults = await Promise.allSettled(stored.accounts.map((acc) => ensureTokenAndFetchUsage(acc)));
693
+
694
+ // If any tokens were refreshed, persist them back to disk
695
+ let anyRefreshed = false;
696
+ for (const result of usageResults) {
697
+ if (result.status === "fulfilled" && result.value.tokenRefreshed) {
698
+ anyRefreshed = true;
699
+ }
700
+ }
701
+ if (anyRefreshed) {
702
+ await saveAccounts(stored).catch(() => {});
703
+ }
704
+
705
+ console.log(c.bold("Anthropic Multi-Account Status"));
706
+
707
+ // Header
708
+ console.log(
709
+ " " +
710
+ pad(c.dim("#"), 5) +
711
+ pad(c.dim("Account"), 22) +
712
+ pad(c.dim("Status"), 14) +
713
+ pad(c.dim("Failures"), 11) +
714
+ c.dim("Rate Limit"),
715
+ );
716
+ console.log(c.dim(" " + "─".repeat(62)));
717
+
718
+ for (let i = 0; i < stored.accounts.length; i++) {
719
+ const acc = stored.accounts[i];
720
+ const isActive = i === stored.activeIndex;
721
+ const num = String(i + 1);
722
+
723
+ // Label
724
+ const label = acc.email || `Account ${i + 1}`;
725
+
726
+ // Status
727
+ let status;
728
+ if (!acc.enabled) {
729
+ status = c.gray("○ disabled");
730
+ } else if (isActive) {
731
+ status = c.green("● active");
732
+ } else {
733
+ status = c.cyan("● ready");
734
+ }
735
+
736
+ // Failures
737
+ let failures;
738
+ if (!acc.enabled) {
739
+ failures = c.dim("—");
740
+ } else if (acc.consecutiveFailures > 0) {
741
+ failures = c.yellow(String(acc.consecutiveFailures));
742
+ } else {
743
+ failures = c.dim("0");
744
+ }
745
+
746
+ // Rate limit
747
+ let rateLimit;
748
+ if (!acc.enabled) {
749
+ rateLimit = c.dim("—");
750
+ } else {
751
+ const resetTimes = acc.rateLimitResetTimes || {};
752
+ const maxReset = Math.max(0, ...Object.values(resetTimes));
753
+ if (maxReset > now) {
754
+ rateLimit = c.yellow(`\u26A0 ${formatDuration(maxReset - now)}`);
755
+ } else {
756
+ rateLimit = c.dim("—");
757
+ }
758
+ }
759
+
760
+ // Render account header line
761
+ console.log(" " + pad(c.bold(num), 5) + pad(label, 22) + pad(status, 14) + pad(failures, 11) + rateLimit);
762
+
763
+ // Render usage quota lines for enabled accounts
764
+ if (acc.enabled) {
765
+ const result = usageResults[i];
766
+ const usage = result.status === "fulfilled" ? result.value.usage : null;
767
+ if (usage) {
768
+ const lines = renderUsageLines(usage);
769
+ for (const line of lines) {
770
+ console.log(line);
771
+ }
772
+ } else {
773
+ console.log(c.dim(`${USAGE_INDENT}quotas: unavailable`));
774
+ }
775
+ }
776
+
777
+ if (i < stored.accounts.length - 1) {
778
+ console.log("");
779
+ }
780
+ }
781
+
782
+ console.log("");
783
+
784
+ const enabled = stored.accounts.filter((a) => a.enabled).length;
785
+ const disabled = stored.accounts.length - enabled;
786
+
787
+ const parts = [
788
+ `Strategy: ${c.cyan(config.account_selection_strategy)}`,
789
+ `${c.bold(String(enabled))} of ${stored.accounts.length} enabled`,
790
+ ];
791
+ if (disabled > 0) {
792
+ parts.push(`${c.yellow(String(disabled))} disabled`);
793
+ }
794
+ console.log(parts.join(c.dim(" | ")));
795
+ console.log(c.dim(`Storage: ${shortPath(getStoragePath())}`));
796
+
797
+ return 0;
798
+ }
799
+
800
+ /**
801
+ * Show compact one-liner status.
802
+ * @returns {Promise<number>} exit code
803
+ */
804
+ export async function cmdStatus() {
805
+ const stored = await loadAccounts();
806
+ if (!stored || stored.accounts.length === 0) {
807
+ console.log("anthropic: no accounts configured");
808
+ return 1;
809
+ }
810
+
811
+ const config = loadConfig();
812
+ const total = stored.accounts.length;
813
+ const enabled = stored.accounts.filter((a) => a.enabled).length;
814
+ const now = Date.now();
815
+
816
+ // Count rate-limited accounts
817
+ let rateLimited = 0;
818
+ for (const acc of stored.accounts) {
819
+ if (!acc.enabled) continue;
820
+ const resetTimes = acc.rateLimitResetTimes || {};
821
+ const maxReset = Math.max(0, ...Object.values(resetTimes));
822
+ if (maxReset > now) rateLimited++;
823
+ }
824
+
825
+ let line = `anthropic: ${total} account${total !== 1 ? "s" : ""} (${enabled} active)`;
826
+ line += `, strategy: ${config.account_selection_strategy}`;
827
+ line += `, next: #${stored.activeIndex + 1}`;
828
+ if (rateLimited > 0) {
829
+ line += `, ${rateLimited} rate-limited`;
830
+ }
831
+
832
+ console.log(line);
833
+ return 0;
834
+ }
835
+
836
+ /**
837
+ * Switch active account.
838
+ * @param {string} arg
839
+ * @returns {Promise<number>} exit code
840
+ */
841
+ export async function cmdSwitch(arg) {
842
+ const n = parseInt(arg, 10);
843
+ if (isNaN(n) || n < 1) {
844
+ console.error(c.red("Error: provide a valid account number (e.g., 'switch 2')"));
845
+ return 1;
846
+ }
847
+
848
+ const stored = await loadAccounts();
849
+ if (!stored || stored.accounts.length === 0) {
850
+ console.error(c.red("Error: no accounts configured."));
851
+ return 1;
852
+ }
853
+
854
+ const idx = n - 1;
855
+ if (idx >= stored.accounts.length) {
856
+ console.error(c.red(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`));
857
+ return 1;
858
+ }
859
+
860
+ if (!stored.accounts[idx].enabled) {
861
+ console.error(c.yellow(`Warning: account ${n} is disabled. Enable it first with 'enable ${n}'.`));
862
+ return 1;
863
+ }
864
+
865
+ stored.activeIndex = idx;
866
+ await saveAccounts(stored);
867
+
868
+ const label = stored.accounts[idx].email || `Account ${n}`;
869
+ console.log(c.green(`Switched active account to #${n} (${label}).`));
870
+ return 0;
871
+ }
872
+
873
+ /**
874
+ * Enable a disabled account.
875
+ * @param {string} arg
876
+ * @returns {Promise<number>} exit code
877
+ */
878
+ export async function cmdEnable(arg) {
879
+ const n = parseInt(arg, 10);
880
+ if (isNaN(n) || n < 1) {
881
+ console.error(c.red("Error: provide a valid account number (e.g., 'enable 3')"));
882
+ return 1;
883
+ }
884
+
885
+ const stored = await loadAccounts();
886
+ if (!stored || stored.accounts.length === 0) {
887
+ console.error(c.red("Error: no accounts configured."));
888
+ return 1;
889
+ }
890
+
891
+ const idx = n - 1;
892
+ if (idx >= stored.accounts.length) {
893
+ console.error(c.red(`Error: account ${n} does not exist.`));
894
+ return 1;
895
+ }
896
+
897
+ if (stored.accounts[idx].enabled) {
898
+ console.log(c.dim(`Account ${n} is already enabled.`));
899
+ return 0;
900
+ }
901
+
902
+ stored.accounts[idx].enabled = true;
903
+ await saveAccounts(stored);
904
+
905
+ const label = stored.accounts[idx].email || `Account ${n}`;
906
+ console.log(c.green(`Enabled account #${n} (${label}).`));
907
+ return 0;
908
+ }
909
+
910
+ /**
911
+ * Disable an account.
912
+ * @param {string} arg
913
+ * @returns {Promise<number>} exit code
914
+ */
915
+ export async function cmdDisable(arg) {
916
+ const n = parseInt(arg, 10);
917
+ if (isNaN(n) || n < 1) {
918
+ console.error(c.red("Error: provide a valid account number (e.g., 'disable 3')"));
919
+ return 1;
920
+ }
921
+
922
+ const stored = await loadAccounts();
923
+ if (!stored || stored.accounts.length === 0) {
924
+ console.error(c.red("Error: no accounts configured."));
925
+ return 1;
926
+ }
927
+
928
+ const idx = n - 1;
929
+ if (idx >= stored.accounts.length) {
930
+ console.error(c.red(`Error: account ${n} does not exist.`));
931
+ return 1;
932
+ }
933
+
934
+ if (!stored.accounts[idx].enabled) {
935
+ console.log(c.dim(`Account ${n} is already disabled.`));
936
+ return 0;
937
+ }
938
+
939
+ // Don't allow disabling the last enabled account
940
+ const enabledCount = stored.accounts.filter((a) => a.enabled).length;
941
+ if (enabledCount <= 1) {
942
+ console.error(c.red("Error: cannot disable the last enabled account."));
943
+ return 1;
944
+ }
945
+
946
+ stored.accounts[idx].enabled = false;
947
+
948
+ const label = stored.accounts[idx].email || `Account ${n}`;
949
+ let switchedTo = null;
950
+
951
+ // If we disabled the active account, switch to the next enabled one
952
+ // (adjust before saving to avoid a TOCTOU race with the running plugin)
953
+ if (idx === stored.activeIndex) {
954
+ const nextEnabled = stored.accounts.findIndex((a) => a.enabled);
955
+ if (nextEnabled >= 0) {
956
+ stored.activeIndex = nextEnabled;
957
+ switchedTo = nextEnabled;
958
+ }
959
+ }
960
+
961
+ await saveAccounts(stored);
962
+
963
+ console.log(c.yellow(`Disabled account #${n} (${label}).`));
964
+ if (switchedTo !== null) {
965
+ const nextLabel = stored.accounts[switchedTo].email || `Account ${switchedTo + 1}`;
966
+ console.log(c.dim(`Active account switched to #${switchedTo + 1} (${nextLabel}).`));
967
+ }
968
+
969
+ return 0;
970
+ }
971
+
972
+ /**
973
+ * Remove an account permanently.
974
+ * @param {string} arg
975
+ * @param {object} [opts]
976
+ * @param {boolean} [opts.force] Skip confirmation prompt
977
+ * @returns {Promise<number>} exit code
978
+ */
979
+ export async function cmdRemove(arg, opts = {}) {
980
+ const n = parseInt(arg, 10);
981
+ if (isNaN(n) || n < 1) {
982
+ console.error(c.red("Error: provide a valid account number (e.g., 'remove 2')"));
983
+ return 1;
984
+ }
985
+
986
+ const stored = await loadAccounts();
987
+ if (!stored || stored.accounts.length === 0) {
988
+ console.error(c.red("Error: no accounts configured."));
989
+ return 1;
990
+ }
991
+
992
+ const idx = n - 1;
993
+ if (idx >= stored.accounts.length) {
994
+ console.error(c.red(`Error: account ${n} does not exist.`));
995
+ return 1;
996
+ }
997
+
998
+ const label = stored.accounts[idx].email || `Account ${n}`;
999
+
1000
+ // Confirm unless --force
1001
+ if (!opts.force) {
1002
+ if (!process.stdin.isTTY) {
1003
+ console.error(c.red("Error: use --force to remove accounts in non-interactive mode."));
1004
+ return 1;
1005
+ }
1006
+ const rl = createInterface({ input: stdin, output: stdout });
1007
+ try {
1008
+ const answer = await rl.question(`Remove account #${n} (${label})? This cannot be undone. [y/N]: `);
1009
+ if (answer.trim().toLowerCase() !== "y") {
1010
+ console.log(c.dim("Cancelled."));
1011
+ return 0;
1012
+ }
1013
+ } finally {
1014
+ rl.close();
1015
+ }
1016
+ }
1017
+
1018
+ stored.accounts.splice(idx, 1);
1019
+
1020
+ // Adjust active index
1021
+ if (stored.accounts.length === 0) {
1022
+ stored.activeIndex = 0;
1023
+ } else if (stored.activeIndex >= stored.accounts.length) {
1024
+ stored.activeIndex = stored.accounts.length - 1;
1025
+ } else if (stored.activeIndex > idx) {
1026
+ stored.activeIndex--;
1027
+ }
1028
+
1029
+ await saveAccounts(stored);
1030
+ console.log(c.green(`Removed account #${n} (${label}).`));
1031
+
1032
+ if (stored.accounts.length > 0) {
1033
+ console.log(c.dim(`${stored.accounts.length} account(s) remaining.`));
1034
+ } else {
1035
+ console.log(c.dim("No accounts remaining. Run 'opencode auth login' to add one."));
1036
+ }
1037
+
1038
+ return 0;
1039
+ }
1040
+
1041
+ /**
1042
+ * Reset rate-limit and failure tracking.
1043
+ * @param {string} arg - Account number or "all"
1044
+ * @returns {Promise<number>} exit code
1045
+ */
1046
+ export async function cmdReset(arg) {
1047
+ if (!arg) {
1048
+ console.error(c.red("Error: provide an account number or 'all' (e.g., 'reset 1' or 'reset all')"));
1049
+ return 1;
1050
+ }
1051
+
1052
+ const stored = await loadAccounts();
1053
+ if (!stored || stored.accounts.length === 0) {
1054
+ console.error(c.red("Error: no accounts configured."));
1055
+ return 1;
1056
+ }
1057
+
1058
+ if (arg.toLowerCase() === "all") {
1059
+ let count = 0;
1060
+ for (const acc of stored.accounts) {
1061
+ acc.rateLimitResetTimes = {};
1062
+ acc.consecutiveFailures = 0;
1063
+ acc.lastFailureTime = null;
1064
+ count++;
1065
+ }
1066
+ await saveAccounts(stored);
1067
+ console.log(c.green(`Reset tracking for all ${count} account(s).`));
1068
+ return 0;
1069
+ }
1070
+
1071
+ const n = parseInt(arg, 10);
1072
+ if (isNaN(n) || n < 1) {
1073
+ console.error(c.red("Error: provide a valid account number or 'all'."));
1074
+ return 1;
1075
+ }
1076
+
1077
+ const idx = n - 1;
1078
+ if (idx >= stored.accounts.length) {
1079
+ console.error(c.red(`Error: account ${n} does not exist.`));
1080
+ return 1;
1081
+ }
1082
+
1083
+ stored.accounts[idx].rateLimitResetTimes = {};
1084
+ stored.accounts[idx].consecutiveFailures = 0;
1085
+ stored.accounts[idx].lastFailureTime = null;
1086
+ await saveAccounts(stored);
1087
+
1088
+ const label = stored.accounts[idx].email || `Account ${n}`;
1089
+ console.log(c.green(`Reset tracking for account #${n} (${label}).`));
1090
+ return 0;
1091
+ }
1092
+
1093
+ /**
1094
+ * Show current configuration.
1095
+ * @returns {Promise<number>} exit code
1096
+ */
1097
+ export async function cmdConfig() {
1098
+ const config = loadConfig();
1099
+ const stored = await loadAccounts();
1100
+
1101
+ console.log(c.bold("Anthropic Auth Configuration"));
1102
+ console.log(c.dim("─".repeat(45)));
1103
+ console.log("");
1104
+
1105
+ console.log(c.dim("Strategy: ") + c.cyan(config.account_selection_strategy));
1106
+ console.log(c.dim("Failure TTL: ") + `${config.failure_ttl_seconds}s`);
1107
+ console.log(c.dim("Debug: ") + (config.debug ? c.yellow("on") : c.dim("off")));
1108
+ console.log("");
1109
+
1110
+ console.log(c.dim("Health Score"));
1111
+ console.log(c.dim(" Initial: ") + `${config.health_score.initial}`);
1112
+ console.log(c.dim(" Success reward: ") + `+${config.health_score.success_reward}`);
1113
+ console.log(c.dim(" Rate limit: ") + `${config.health_score.rate_limit_penalty}`);
1114
+ console.log(c.dim(" Failure: ") + `${config.health_score.failure_penalty}`);
1115
+ console.log(c.dim(" Recovery/hour: ") + `+${config.health_score.recovery_rate_per_hour}`);
1116
+ console.log(c.dim(" Min usable: ") + `${config.health_score.min_usable}`);
1117
+ console.log("");
1118
+
1119
+ console.log(c.dim("Token Bucket"));
1120
+ console.log(c.dim(" Max tokens: ") + `${config.token_bucket.max_tokens}`);
1121
+ console.log(c.dim(" Regen/min: ") + `${config.token_bucket.regeneration_rate_per_minute}`);
1122
+ console.log(c.dim(" Initial: ") + `${config.token_bucket.initial_tokens}`);
1123
+ console.log("");
1124
+
1125
+ console.log(c.dim("Files"));
1126
+ console.log(c.dim(" Config: ") + shortPath(getConfigPath()));
1127
+ console.log(c.dim(" Accounts: ") + shortPath(getStoragePath()));
1128
+
1129
+ if (stored) {
1130
+ const enabled = stored.accounts.filter((a) => a.enabled).length;
1131
+ console.log(c.dim(" Accounts total: ") + `${stored.accounts.length} (${enabled} enabled)`);
1132
+ } else {
1133
+ console.log(c.dim(" Accounts total: ") + c.dim("none"));
1134
+ }
1135
+
1136
+ console.log("");
1137
+
1138
+ // Show env var overrides if active
1139
+ const envOverrides = [];
1140
+ if (process.env.OPENCODE_ANTHROPIC_STRATEGY) {
1141
+ envOverrides.push(`OPENCODE_ANTHROPIC_STRATEGY=${process.env.OPENCODE_ANTHROPIC_STRATEGY}`);
1142
+ }
1143
+ if (process.env.OPENCODE_ANTHROPIC_DEBUG) {
1144
+ envOverrides.push(`OPENCODE_ANTHROPIC_DEBUG=${process.env.OPENCODE_ANTHROPIC_DEBUG}`);
1145
+ }
1146
+ if (envOverrides.length > 0) {
1147
+ console.log(c.dim("Environment overrides:"));
1148
+ for (const ov of envOverrides) {
1149
+ console.log(c.dim(" ") + c.yellow(ov));
1150
+ }
1151
+ }
1152
+
1153
+ return 0;
1154
+ }
1155
+
1156
+ /**
1157
+ * Show or change the account selection strategy.
1158
+ * @param {string} [arg] - New strategy name, or undefined to show current
1159
+ * @returns {Promise<number>} exit code
1160
+ */
1161
+ export async function cmdStrategy(arg) {
1162
+ const config = loadConfig();
1163
+
1164
+ if (!arg) {
1165
+ // Show current strategy with descriptions
1166
+ console.log(c.bold("Account Selection Strategy"));
1167
+ console.log(c.dim("─".repeat(45)));
1168
+ console.log("");
1169
+
1170
+ const descriptions = {
1171
+ sticky: "Stay on one account until it fails or is rate-limited",
1172
+ "round-robin": "Rotate through accounts on every request",
1173
+ hybrid: "Prefer healthy accounts, rotate when degraded",
1174
+ };
1175
+
1176
+ for (const s of VALID_STRATEGIES) {
1177
+ const current = s === config.account_selection_strategy;
1178
+ const marker = current ? c.green("▸ ") : " ";
1179
+ const name = current ? c.bold(c.cyan(s)) : c.dim(s);
1180
+ const desc = current ? descriptions[s] : c.dim(descriptions[s]);
1181
+ console.log(`${marker}${pad(name, 16)}${desc}`);
1182
+ }
1183
+
1184
+ console.log("");
1185
+ console.log(c.dim(`Change with: opencode-anthropic-auth strategy <${VALID_STRATEGIES.join("|")}>`));
1186
+
1187
+ if (process.env.OPENCODE_ANTHROPIC_STRATEGY) {
1188
+ console.log(
1189
+ c.yellow(
1190
+ `\nNote: OPENCODE_ANTHROPIC_STRATEGY=${process.env.OPENCODE_ANTHROPIC_STRATEGY} overrides config file at runtime.`,
1191
+ ),
1192
+ );
1193
+ }
1194
+
1195
+ return 0;
1196
+ }
1197
+
1198
+ // Normalize input
1199
+ const normalized = arg.toLowerCase().trim();
1200
+
1201
+ if (!VALID_STRATEGIES.includes(normalized)) {
1202
+ console.error(c.red(`Error: invalid strategy '${arg}'.`));
1203
+ console.error(c.dim(`Valid strategies: ${VALID_STRATEGIES.join(", ")}`));
1204
+ return 1;
1205
+ }
1206
+
1207
+ if (normalized === config.account_selection_strategy && !process.env.OPENCODE_ANTHROPIC_STRATEGY) {
1208
+ console.log(c.dim(`Strategy is already '${normalized}'.`));
1209
+ return 0;
1210
+ }
1211
+
1212
+ saveConfig({ account_selection_strategy: normalized });
1213
+ console.log(c.green(`Strategy changed to '${normalized}'.`));
1214
+
1215
+ if (process.env.OPENCODE_ANTHROPIC_STRATEGY) {
1216
+ console.log(
1217
+ c.yellow(
1218
+ `Note: OPENCODE_ANTHROPIC_STRATEGY=${process.env.OPENCODE_ANTHROPIC_STRATEGY} will override this at runtime.`,
1219
+ ),
1220
+ );
1221
+ }
1222
+
1223
+ return 0;
1224
+ }
1225
+
1226
+ /**
1227
+ * Format a token count for display. Uses K/M suffixes for readability.
1228
+ * @param {number} n
1229
+ * @returns {string}
1230
+ */
1231
+ function fmtTokens(n) {
1232
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
1233
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
1234
+ return String(n);
1235
+ }
1236
+
1237
+ /**
1238
+ * Show per-account usage statistics.
1239
+ * @returns {Promise<number>} exit code
1240
+ */
1241
+ export async function cmdStats() {
1242
+ const stored = await loadAccounts();
1243
+ if (!stored || stored.accounts.length === 0) {
1244
+ console.log(c.yellow("No accounts configured."));
1245
+ return 1;
1246
+ }
1247
+
1248
+ // Column widths: marker(1) + gap(1) + num(2) + gap(2) + name(20) + gap(2) + 5 numeric cols(10 each)
1249
+ const W = { num: 4, name: 22, val: 10 };
1250
+
1251
+ const RULE = c.dim(" " + "─".repeat(74));
1252
+
1253
+ console.log(c.bold("Anthropic Account Usage"));
1254
+ console.log(
1255
+ " " +
1256
+ pad(c.dim("#"), W.num) +
1257
+ pad(c.dim("Account"), W.name) +
1258
+ rpad(c.dim("Requests"), W.val) +
1259
+ rpad(c.dim("Input"), W.val) +
1260
+ rpad(c.dim("Output"), W.val) +
1261
+ rpad(c.dim("Cache R"), W.val) +
1262
+ rpad(c.dim("Cache W"), W.val),
1263
+ );
1264
+ console.log(RULE);
1265
+
1266
+ let totReq = 0,
1267
+ totIn = 0,
1268
+ totOut = 0,
1269
+ totCR = 0,
1270
+ totCW = 0;
1271
+ let oldestReset = Infinity;
1272
+
1273
+ for (let i = 0; i < stored.accounts.length; i++) {
1274
+ const acc = stored.accounts[i];
1275
+ const s = acc.stats || createDefaultStats();
1276
+ const isActive = i === stored.activeIndex;
1277
+ const marker = isActive ? c.green("●") : " ";
1278
+ const num = `${marker} ${i + 1}`;
1279
+ const name = acc.email || `Account ${i + 1}`;
1280
+
1281
+ console.log(
1282
+ " " +
1283
+ pad(num, W.num) +
1284
+ pad(name, W.name) +
1285
+ rpad(String(s.requests), W.val) +
1286
+ rpad(fmtTokens(s.inputTokens), W.val) +
1287
+ rpad(fmtTokens(s.outputTokens), W.val) +
1288
+ rpad(fmtTokens(s.cacheReadTokens), W.val) +
1289
+ rpad(fmtTokens(s.cacheWriteTokens), W.val),
1290
+ );
1291
+
1292
+ totReq += s.requests;
1293
+ totIn += s.inputTokens;
1294
+ totOut += s.outputTokens;
1295
+ totCR += s.cacheReadTokens;
1296
+ totCW += s.cacheWriteTokens;
1297
+ if (s.lastReset < oldestReset) oldestReset = s.lastReset;
1298
+ }
1299
+
1300
+ if (stored.accounts.length > 1) {
1301
+ console.log(RULE);
1302
+ console.log(
1303
+ c.bold(
1304
+ " " +
1305
+ pad("", W.num) +
1306
+ pad("Total", W.name) +
1307
+ rpad(String(totReq), W.val) +
1308
+ rpad(fmtTokens(totIn), W.val) +
1309
+ rpad(fmtTokens(totOut), W.val) +
1310
+ rpad(fmtTokens(totCR), W.val) +
1311
+ rpad(fmtTokens(totCW), W.val),
1312
+ ),
1313
+ );
1314
+ }
1315
+
1316
+ console.log("");
1317
+ if (oldestReset < Infinity) {
1318
+ console.log(c.dim(`Tracking since: ${new Date(oldestReset).toLocaleString()} (${formatTimeAgo(oldestReset)})`));
1319
+ }
1320
+
1321
+ return 0;
1322
+ }
1323
+
1324
+ /**
1325
+ * Reset usage statistics for one or all accounts.
1326
+ * @param {string} [arg] - Account number or "all"
1327
+ * @returns {Promise<number>} exit code
1328
+ */
1329
+ export async function cmdResetStats(arg) {
1330
+ const stored = await loadAccounts();
1331
+ if (!stored || stored.accounts.length === 0) {
1332
+ console.log(c.yellow("No accounts configured."));
1333
+ return 1;
1334
+ }
1335
+
1336
+ const now = Date.now();
1337
+
1338
+ if (!arg || arg === "all") {
1339
+ for (const acc of stored.accounts) {
1340
+ acc.stats = createDefaultStats(now);
1341
+ }
1342
+ await saveAccounts(stored);
1343
+ console.log(c.green("Reset usage statistics for all accounts."));
1344
+ return 0;
1345
+ }
1346
+
1347
+ const idx = parseInt(arg, 10) - 1;
1348
+ if (isNaN(idx) || idx < 0 || idx >= stored.accounts.length) {
1349
+ console.log(c.red(`Invalid account number. Use 1-${stored.accounts.length} or 'all'.`));
1350
+ return 1;
1351
+ }
1352
+
1353
+ stored.accounts[idx].stats = createDefaultStats(now);
1354
+ await saveAccounts(stored);
1355
+ const name = stored.accounts[idx].email || `Account ${idx + 1}`;
1356
+ console.log(c.green(`Reset usage statistics for ${name}.`));
1357
+ return 0;
1358
+ }
1359
+
1360
+ /**
1361
+ * Interactive account management menu.
1362
+ *
1363
+ * Operates on raw storage (not AccountManager) to avoid stale-state issues.
1364
+ * Each mutation is saved atomically before the next prompt.
1365
+ *
1366
+ * @returns {Promise<number>} exit code
1367
+ */
1368
+ export async function cmdManage() {
1369
+ let stored = await loadAccounts();
1370
+ if (!stored || stored.accounts.length === 0) {
1371
+ console.log(c.yellow("No accounts configured."));
1372
+ console.log(c.dim("Run 'opencode auth login' and select 'Claude Pro/Max' to add accounts."));
1373
+ return 1;
1374
+ }
1375
+
1376
+ if (!process.stdin.isTTY) {
1377
+ console.error(c.red("Error: 'manage' requires an interactive terminal."));
1378
+ console.error(c.dim("Use 'enable', 'disable', 'remove', 'switch' for non-interactive use."));
1379
+ return 1;
1380
+ }
1381
+
1382
+ const rl = createInterface({ input: stdin, output: stdout });
1383
+
1384
+ try {
1385
+ while (true) {
1386
+ // Re-read from disk each iteration to stay in sync
1387
+ stored = await loadAccounts();
1388
+ if (!stored || stored.accounts.length === 0) {
1389
+ console.log(c.dim("No accounts remaining."));
1390
+ break;
1391
+ }
1392
+
1393
+ const accounts = stored.accounts;
1394
+
1395
+ console.log("");
1396
+ console.log(c.bold(`${accounts.length} account(s):`));
1397
+ for (let i = 0; i < accounts.length; i++) {
1398
+ const num = i + 1;
1399
+ const label = accounts[i].email || `Account ${num}`;
1400
+ const active = i === stored.activeIndex ? c.green(" (active)") : "";
1401
+ const disabled = !accounts[i].enabled ? c.yellow(" [disabled]") : "";
1402
+ console.log(` ${c.bold(String(num))}. ${label}${active}${disabled}`);
1403
+ }
1404
+ console.log("");
1405
+ const currentStrategy = loadConfig().account_selection_strategy;
1406
+ console.log(c.dim(`Strategy: ${currentStrategy}`));
1407
+ console.log(c.dim("Commands: (s)witch N, (e)nable N, (d)isable N, (r)emove N, (R)eset N, s(t)rategy, (q)uit"));
1408
+
1409
+ const answer = await rl.question(c.dim("> "));
1410
+ const trimmed = answer.trim();
1411
+
1412
+ // Handle quit (case-insensitive)
1413
+ if (trimmed.toLowerCase() === "q" || trimmed.toLowerCase() === "quit") break;
1414
+
1415
+ // Parse command + number. Keep original case for 'R' detection.
1416
+ const match = trimmed.match(/^([a-zA-Z]+)\s*(\d+)?$/);
1417
+ if (!match) {
1418
+ console.log(c.red("Invalid input. Try 's 2', 'e 3', 'd 1', 'r 2', 'R 1', or 'q'."));
1419
+ continue;
1420
+ }
1421
+
1422
+ const [, rawCmd, numStr] = match;
1423
+ const cmd = rawCmd.toLowerCase();
1424
+ const num = numStr ? parseInt(numStr, 10) : NaN;
1425
+ const idx = num - 1;
1426
+
1427
+ if (numStr && (isNaN(num) || num < 1 || idx >= accounts.length)) {
1428
+ console.log(c.red(`Invalid account number. Valid range: 1-${accounts.length}.`));
1429
+ continue;
1430
+ }
1431
+
1432
+ // Detect uppercase R for reset (since lowercase 'r' is remove)
1433
+ const isReset = rawCmd === "R" || cmd === "reset";
1434
+
1435
+ if (isReset) {
1436
+ if (isNaN(num)) {
1437
+ console.log(c.red("Usage: R <number>"));
1438
+ continue;
1439
+ }
1440
+ stored.accounts[idx].rateLimitResetTimes = {};
1441
+ stored.accounts[idx].consecutiveFailures = 0;
1442
+ stored.accounts[idx].lastFailureTime = null;
1443
+ await saveAccounts(stored);
1444
+ console.log(c.green(`Reset tracking for account #${num}.`));
1445
+ continue;
1446
+ }
1447
+
1448
+ switch (cmd) {
1449
+ case "s":
1450
+ case "switch": {
1451
+ if (isNaN(num)) {
1452
+ console.log(c.red("Usage: s <number>"));
1453
+ break;
1454
+ }
1455
+ if (!accounts[idx].enabled) {
1456
+ console.log(c.yellow(`Account ${num} is disabled. Enable it first.`));
1457
+ break;
1458
+ }
1459
+ stored.activeIndex = idx;
1460
+ await saveAccounts(stored);
1461
+ const switchLabel = accounts[idx].email || `Account ${num}`;
1462
+ console.log(c.green(`Switched to #${num} (${switchLabel}).`));
1463
+ break;
1464
+ }
1465
+ case "e":
1466
+ case "enable": {
1467
+ if (isNaN(num)) {
1468
+ console.log(c.red("Usage: e <number>"));
1469
+ break;
1470
+ }
1471
+ if (accounts[idx].enabled) {
1472
+ console.log(c.dim(`Account ${num} is already enabled.`));
1473
+ break;
1474
+ }
1475
+ stored.accounts[idx].enabled = true;
1476
+ await saveAccounts(stored);
1477
+ console.log(c.green(`Enabled account #${num}.`));
1478
+ break;
1479
+ }
1480
+ case "d":
1481
+ case "disable": {
1482
+ if (isNaN(num)) {
1483
+ console.log(c.red("Usage: d <number>"));
1484
+ break;
1485
+ }
1486
+ if (!accounts[idx].enabled) {
1487
+ console.log(c.dim(`Account ${num} is already disabled.`));
1488
+ break;
1489
+ }
1490
+ const enabledCount = accounts.filter((a) => a.enabled).length;
1491
+ if (enabledCount <= 1) {
1492
+ console.log(c.red("Cannot disable the last enabled account."));
1493
+ break;
1494
+ }
1495
+ stored.accounts[idx].enabled = false;
1496
+ // Adjust active index if needed
1497
+ if (idx === stored.activeIndex) {
1498
+ const nextEnabled = accounts.findIndex((a) => a.enabled && accounts.indexOf(a) !== idx);
1499
+ if (nextEnabled >= 0) stored.activeIndex = nextEnabled;
1500
+ }
1501
+ await saveAccounts(stored);
1502
+ console.log(c.yellow(`Disabled account #${num}.`));
1503
+ break;
1504
+ }
1505
+ case "r":
1506
+ case "remove": {
1507
+ if (isNaN(num)) {
1508
+ console.log(c.red("Usage: r <number>"));
1509
+ break;
1510
+ }
1511
+ const removeLabel = accounts[idx].email || `Account ${num}`;
1512
+ const confirm = await rl.question(`Remove #${num} (${removeLabel})? [y/N]: `);
1513
+ if (confirm.trim().toLowerCase() === "y") {
1514
+ stored.accounts.splice(idx, 1);
1515
+ // Adjust active index
1516
+ if (stored.accounts.length === 0) {
1517
+ stored.activeIndex = 0;
1518
+ } else if (stored.activeIndex >= stored.accounts.length) {
1519
+ stored.activeIndex = stored.accounts.length - 1;
1520
+ } else if (stored.activeIndex > idx) {
1521
+ stored.activeIndex--;
1522
+ }
1523
+ await saveAccounts(stored);
1524
+ console.log(c.green(`Removed account #${num}.`));
1525
+ } else {
1526
+ console.log(c.dim("Cancelled."));
1527
+ }
1528
+ break;
1529
+ }
1530
+ case "t":
1531
+ case "strategy": {
1532
+ console.log(c.dim(`Current: ${loadConfig().account_selection_strategy}`));
1533
+ console.log(c.dim(`Options: ${VALID_STRATEGIES.join(", ")}`));
1534
+ const stratAnswer = await rl.question(c.dim("New strategy: "));
1535
+ const strat = stratAnswer.trim().toLowerCase();
1536
+ if (!strat) {
1537
+ console.log(c.dim("Cancelled."));
1538
+ break;
1539
+ }
1540
+ if (!VALID_STRATEGIES.includes(strat)) {
1541
+ console.log(c.red(`Invalid strategy. Choose: ${VALID_STRATEGIES.join(", ")}`));
1542
+ break;
1543
+ }
1544
+ saveConfig({ account_selection_strategy: strat });
1545
+ console.log(c.green(`Strategy changed to '${strat}'.`));
1546
+ break;
1547
+ }
1548
+ default:
1549
+ console.log(c.red("Unknown command. Try 's', 'e', 'd', 'r', 'R', 't', or 'q'."));
1550
+ }
1551
+ }
1552
+ } finally {
1553
+ rl.close();
1554
+ }
1555
+
1556
+ return 0;
1557
+ }
1558
+
1559
+ /**
1560
+ * Show help text.
1561
+ */
1562
+ export function cmdHelp() {
1563
+ const bin = "opencode-anthropic-auth";
1564
+ console.log(`
1565
+ ${c.bold("Anthropic Multi-Account Auth CLI")}
1566
+
1567
+ ${c.dim("Usage:")}
1568
+ ${bin} [command] [args]
1569
+
1570
+ ${c.dim("Auth Commands:")}
1571
+ ${pad(c.cyan("login"), 22)}Add a new account via browser OAuth flow
1572
+ ${pad(c.cyan("logout") + " <N>", 22)}Revoke tokens and remove account N
1573
+ ${pad(c.cyan("logout") + " --all", 22)}Revoke all tokens and clear all accounts
1574
+ ${pad(c.cyan("reauth") + " <N>", 22)}Re-authenticate account N with fresh tokens
1575
+ ${pad(c.cyan("refresh") + " <N>", 22)}Attempt token refresh (no browser needed)
1576
+
1577
+ ${c.dim("Account Commands:")}
1578
+ ${pad(c.cyan("list"), 22)}Show all accounts with status ${c.dim("(default)")}
1579
+ ${pad(c.cyan("status"), 22)}Compact one-liner for scripts/prompts
1580
+ ${pad(c.cyan("switch") + " <N>", 22)}Set account N as active
1581
+ ${pad(c.cyan("enable") + " <N>", 22)}Enable a disabled account
1582
+ ${pad(c.cyan("disable") + " <N>", 22)}Disable an account (skipped in rotation)
1583
+ ${pad(c.cyan("remove") + " <N>", 22)}Remove an account permanently
1584
+ ${pad(c.cyan("reset") + " <N|all>", 22)}Clear rate-limit / failure tracking
1585
+ ${pad(c.cyan("stats"), 22)}Show per-account usage statistics
1586
+ ${pad(c.cyan("reset-stats") + " [N|all]", 22)}Reset usage statistics
1587
+ ${pad(c.cyan("strategy") + " [name]", 22)}Show or change selection strategy
1588
+ ${pad(c.cyan("config"), 22)}Show configuration and file paths
1589
+ ${pad(c.cyan("manage"), 22)}Interactive account management menu
1590
+ ${pad(c.cyan("help"), 22)}Show this help message
1591
+
1592
+ ${c.dim("Options:")}
1593
+ --force Skip confirmation prompts
1594
+ --all Target all accounts (for logout)
1595
+ --no-color Disable colored output
1596
+
1597
+ ${c.dim("Examples:")}
1598
+ ${bin} login ${c.dim("# Add a new account via browser")}
1599
+ ${bin} logout 2 ${c.dim("# Revoke tokens & remove account 2")}
1600
+ ${bin} logout --all ${c.dim("# Logout all accounts")}
1601
+ ${bin} reauth 1 ${c.dim("# Re-authenticate account 1")}
1602
+ ${bin} refresh 1 ${c.dim("# Quick token refresh for account 1")}
1603
+ ${bin} list ${c.dim("# Show all accounts")}
1604
+ ${bin} switch 2 ${c.dim("# Make account 2 active")}
1605
+ ${bin} disable 3 ${c.dim("# Temporarily disable account 3")}
1606
+ ${bin} reset all ${c.dim("# Clear all rate-limit tracking")}
1607
+ ${bin} strategy sticky ${c.dim("# Switch to sticky mode")}
1608
+ ${bin} stats ${c.dim("# Show token usage per account")}
1609
+ ${bin} status ${c.dim("# One-liner for shell prompt")}
1610
+
1611
+ ${c.dim("Files:")}
1612
+ Config: ${shortPath(getConfigPath())}
1613
+ Accounts: ${shortPath(getStoragePath())}
1614
+ `);
1615
+ return 0;
1616
+ }
1617
+
1618
+ // ---------------------------------------------------------------------------
1619
+ // Main entry point
1620
+ // ---------------------------------------------------------------------------
1621
+
1622
+ /** @type {AsyncLocalStorage<{ log?: (...args: any[]) => void, error?: (...args: any[]) => void }>} */
1623
+ const ioContext = new AsyncLocalStorage();
1624
+
1625
+ const nativeConsoleLog = console.log.bind(console);
1626
+ const nativeConsoleError = console.error.bind(console);
1627
+ let consoleRouterUsers = 0;
1628
+
1629
+ function installConsoleRouter() {
1630
+ if (consoleRouterUsers === 0) {
1631
+ console.log = (...args) => {
1632
+ const io = ioContext.getStore();
1633
+ if (io?.log) return io.log(...args);
1634
+ return nativeConsoleLog(...args);
1635
+ };
1636
+ console.error = (...args) => {
1637
+ const io = ioContext.getStore();
1638
+ if (io?.error) return io.error(...args);
1639
+ return nativeConsoleError(...args);
1640
+ };
1641
+ }
1642
+ consoleRouterUsers++;
1643
+ }
1644
+
1645
+ function uninstallConsoleRouter() {
1646
+ consoleRouterUsers = Math.max(0, consoleRouterUsers - 1);
1647
+ if (consoleRouterUsers === 0) {
1648
+ console.log = nativeConsoleLog;
1649
+ console.error = nativeConsoleError;
1650
+ }
1651
+ }
1652
+
1653
+ /**
1654
+ * Run with async-local IO capture without persistent global side effects.
1655
+ * @param {{ log?: (...args: any[]) => void, error?: (...args: any[]) => void }} io
1656
+ * @param {() => Promise<number>} fn
1657
+ */
1658
+ async function runWithIoContext(io, fn) {
1659
+ installConsoleRouter();
1660
+ try {
1661
+ return await ioContext.run(io, fn);
1662
+ } finally {
1663
+ uninstallConsoleRouter();
1664
+ }
1665
+ }
1666
+
1667
+ /**
1668
+ * Parse argv and route to the appropriate command.
1669
+ * @param {string[]} argv - process.argv.slice(2)
1670
+ * @returns {Promise<number>} exit code
1671
+ */
1672
+ async function dispatch(argv) {
1673
+ const args = argv.filter((a) => !a.startsWith("--"));
1674
+ const flags = argv.filter((a) => a.startsWith("--"));
1675
+
1676
+ // Handle global flags
1677
+ if (flags.includes("--no-color")) USE_COLOR = false;
1678
+ if (flags.includes("--help")) return cmdHelp();
1679
+
1680
+ const command = args[0] || "list";
1681
+ const arg = args[1];
1682
+
1683
+ const force = flags.includes("--force");
1684
+ const all = flags.includes("--all");
1685
+
1686
+ switch (command) {
1687
+ // Auth commands
1688
+ case "login":
1689
+ case "ln":
1690
+ return cmdLogin();
1691
+ case "logout":
1692
+ case "lo":
1693
+ return cmdLogout(arg, { force, all });
1694
+ case "reauth":
1695
+ case "ra":
1696
+ return cmdReauth(arg);
1697
+ case "refresh":
1698
+ case "rf":
1699
+ return cmdRefresh(arg);
1700
+ // Account management commands
1701
+ case "list":
1702
+ case "ls":
1703
+ return cmdList();
1704
+ case "status":
1705
+ case "st":
1706
+ return cmdStatus();
1707
+ case "switch":
1708
+ case "sw":
1709
+ return cmdSwitch(arg);
1710
+ case "enable":
1711
+ case "en":
1712
+ return cmdEnable(arg);
1713
+ case "disable":
1714
+ case "dis":
1715
+ return cmdDisable(arg);
1716
+ case "remove":
1717
+ case "rm":
1718
+ return cmdRemove(arg, { force });
1719
+ case "reset":
1720
+ return cmdReset(arg);
1721
+ case "stats":
1722
+ return cmdStats();
1723
+ case "reset-stats":
1724
+ return cmdResetStats(arg);
1725
+ case "strategy":
1726
+ case "strat":
1727
+ return cmdStrategy(arg);
1728
+ case "config":
1729
+ case "cfg":
1730
+ return cmdConfig();
1731
+ case "manage":
1732
+ case "mg":
1733
+ return cmdManage();
1734
+ case "help":
1735
+ case "-h":
1736
+ case "--help":
1737
+ return cmdHelp();
1738
+ default:
1739
+ console.error(c.red(`Unknown command: ${command}`));
1740
+ console.error(c.dim("Run 'opencode-anthropic-auth help' for usage."));
1741
+ return 1;
1742
+ }
1743
+ }
1744
+
1745
+ /**
1746
+ * Parse argv and route to the appropriate command.
1747
+ * @param {string[]} argv - process.argv.slice(2)
1748
+ * @param {{ io?: { log?: (...args: any[]) => void, error?: (...args: any[]) => void } }} [options]
1749
+ * @returns {Promise<number>} exit code
1750
+ */
1751
+ export async function main(argv, options = {}) {
1752
+ if (options.io) {
1753
+ return runWithIoContext(options.io, () => dispatch(argv));
1754
+ }
1755
+ return dispatch(argv);
1756
+ }
1757
+
1758
+ // Run if executed directly (not imported)
1759
+ async function detectMain() {
1760
+ if (!process.argv[1]) return false;
1761
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) return true;
1762
+ // Handle symlinks (e.g., ~/.config/opencode/plugin/opencode-anthropic-auth-plugin.js → index.mjs)
1763
+ try {
1764
+ const { realpath } = await import("node:fs/promises");
1765
+ const resolved = await realpath(process.argv[1]);
1766
+ return import.meta.url === pathToFileURL(resolved).href;
1767
+ } catch {
1768
+ return false;
1769
+ }
1770
+ }
1771
+
1772
+ if (await detectMain()) {
1773
+ main(process.argv.slice(2))
1774
+ .then((code) => process.exit(code))
1775
+ .catch((err) => {
1776
+ console.error(c.red(`Fatal: ${err.message}`));
1777
+ process.exit(1);
1778
+ });
1779
+ }