@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -0,0 +1,963 @@
1
+ import { exec } from "node:child_process";
2
+ import { findByIdentity, resolveIdentityFromOAuthExchange } from "../../account-identity.js";
3
+ import { CLIENT_ID, loadConfig } from "../../config.js";
4
+ import { authorize, exchange, revoke } from "../../oauth.js";
5
+ import { createDefaultStats, getStoragePath, loadAccounts, saveAccounts, type AccountMetadata } from "../../storage.js";
6
+ import { loadAccountsWithRepair } from "../../accounts/repair.js";
7
+ import { confirm, intro, isCancel, log, spinner, text } from "@clack/prompts";
8
+ import {
9
+ c,
10
+ fmtTokens,
11
+ formatDuration,
12
+ formatTimeAgo,
13
+ pad,
14
+ renderUsageLines,
15
+ rpad,
16
+ shortPath,
17
+ USAGE_INDENT,
18
+ } from "../formatting.js";
19
+
20
+ type RefreshableAccount = Pick<AccountMetadata, "refreshToken" | "access" | "expires" | "token_updated_at">;
21
+ type UsageAccount = Pick<AccountMetadata, "refreshToken" | "access" | "expires" | "enabled" | "token_updated_at">;
22
+ type LogoutOptions = { force?: boolean; all?: boolean };
23
+ type RemoveOptions = { force?: boolean };
24
+
25
+ /**
26
+ * Refresh an account's OAuth access token.
27
+ * Mutates the account object in-place and returns the new access token.
28
+ */
29
+ export async function refreshAccessToken(account: RefreshableAccount) {
30
+ try {
31
+ const resp = await fetch("https://platform.claude.com/v1/oauth/token", {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({
35
+ grant_type: "refresh_token",
36
+ refresh_token: account.refreshToken,
37
+ client_id: CLIENT_ID,
38
+ }),
39
+ signal: AbortSignal.timeout(5000),
40
+ });
41
+ if (!resp.ok) return null;
42
+
43
+ const json = (await resp.json()) as {
44
+ access_token: string;
45
+ expires_in: number;
46
+ refresh_token?: string;
47
+ };
48
+
49
+ account.access = json.access_token;
50
+ account.expires = Date.now() + json.expires_in * 1000;
51
+ if (json.refresh_token) account.refreshToken = json.refresh_token;
52
+ account.token_updated_at = Date.now();
53
+ return json.access_token;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Fetch usage quotas from the Anthropic OAuth usage endpoint.
61
+ */
62
+ export async function fetchUsage(accessToken: string) {
63
+ try {
64
+ const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
65
+ headers: {
66
+ authorization: `Bearer ${accessToken}`,
67
+ "anthropic-beta": "oauth-2025-04-20",
68
+ accept: "application/json",
69
+ },
70
+ signal: AbortSignal.timeout(5000),
71
+ });
72
+ if (!resp.ok) return null;
73
+ return resp.json() as Promise<Record<string, unknown>>;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Ensure an account has a valid access token and fetch its usage data.
81
+ */
82
+ export async function ensureTokenAndFetchUsage(account: UsageAccount) {
83
+ if (!account.enabled) return { usage: null, tokenRefreshed: false };
84
+
85
+ let token: string | undefined = account.access;
86
+ let tokenRefreshed = false;
87
+
88
+ if (!token || !account.expires || account.expires < Date.now()) {
89
+ const refreshedToken = await refreshAccessToken(account);
90
+ tokenRefreshed = !!refreshedToken;
91
+ if (!refreshedToken) return { usage: null, tokenRefreshed: false };
92
+ token = refreshedToken;
93
+ }
94
+
95
+ const usage = await fetchUsage(token);
96
+ return { usage, tokenRefreshed };
97
+ }
98
+
99
+ /**
100
+ * Open a URL in the user's default browser.
101
+ * Best-effort: uses platform-specific command, silently fails on error.
102
+ */
103
+ function openBrowser(url: string) {
104
+ if (process.platform === "win32") {
105
+ exec(`cmd /c start "" ${JSON.stringify(url)}`);
106
+ return;
107
+ }
108
+
109
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
110
+ exec(`${cmd} ${JSON.stringify(url)}`);
111
+ }
112
+
113
+ /**
114
+ * Run the OAuth PKCE login flow from the CLI.
115
+ * Opens browser, prompts for code, exchanges for tokens.
116
+ */
117
+ async function runOAuthFlow() {
118
+ const { url, verifier, state } = await authorize("max");
119
+
120
+ log.info("Opening browser for Anthropic OAuth login...");
121
+ log.info("If your browser didn't open, visit this URL:");
122
+ log.info(url);
123
+
124
+ openBrowser(url);
125
+
126
+ const code = await text({
127
+ message: "Paste the authorization code here:",
128
+ placeholder: "auth-code#state",
129
+ });
130
+ if (isCancel(code)) {
131
+ log.warn("Login cancelled.");
132
+ return null;
133
+ }
134
+
135
+ const trimmed = code.trim();
136
+ if (!trimmed) {
137
+ log.error("Error: no authorization code provided.");
138
+ return null;
139
+ }
140
+
141
+ const parts = trimmed.split("#");
142
+ if (state && parts[1] && parts[1] !== state) {
143
+ log.error("Error: OAuth state mismatch — possible CSRF attack.");
144
+ return null;
145
+ }
146
+
147
+ const s = spinner();
148
+ s.start("Exchanging authorization code for tokens...");
149
+ const credentials = await exchange(trimmed, verifier);
150
+ if (credentials.type === "failed") {
151
+ if (credentials.details) {
152
+ s.stop(`Token exchange failed (${credentials.details}).`);
153
+ } else {
154
+ s.stop("Token exchange failed. The code may be invalid or expired.");
155
+ }
156
+ return null;
157
+ }
158
+
159
+ s.stop("Token exchange successful.");
160
+
161
+ return {
162
+ refresh: credentials.refresh,
163
+ access: credentials.access,
164
+ expires: credentials.expires,
165
+ email: credentials.email,
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Login: add a new account via browser OAuth flow.
171
+ */
172
+ export async function cmdLogin() {
173
+ if (!process.stdin.isTTY) {
174
+ log.error("Error: 'login' requires an interactive terminal.");
175
+ return 1;
176
+ }
177
+
178
+ intro("Login — Add a new account");
179
+
180
+ const stored = await loadAccounts();
181
+ const credentials = await runOAuthFlow();
182
+ if (!credentials) return 1;
183
+
184
+ const storage = stored || { version: 1, accounts: [], activeIndex: 0 };
185
+ const identity = resolveIdentityFromOAuthExchange(credentials);
186
+
187
+ const existing =
188
+ findByIdentity(storage.accounts, identity) ||
189
+ storage.accounts.find((account) => account.refreshToken === credentials.refresh);
190
+
191
+ if (existing) {
192
+ const existingIdx = storage.accounts.indexOf(existing);
193
+ const existingIsCC = existing.source === "cc-keychain" || existing.source === "cc-file";
194
+ existing.refreshToken = credentials.refresh;
195
+ existing.access = credentials.access;
196
+ existing.expires = credentials.expires;
197
+ existing.token_updated_at = Date.now();
198
+ existing.enabled = true;
199
+ if (!existingIsCC) {
200
+ if (credentials.email) existing.email = credentials.email;
201
+ existing.identity = identity;
202
+ existing.source = existing.source ?? "oauth";
203
+ }
204
+ await saveAccounts(storage);
205
+
206
+ const label = credentials.email || existing.email || `Account ${existingIdx + 1}`;
207
+ log.success(`Updated existing account #${existingIdx + 1} (${label}).`);
208
+ return 0;
209
+ }
210
+
211
+ if (storage.accounts.length >= 10) {
212
+ log.error("Error: maximum of 10 accounts reached. Remove one first.");
213
+ return 1;
214
+ }
215
+
216
+ const now = Date.now();
217
+ storage.accounts.push({
218
+ id: `${now}:${credentials.refresh.slice(0, 12)}`,
219
+ email: credentials.email,
220
+ identity,
221
+ refreshToken: credentials.refresh,
222
+ access: credentials.access,
223
+ expires: credentials.expires,
224
+ token_updated_at: now,
225
+ addedAt: now,
226
+ lastUsed: 0,
227
+ enabled: true,
228
+ rateLimitResetTimes: {},
229
+ consecutiveFailures: 0,
230
+ lastFailureTime: null,
231
+ stats: createDefaultStats(now),
232
+ source: "oauth",
233
+ });
234
+
235
+ await saveAccounts(storage);
236
+
237
+ const label = credentials.email || `Account ${storage.accounts.length}`;
238
+ log.success(`Added account #${storage.accounts.length} (${label}).`);
239
+ log.info(`${storage.accounts.length} account(s) total.`);
240
+ return 0;
241
+ }
242
+
243
+ /**
244
+ * Logout: revoke tokens and remove an account, or all accounts.
245
+ */
246
+ export async function cmdLogout(arg?: string, opts: LogoutOptions = {}) {
247
+ if (opts.all) {
248
+ return cmdLogoutAll(opts);
249
+ }
250
+
251
+ const n = parseInt(arg || "", 10);
252
+ if (isNaN(n) || n < 1) {
253
+ log.error("Error: provide a valid account number (e.g., 'logout 2') or --all.");
254
+ return 1;
255
+ }
256
+
257
+ const stored = await loadAccounts();
258
+ if (!stored || stored.accounts.length === 0) {
259
+ log.error("Error: no accounts configured.");
260
+ return 1;
261
+ }
262
+
263
+ const idx = n - 1;
264
+ if (idx >= stored.accounts.length) {
265
+ log.error(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`);
266
+ return 1;
267
+ }
268
+
269
+ const label = stored.accounts[idx].email || `Account ${n}`;
270
+
271
+ if (!opts.force) {
272
+ if (!process.stdin.isTTY) {
273
+ log.error("Error: use --force to logout in non-interactive mode.");
274
+ return 1;
275
+ }
276
+
277
+ const shouldLogout = await confirm({
278
+ message: `Logout account #${n} (${label})? This will revoke tokens and remove the account.`,
279
+ });
280
+ if (isCancel(shouldLogout) || !shouldLogout) {
281
+ log.info("Cancelled.");
282
+ return 0;
283
+ }
284
+ }
285
+
286
+ const revoked = await revoke(stored.accounts[idx].refreshToken);
287
+ if (revoked) {
288
+ log.info("Token revoked server-side.");
289
+ } else {
290
+ log.info("Token revocation skipped (server may not support it).");
291
+ }
292
+
293
+ stored.accounts.splice(idx, 1);
294
+
295
+ if (stored.accounts.length === 0) {
296
+ stored.activeIndex = 0;
297
+ } else if (stored.activeIndex >= stored.accounts.length) {
298
+ stored.activeIndex = stored.accounts.length - 1;
299
+ } else if (stored.activeIndex > idx) {
300
+ stored.activeIndex--;
301
+ }
302
+
303
+ await saveAccounts(stored);
304
+ log.success(`Logged out account #${n} (${label}).`);
305
+
306
+ if (stored.accounts.length > 0) {
307
+ log.info(`${stored.accounts.length} account(s) remaining.`);
308
+ } else {
309
+ log.info("No accounts remaining. Run 'login' to add one.");
310
+ }
311
+
312
+ return 0;
313
+ }
314
+
315
+ async function cmdLogoutAll(opts: Pick<LogoutOptions, "force"> = {}) {
316
+ const stored = await loadAccounts();
317
+ if (!stored || stored.accounts.length === 0) {
318
+ log.info("No accounts to logout.");
319
+ return 0;
320
+ }
321
+
322
+ const count = stored.accounts.length;
323
+
324
+ if (!opts.force) {
325
+ if (!process.stdin.isTTY) {
326
+ log.error("Error: use --force to logout all in non-interactive mode.");
327
+ return 1;
328
+ }
329
+
330
+ const shouldLogoutAll = await confirm({
331
+ message: `Logout all ${count} account(s)? This will revoke tokens and remove all accounts.`,
332
+ });
333
+ if (isCancel(shouldLogoutAll) || !shouldLogoutAll) {
334
+ log.info("Cancelled.");
335
+ return 0;
336
+ }
337
+ }
338
+
339
+ const results = await Promise.allSettled(stored.accounts.map((account) => revoke(account.refreshToken)));
340
+ const revokedCount = results.filter((result) => result.status === "fulfilled" && result.value === true).length;
341
+
342
+ if (revokedCount > 0) {
343
+ log.info(`Revoked ${revokedCount} of ${count} token(s) server-side.`);
344
+ }
345
+
346
+ await saveAccounts({ version: 1, accounts: [], activeIndex: 0 });
347
+ log.success(`Logged out all ${count} account(s).`);
348
+ return 0;
349
+ }
350
+
351
+ /**
352
+ * Reauth: re-authenticate an existing account with fresh OAuth tokens.
353
+ */
354
+ export async function cmdReauth(arg: string) {
355
+ const n = parseInt(arg, 10);
356
+ if (isNaN(n) || n < 1) {
357
+ log.error("Error: provide a valid account number (e.g., 'reauth 1')");
358
+ return 1;
359
+ }
360
+
361
+ if (!process.stdin.isTTY) {
362
+ log.error("Error: 'reauth' requires an interactive terminal.");
363
+ return 1;
364
+ }
365
+
366
+ const stored = await loadAccounts();
367
+ if (!stored || stored.accounts.length === 0) {
368
+ log.error("Error: no accounts configured.");
369
+ return 1;
370
+ }
371
+
372
+ const idx = n - 1;
373
+ if (idx >= stored.accounts.length) {
374
+ log.error(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`);
375
+ return 1;
376
+ }
377
+
378
+ const existing = stored.accounts[idx];
379
+ const existingIsCC = existing.source === "cc-keychain" || existing.source === "cc-file";
380
+ const wasDisabled = !existing.enabled;
381
+ const oldLabel = existing.email || `Account ${n}`;
382
+ log.info(`Re-authenticating account #${n} (${oldLabel})...`);
383
+
384
+ const credentials = await runOAuthFlow();
385
+ if (!credentials) return 1;
386
+
387
+ existing.refreshToken = credentials.refresh;
388
+ existing.access = credentials.access;
389
+ existing.expires = credentials.expires;
390
+ existing.token_updated_at = Date.now();
391
+ existing.enabled = true;
392
+ existing.consecutiveFailures = 0;
393
+ existing.lastFailureTime = null;
394
+ existing.rateLimitResetTimes = {};
395
+ if (!existingIsCC) {
396
+ if (credentials.email) existing.email = credentials.email;
397
+ existing.identity = resolveIdentityFromOAuthExchange(credentials);
398
+ existing.source = existing.source ?? "oauth";
399
+ }
400
+
401
+ await saveAccounts(stored);
402
+
403
+ const newLabel = credentials.email || `Account ${n}`;
404
+ log.success(`Re-authenticated account #${n} (${newLabel}).`);
405
+ if (wasDisabled) {
406
+ log.info("Account has been re-enabled.");
407
+ }
408
+
409
+ return 0;
410
+ }
411
+
412
+ /**
413
+ * Refresh: attempt a token refresh for an account without browser interaction.
414
+ */
415
+ export async function cmdRefresh(arg: string) {
416
+ const n = parseInt(arg, 10);
417
+ if (isNaN(n) || n < 1) {
418
+ log.error("Error: provide a valid account number (e.g., 'refresh 1')");
419
+ return 1;
420
+ }
421
+
422
+ const stored = await loadAccounts();
423
+ if (!stored || stored.accounts.length === 0) {
424
+ log.error("Error: no accounts configured.");
425
+ return 1;
426
+ }
427
+
428
+ const idx = n - 1;
429
+ if (idx >= stored.accounts.length) {
430
+ log.error(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`);
431
+ return 1;
432
+ }
433
+
434
+ const account = stored.accounts[idx];
435
+ const label = account.email || `Account ${n}`;
436
+
437
+ const s = spinner();
438
+ s.start(`Refreshing token for account #${n} (${label})...`);
439
+
440
+ const token = await refreshAccessToken(account);
441
+ if (!token) {
442
+ s.stop(`Token refresh failed for account #${n}.`);
443
+ log.error("The refresh token may be invalid or expired.");
444
+ log.error(`Try: opencode-anthropic-auth reauth ${n}`);
445
+ return 1;
446
+ }
447
+
448
+ const wasDisabled = !account.enabled;
449
+ account.enabled = true;
450
+ account.consecutiveFailures = 0;
451
+ account.lastFailureTime = null;
452
+ account.rateLimitResetTimes = {};
453
+
454
+ await saveAccounts(stored);
455
+
456
+ const expiresIn = account.expires ? formatDuration(account.expires - Date.now()) : "unknown";
457
+ s.stop("Token refreshed.");
458
+ log.success(`Token refreshed for account #${n} (${label}).`);
459
+ log.info(`New token expires in ${expiresIn}.`);
460
+ if (wasDisabled) {
461
+ log.info("Account has been re-enabled.");
462
+ }
463
+
464
+ return 0;
465
+ }
466
+
467
+ /**
468
+ * List all accounts with full status table and live usage quotas.
469
+ */
470
+ export async function cmdList() {
471
+ const stored = await loadAccountsWithRepair();
472
+ if (!stored || stored.accounts.length === 0) {
473
+ log.warn("No accounts configured.");
474
+ log.info(`Storage: ${shortPath(getStoragePath())}`);
475
+ log.info("Run 'opencode auth login' and select 'Claude Pro/Max' to add accounts.");
476
+ return 1;
477
+ }
478
+
479
+ const config = loadConfig();
480
+ const now = Date.now();
481
+
482
+ const s = spinner();
483
+ s.start("Fetching usage quotas...");
484
+ const usageResults = await Promise.allSettled(stored.accounts.map((account) => ensureTokenAndFetchUsage(account)));
485
+ s.stop("Usage quotas fetched.");
486
+
487
+ let anyRefreshed = false;
488
+ for (const result of usageResults) {
489
+ if (result.status === "fulfilled" && result.value.tokenRefreshed) {
490
+ anyRefreshed = true;
491
+ }
492
+ }
493
+
494
+ if (anyRefreshed) {
495
+ await saveAccounts(stored).catch((error) => {
496
+ console.error("[opencode-anthropic-auth] failed to persist refreshed tokens:", error);
497
+ });
498
+ }
499
+
500
+ log.message(c.bold("Anthropic Multi-Account Status"));
501
+ log.message(
502
+ " " +
503
+ pad(c.dim("#"), 5) +
504
+ pad(c.dim("Account"), 22) +
505
+ pad(c.dim("Status"), 14) +
506
+ pad(c.dim("Failures"), 11) +
507
+ c.dim("Rate Limit"),
508
+ );
509
+ log.message(c.dim(" " + "─".repeat(62)));
510
+
511
+ for (let i = 0; i < stored.accounts.length; i++) {
512
+ const account = stored.accounts[i];
513
+ const isActive = i === stored.activeIndex;
514
+ const label = account.email || `Account ${i + 1}`;
515
+ const status = !account.enabled ? c.gray("○ disabled") : isActive ? c.green("● active") : c.cyan("● ready");
516
+ const failures = !account.enabled
517
+ ? c.dim("—")
518
+ : account.consecutiveFailures > 0
519
+ ? c.yellow(String(account.consecutiveFailures))
520
+ : c.dim("0");
521
+
522
+ let rateLimit: string;
523
+ if (!account.enabled) {
524
+ rateLimit = c.dim("—");
525
+ } else {
526
+ const maxReset = Math.max(0, ...Object.values(account.rateLimitResetTimes || {}));
527
+ rateLimit = maxReset > now ? c.yellow(`\u26A0 ${formatDuration(maxReset - now)}`) : c.dim("—");
528
+ }
529
+
530
+ log.message(
531
+ " " + pad(c.bold(String(i + 1)), 5) + pad(label, 22) + pad(status, 14) + pad(failures, 11) + rateLimit,
532
+ );
533
+
534
+ if (account.enabled) {
535
+ const result = usageResults[i];
536
+ const usage = result.status === "fulfilled" ? result.value.usage : null;
537
+ if (usage) {
538
+ const lines = renderUsageLines(usage);
539
+ for (const line of lines) {
540
+ log.message(line);
541
+ }
542
+ } else {
543
+ log.message(c.dim(`${USAGE_INDENT}quotas: unavailable`));
544
+ }
545
+ }
546
+
547
+ if (i < stored.accounts.length - 1) {
548
+ log.message("");
549
+ }
550
+ }
551
+
552
+ log.message("");
553
+
554
+ const enabled = stored.accounts.filter((account) => account.enabled).length;
555
+ const disabled = stored.accounts.length - enabled;
556
+ const parts = [
557
+ `Strategy: ${c.cyan(config.account_selection_strategy)}`,
558
+ `${c.bold(String(enabled))} of ${stored.accounts.length} enabled`,
559
+ ];
560
+ if (disabled > 0) {
561
+ parts.push(`${c.yellow(String(disabled))} disabled`);
562
+ }
563
+ log.info(parts.join(c.dim(" | ")));
564
+ log.info(`Storage: ${shortPath(getStoragePath())}`);
565
+
566
+ return 0;
567
+ }
568
+
569
+ /**
570
+ * Show compact one-liner status.
571
+ */
572
+ export async function cmdStatus() {
573
+ const stored = await loadAccountsWithRepair();
574
+ if (!stored || stored.accounts.length === 0) {
575
+ console.log("anthropic: no accounts configured");
576
+ return 1;
577
+ }
578
+
579
+ const config = loadConfig();
580
+ const total = stored.accounts.length;
581
+ const enabled = stored.accounts.filter((account) => account.enabled).length;
582
+ const now = Date.now();
583
+
584
+ let rateLimited = 0;
585
+ for (const account of stored.accounts) {
586
+ if (!account.enabled) continue;
587
+ const maxReset = Math.max(0, ...Object.values(account.rateLimitResetTimes || {}));
588
+ if (maxReset > now) rateLimited++;
589
+ }
590
+
591
+ let line = `anthropic: ${total} account${total !== 1 ? "s" : ""} (${enabled} active)`;
592
+ line += `, strategy: ${config.account_selection_strategy}`;
593
+ line += `, next: #${stored.activeIndex + 1}`;
594
+ if (rateLimited > 0) {
595
+ line += `, ${rateLimited} rate-limited`;
596
+ }
597
+
598
+ console.log(line);
599
+ return 0;
600
+ }
601
+
602
+ /**
603
+ * Switch active account.
604
+ */
605
+ export async function cmdSwitch(arg?: string) {
606
+ const n = parseInt(arg || "", 10);
607
+ if (isNaN(n) || n < 1) {
608
+ log.error("Error: provide a valid account number (e.g., 'switch 2')");
609
+ return 1;
610
+ }
611
+
612
+ const stored = await loadAccounts();
613
+ if (!stored || stored.accounts.length === 0) {
614
+ log.error("Error: no accounts configured.");
615
+ return 1;
616
+ }
617
+
618
+ const idx = n - 1;
619
+ if (idx >= stored.accounts.length) {
620
+ log.error(`Error: account ${n} does not exist. You have ${stored.accounts.length} account(s).`);
621
+ return 1;
622
+ }
623
+
624
+ if (!stored.accounts[idx].enabled) {
625
+ log.error(`Warning: account ${n} is disabled. Enable it first with 'enable ${n}'.`);
626
+ return 1;
627
+ }
628
+
629
+ stored.activeIndex = idx;
630
+ await saveAccounts(stored);
631
+
632
+ const label = stored.accounts[idx].email || `Account ${n}`;
633
+ log.success(`Switched active account to #${n} (${label}).`);
634
+ return 0;
635
+ }
636
+
637
+ /**
638
+ * Enable a disabled account.
639
+ */
640
+ export async function cmdEnable(arg?: string) {
641
+ const n = parseInt(arg || "", 10);
642
+ if (isNaN(n) || n < 1) {
643
+ log.error("Error: provide a valid account number (e.g., 'enable 3')");
644
+ return 1;
645
+ }
646
+
647
+ const stored = await loadAccounts();
648
+ if (!stored || stored.accounts.length === 0) {
649
+ log.error("Error: no accounts configured.");
650
+ return 1;
651
+ }
652
+
653
+ const idx = n - 1;
654
+ if (idx >= stored.accounts.length) {
655
+ log.error(`Error: account ${n} does not exist.`);
656
+ return 1;
657
+ }
658
+
659
+ if (stored.accounts[idx].enabled) {
660
+ log.info(`Account ${n} is already enabled.`);
661
+ return 0;
662
+ }
663
+
664
+ stored.accounts[idx].enabled = true;
665
+ await saveAccounts(stored);
666
+
667
+ const label = stored.accounts[idx].email || `Account ${n}`;
668
+ log.success(`Enabled account #${n} (${label}).`);
669
+ return 0;
670
+ }
671
+
672
+ /**
673
+ * Disable an account.
674
+ */
675
+ export async function cmdDisable(arg?: string) {
676
+ const n = parseInt(arg || "", 10);
677
+ if (isNaN(n) || n < 1) {
678
+ log.error("Error: provide a valid account number (e.g., 'disable 3')");
679
+ return 1;
680
+ }
681
+
682
+ const stored = await loadAccounts();
683
+ if (!stored || stored.accounts.length === 0) {
684
+ log.error("Error: no accounts configured.");
685
+ return 1;
686
+ }
687
+
688
+ const idx = n - 1;
689
+ if (idx >= stored.accounts.length) {
690
+ log.error(`Error: account ${n} does not exist.`);
691
+ return 1;
692
+ }
693
+
694
+ if (!stored.accounts[idx].enabled) {
695
+ log.info(`Account ${n} is already disabled.`);
696
+ return 0;
697
+ }
698
+
699
+ const enabledCount = stored.accounts.filter((account) => account.enabled).length;
700
+ if (enabledCount <= 1) {
701
+ log.error("Error: cannot disable the last enabled account.");
702
+ return 1;
703
+ }
704
+
705
+ stored.accounts[idx].enabled = false;
706
+
707
+ const label = stored.accounts[idx].email || `Account ${n}`;
708
+ let switchedTo: number | null = null;
709
+
710
+ if (idx === stored.activeIndex) {
711
+ const nextEnabled = stored.accounts.findIndex((account) => account.enabled);
712
+ if (nextEnabled >= 0) {
713
+ stored.activeIndex = nextEnabled;
714
+ switchedTo = nextEnabled;
715
+ }
716
+ }
717
+
718
+ await saveAccounts(stored);
719
+
720
+ log.warn(`Disabled account #${n} (${label}).`);
721
+ if (switchedTo !== null) {
722
+ const nextLabel = stored.accounts[switchedTo].email || `Account ${switchedTo + 1}`;
723
+ log.info(`Active account switched to #${switchedTo + 1} (${nextLabel}).`);
724
+ }
725
+
726
+ return 0;
727
+ }
728
+
729
+ /**
730
+ * Reset rate-limit and failure tracking.
731
+ */
732
+ export async function cmdReset(arg?: string) {
733
+ if (!arg) {
734
+ log.error("Error: provide an account number or 'all' (e.g., 'reset 1' or 'reset all')");
735
+ return 1;
736
+ }
737
+
738
+ const stored = await loadAccounts();
739
+ if (!stored || stored.accounts.length === 0) {
740
+ log.error("Error: no accounts configured.");
741
+ return 1;
742
+ }
743
+
744
+ if (arg.toLowerCase() === "all") {
745
+ let count = 0;
746
+ for (const account of stored.accounts) {
747
+ account.rateLimitResetTimes = {};
748
+ account.consecutiveFailures = 0;
749
+ account.lastFailureTime = null;
750
+ count++;
751
+ }
752
+ await saveAccounts(stored);
753
+ log.success(`Reset tracking for all ${count} account(s).`);
754
+ return 0;
755
+ }
756
+
757
+ const n = parseInt(arg, 10);
758
+ if (isNaN(n) || n < 1) {
759
+ log.error("Error: provide a valid account number or 'all'.");
760
+ return 1;
761
+ }
762
+
763
+ const idx = n - 1;
764
+ if (idx >= stored.accounts.length) {
765
+ log.error(`Error: account ${n} does not exist.`);
766
+ return 1;
767
+ }
768
+
769
+ stored.accounts[idx].rateLimitResetTimes = {};
770
+ stored.accounts[idx].consecutiveFailures = 0;
771
+ stored.accounts[idx].lastFailureTime = null;
772
+ await saveAccounts(stored);
773
+
774
+ const label = stored.accounts[idx].email || `Account ${n}`;
775
+ log.success(`Reset tracking for account #${n} (${label}).`);
776
+ return 0;
777
+ }
778
+
779
+ /**
780
+ * Show per-account usage statistics.
781
+ */
782
+ export async function cmdStats() {
783
+ const stored = await loadAccounts();
784
+ if (!stored || stored.accounts.length === 0) {
785
+ log.warn("No accounts configured.");
786
+ return 1;
787
+ }
788
+
789
+ const widths = { num: 4, name: 22, val: 10 };
790
+ const rule = c.dim(" " + "─".repeat(74));
791
+
792
+ log.message(c.bold("Anthropic Account Usage"));
793
+ log.message(
794
+ " " +
795
+ pad(c.dim("#"), widths.num) +
796
+ pad(c.dim("Account"), widths.name) +
797
+ rpad(c.dim("Requests"), widths.val) +
798
+ rpad(c.dim("Input"), widths.val) +
799
+ rpad(c.dim("Output"), widths.val) +
800
+ rpad(c.dim("Cache R"), widths.val) +
801
+ rpad(c.dim("Cache W"), widths.val),
802
+ );
803
+ log.message(rule);
804
+
805
+ let totalRequests = 0;
806
+ let totalInput = 0;
807
+ let totalOutput = 0;
808
+ let totalCacheRead = 0;
809
+ let totalCacheWrite = 0;
810
+ let oldestReset = Infinity;
811
+
812
+ for (let i = 0; i < stored.accounts.length; i++) {
813
+ const account = stored.accounts[i];
814
+ const stats = account.stats || createDefaultStats();
815
+ const marker = i === stored.activeIndex ? c.green("●") : " ";
816
+ const number = `${marker} ${i + 1}`;
817
+ const name = account.email || `Account ${i + 1}`;
818
+
819
+ log.message(
820
+ " " +
821
+ pad(number, widths.num) +
822
+ pad(name, widths.name) +
823
+ rpad(String(stats.requests), widths.val) +
824
+ rpad(fmtTokens(stats.inputTokens), widths.val) +
825
+ rpad(fmtTokens(stats.outputTokens), widths.val) +
826
+ rpad(fmtTokens(stats.cacheReadTokens), widths.val) +
827
+ rpad(fmtTokens(stats.cacheWriteTokens), widths.val),
828
+ );
829
+
830
+ totalRequests += stats.requests;
831
+ totalInput += stats.inputTokens;
832
+ totalOutput += stats.outputTokens;
833
+ totalCacheRead += stats.cacheReadTokens;
834
+ totalCacheWrite += stats.cacheWriteTokens;
835
+ if (stats.lastReset < oldestReset) oldestReset = stats.lastReset;
836
+ }
837
+
838
+ if (stored.accounts.length > 1) {
839
+ log.message(rule);
840
+ log.message(
841
+ c.bold(
842
+ " " +
843
+ pad("", widths.num) +
844
+ pad("Total", widths.name) +
845
+ rpad(String(totalRequests), widths.val) +
846
+ rpad(fmtTokens(totalInput), widths.val) +
847
+ rpad(fmtTokens(totalOutput), widths.val) +
848
+ rpad(fmtTokens(totalCacheRead), widths.val) +
849
+ rpad(fmtTokens(totalCacheWrite), widths.val),
850
+ ),
851
+ );
852
+ }
853
+
854
+ if (oldestReset < Infinity) {
855
+ log.message(c.dim(`Tracking since: ${new Date(oldestReset).toLocaleString()} (${formatTimeAgo(oldestReset)})`));
856
+ }
857
+
858
+ return 0;
859
+ }
860
+
861
+ /**
862
+ * Remove an account permanently.
863
+ * @returns exit code
864
+ */
865
+ export async function cmdRemove(arg?: string, opts: RemoveOptions = {}) {
866
+ const n = parseInt(arg || "", 10);
867
+ if (isNaN(n) || n < 1) {
868
+ log.error("Error: provide a valid account number (e.g., 'remove 2')");
869
+ return 1;
870
+ }
871
+
872
+ const stored = await loadAccounts();
873
+ if (!stored || stored.accounts.length === 0) {
874
+ log.error("Error: no accounts configured.");
875
+ return 1;
876
+ }
877
+
878
+ const idx = n - 1;
879
+ if (idx >= stored.accounts.length) {
880
+ log.error(`Error: account ${n} does not exist.`);
881
+ return 1;
882
+ }
883
+
884
+ const label = stored.accounts[idx]!.email || `Account ${n}`;
885
+
886
+ if (!opts.force) {
887
+ if (!process.stdin.isTTY) {
888
+ log.error("Error: use --force to remove accounts in non-interactive mode.");
889
+ return 1;
890
+ }
891
+ const shouldRemove = await confirm({
892
+ message: `Remove account #${n} (${label})? This cannot be undone.`,
893
+ });
894
+ if (isCancel(shouldRemove) || !shouldRemove) {
895
+ log.info("Cancelled.");
896
+ return 0;
897
+ }
898
+ }
899
+
900
+ stored.accounts.splice(idx, 1);
901
+
902
+ if (stored.accounts.length === 0) {
903
+ stored.activeIndex = 0;
904
+ } else if (stored.activeIndex >= stored.accounts.length) {
905
+ stored.activeIndex = stored.accounts.length - 1;
906
+ } else if (stored.activeIndex > idx) {
907
+ stored.activeIndex--;
908
+ }
909
+
910
+ await saveAccounts(stored);
911
+ log.success(`Removed account #${n} (${label}).`);
912
+
913
+ if (stored.accounts.length > 0) {
914
+ log.info(`${stored.accounts.length} account(s) remaining.`);
915
+ } else {
916
+ log.info("No accounts remaining. Run 'opencode auth login' to add one.");
917
+ }
918
+
919
+ return 0;
920
+ }
921
+
922
+ /**
923
+ * Show help for auth and account command groups.
924
+ */
925
+ export function cmdAuthGroupHelp(group: "auth" | "account") {
926
+ const bin = "opencode-anthropic-auth";
927
+
928
+ switch (group) {
929
+ case "auth":
930
+ console.log(`
931
+ ${c.bold("Auth Commands")}
932
+
933
+ ${pad(c.cyan("login"), 20)}Add a new account via browser OAuth flow (alias: ln)
934
+ ${pad(c.cyan("logout") + " <N>", 20)}Revoke tokens and remove account N (alias: lo)
935
+ ${pad(c.cyan("logout") + " --all", 20)}Revoke all tokens and clear all accounts
936
+ ${pad(c.cyan("reauth") + " <N>", 20)}Re-authenticate account N with fresh tokens (alias: ra)
937
+ ${pad(c.cyan("refresh") + " <N>", 20)}Attempt token refresh without browser (alias: rf)
938
+
939
+ ${c.dim("Examples:")}
940
+ ${bin} auth login
941
+ ${bin} auth logout 2
942
+ ${bin} auth reauth 1
943
+ `);
944
+ return 0;
945
+ case "account":
946
+ console.log(`
947
+ ${c.bold("Account Commands")}
948
+
949
+ ${pad(c.cyan("list"), 20)}Show all accounts with status (alias: ls)
950
+ ${pad(c.cyan("switch") + " <N>", 20)}Set account N as active (alias: sw)
951
+ ${pad(c.cyan("enable") + " <N>", 20)}Enable a disabled account (alias: en)
952
+ ${pad(c.cyan("disable") + " <N>", 20)}Disable an account (alias: dis)
953
+ ${pad(c.cyan("remove") + " <N>", 20)}Remove an account permanently (alias: rm)
954
+ ${pad(c.cyan("reset"), 20)}Clear rate-limit / failure tracking
955
+
956
+ ${c.dim("Examples:")}
957
+ ${bin} account list
958
+ ${bin} account switch 2
959
+ ${bin} account disable 3
960
+ `);
961
+ return 0;
962
+ }
963
+ }