opencode-anthropic-fix 0.0.14

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