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