opencode-anthropic-fix 0.0.17 → 0.0.18

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