@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/index.ts CHANGED
@@ -5,891 +5,499 @@
5
5
  import { randomUUID } from "node:crypto";
6
6
  import type { ManagedAccount } from "./accounts.js";
7
7
  import { AccountManager } from "./accounts.js";
8
- import { isAccountSpecificError, parseRateLimitReason, parseRetryAfterHeader } from "./backoff.js";
9
8
  import type { PendingOAuthEntry } from "./commands/oauth-flow.js";
10
9
  import { promptAccountMenu, promptManageAccounts } from "./commands/prompts.js";
11
10
  import type { CommandDeps } from "./commands/router.js";
12
11
  import { ANTHROPIC_COMMAND_HANDLED, handleAnthropicSlashCommand } from "./commands/router.js";
13
12
  import type { AnthropicAuthConfig } from "./config.js";
14
13
  import { loadConfig } from "./config.js";
15
- import { CLAUDE_CODE_IDENTITY_STRING, FALLBACK_CLAUDE_CLI_VERSION, FOREGROUND_EXPIRY_BUFFER_MS } from "./constants.js";
16
- import { getOrCreateSignatureUserId, isTruthyEnv, logTransformedSystemPrompt } from "./env.js";
17
- import { buildRequestHeaders } from "./headers/builder.js";
14
+ import { CLAUDE_CODE_IDENTITY_STRING, FALLBACK_CLAUDE_CLI_VERSION } from "./constants.js";
15
+ import { getOrCreateSignatureUserId, isTruthyEnv } from "./env.js";
18
16
  import { fetchLatestClaudeCodeVersion } from "./headers/user-agent.js";
19
17
  import { hasOneMillionContext, isOpus46Model } from "./models.js";
20
18
  import { authorize, exchange } from "./oauth.js";
21
- import { cloneBodyForRetry, transformRequestBody } from "./request/body.js";
22
- import { extractFileIds, getAccountIdentifier } from "./request/metadata.js";
23
- import { fetchWithRetry } from "./request/retry.js";
24
- import { transformRequestUrl } from "./request/url.js";
25
- import { isEventStreamResponse, stripMcpPrefixFromJsonBody, transformResponse } from "./response/index.js";
26
- import { StreamTruncatedError } from "./response/streaming.js";
27
19
  import { readCCCredentials } from "./cc-credentials.js";
28
20
  import {
29
- findByIdentity,
30
- resolveIdentityFromCCCredential,
31
- resolveIdentityFromOAuthExchange,
21
+ findByIdentity,
22
+ resolveIdentityFromCCCredential,
23
+ resolveIdentityFromOAuthExchange,
32
24
  } from "./account-identity.js";
33
25
  import { clearAccounts, loadAccounts } from "./storage.js";
34
26
  import type { OpenCodeClient } from "./token-refresh.js";
35
- import { formatSwitchReason, markTokenStateUpdated, readDiskAccountAuth } from "./token-refresh.js";
36
- import type { UsageStats } from "./types.js";
37
27
  import { createBunFetch } from "./bun-fetch.js";
38
28
  import { createPluginHelpers } from "./plugin-helpers.js";
39
29
  import { createRefreshHelpers } from "./refresh-helpers.js";
40
-
41
- async function finalizeResponse(
42
- response: Response,
43
- onUsage?: ((stats: UsageStats) => void) | null,
44
- onAccountError?: ((details: { reason: string; invalidateToken: boolean }) => void) | null,
45
- onStreamError?: ((error: Error) => void) | null,
46
- ): Promise<Response> {
47
- if (!isEventStreamResponse(response)) {
48
- const body = stripMcpPrefixFromJsonBody(await response.text());
49
- return new Response(body, {
50
- status: response.status,
51
- statusText: response.statusText,
52
- headers: new Headers(response.headers),
53
- });
54
- }
55
-
56
- return transformResponse(response, onUsage, onAccountError, onStreamError);
57
- }
30
+ import { createRequestOrchestrationHelpers } from "./request-orchestration-helpers.js";
58
31
 
59
32
  // ---------------------------------------------------------------------------
60
33
  // Plugin factory
61
34
  // ---------------------------------------------------------------------------
62
35
 
63
36
  export async function AnthropicAuthPlugin({
64
- client,
37
+ client,
65
38
  }: {
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin client API boundary; accepts arbitrary extension methods
67
- client: OpenCodeClient & Record<string, any>;
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin client API boundary; accepts arbitrary extension methods
40
+ client: OpenCodeClient & Record<string, any>;
68
41
  }) {
69
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin config accepts forward-compatible arbitrary keys
70
- const config: AnthropicAuthConfig & Record<string, any> = loadConfig();
71
- const signatureEmulationEnabled = config.signature_emulation.enabled;
72
- const promptCompactionMode =
73
- config.signature_emulation.prompt_compaction === "off" ? ("off" as const) : ("minimal" as const);
74
- const signatureSanitizeSystemPrompt = config.signature_emulation.sanitize_system_prompt === true;
75
- const shouldFetchClaudeCodeVersion =
76
- signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
77
-
78
- let accountManager: AccountManager | null = null;
79
-
80
- // Track account usage toasts; show once per account change (including first use).
81
- let lastToastedIndex = -1;
82
-
83
- let initialAccountPinned = false;
84
- const pendingSlashOAuth = new Map<string, PendingOAuthEntry>();
85
- const fileAccountMap = new Map<string, number>();
86
-
87
- // -- Helpers ---------------------------------------------------------------
88
-
89
- function debugLog(...args: unknown[]) {
90
- if (!config.debug) return;
91
- // eslint-disable-next-line no-console -- this IS the plugin's dedicated debug logger; gated on config.debug
92
- console.error("[opencode-anthropic-auth]", ...args);
93
- }
94
-
95
- const { toast, sendCommandMessage, runCliCommand, reloadAccountManagerFromDisk, persistOpenCodeAuth } =
96
- createPluginHelpers({
97
- client,
98
- config,
99
- debugLog,
100
- getAccountManager: () => accountManager,
101
- setAccountManager: (nextAccountManager) => {
102
- accountManager = nextAccountManager;
103
- },
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- plugin config accepts forward-compatible arbitrary keys
43
+ const config: AnthropicAuthConfig & Record<string, any> = loadConfig();
44
+ const signatureEmulationEnabled = config.signature_emulation.enabled;
45
+ const promptCompactionMode =
46
+ config.signature_emulation.prompt_compaction === "off" ? ("off" as const) : ("minimal" as const);
47
+ const signatureSanitizeSystemPrompt = config.signature_emulation.sanitize_system_prompt === true;
48
+ const shouldFetchClaudeCodeVersion =
49
+ signatureEmulationEnabled && config.signature_emulation.fetch_claude_code_version_on_startup;
50
+
51
+ let accountManager: AccountManager | null = null;
52
+
53
+ // Track account usage toasts; show once per account change (including first use).
54
+ let lastToastedIndex = -1;
55
+
56
+ let initialAccountPinned = false;
57
+ const pendingSlashOAuth = new Map<string, PendingOAuthEntry>();
58
+ const fileAccountMap = new Map<string, number>();
59
+
60
+ // -- Helpers ---------------------------------------------------------------
61
+
62
+ function debugLog(...args: unknown[]) {
63
+ if (!config.debug) return;
64
+ // eslint-disable-next-line no-console -- this IS the plugin's dedicated debug logger; gated on config.debug
65
+ console.error("[opencode-anthropic-auth]", ...args);
66
+ }
67
+
68
+ const { toast, sendCommandMessage, runCliCommand, reloadAccountManagerFromDisk, persistOpenCodeAuth } =
69
+ createPluginHelpers({
70
+ client,
71
+ config,
72
+ debugLog,
73
+ getAccountManager: () => accountManager,
74
+ setAccountManager: (nextAccountManager) => {
75
+ accountManager = nextAccountManager;
76
+ },
77
+ });
78
+
79
+ const { parseRefreshFailure, refreshAccountTokenSingleFlight, maybeRefreshIdleAccounts } = createRefreshHelpers({
80
+ client,
81
+ config,
82
+ getAccountManager: () => accountManager,
83
+ debugLog,
84
+ });
85
+ const bunFetchInstance = createBunFetch({
86
+ debug: config.debug,
87
+ onProxyStatus: (status) => {
88
+ debugLog("bun fetch status", status);
89
+ },
104
90
  });
105
91
 
106
- const { parseRefreshFailure, refreshAccountTokenSingleFlight, maybeRefreshIdleAccounts } = createRefreshHelpers({
107
- client,
108
- config,
109
- getAccountManager: () => accountManager,
110
- debugLog,
111
- });
112
- const bunFetchInstance = createBunFetch({
113
- debug: config.debug,
114
- onProxyStatus: (status) => {
115
- debugLog("bun fetch status", status);
116
- },
117
- });
118
-
119
- const fetchWithTransport = async (input: string | URL | Request, init: RequestInit): Promise<Response> => {
120
- const activeFetch = globalThis.fetch as typeof globalThis.fetch & { mock?: unknown };
121
- if (typeof activeFetch === "function" && activeFetch.mock) {
122
- return activeFetch(input, init);
92
+ const fetchWithTransport = async (input: string | URL | Request, init: RequestInit): Promise<Response> => {
93
+ const activeFetch = globalThis.fetch as typeof globalThis.fetch & { mock?: unknown };
94
+ if (typeof activeFetch === "function" && activeFetch.mock) {
95
+ return activeFetch(input, init);
96
+ }
97
+
98
+ return bunFetchInstance.fetch(input, init);
99
+ };
100
+
101
+ // -- Version resolution ----------------------------------------------------
102
+
103
+ let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
104
+ const signatureSessionId = randomUUID();
105
+ const signatureUserId = getOrCreateSignatureUserId();
106
+ if (shouldFetchClaudeCodeVersion) {
107
+ fetchLatestClaudeCodeVersion()
108
+ .then((version) => {
109
+ if (!version) return;
110
+ claudeCliVersion = version;
111
+ debugLog("resolved claude-code version from npm", version);
112
+ })
113
+ .catch((err) => debugLog("CC version fetch failed:", (err as Error).message));
123
114
  }
124
115
 
125
- return bunFetchInstance.fetch(input, init);
126
- };
127
-
128
- // -- Version resolution ----------------------------------------------------
129
-
130
- let claudeCliVersion = FALLBACK_CLAUDE_CLI_VERSION;
131
- const signatureSessionId = randomUUID();
132
- const signatureUserId = getOrCreateSignatureUserId();
133
- if (shouldFetchClaudeCodeVersion) {
134
- fetchLatestClaudeCodeVersion()
135
- .then((version) => {
136
- if (!version) return;
137
- claudeCliVersion = version;
138
- debugLog("resolved claude-code version from npm", version);
139
- })
140
- .catch((err) => debugLog("CC version fetch failed:", (err as Error).message));
141
- }
142
-
143
- // -- Command deps ----------------------------------------------------------
144
-
145
- const commandDeps: CommandDeps = {
146
- sendCommandMessage,
147
- get accountManager() {
148
- return accountManager;
149
- },
150
- runCliCommand,
151
- config,
152
- fileAccountMap,
153
- get initialAccountPinned() {
154
- return initialAccountPinned;
155
- },
156
- pendingSlashOAuth,
157
- reloadAccountManagerFromDisk,
158
- persistOpenCodeAuth,
159
- refreshAccountTokenSingleFlight,
160
- };
161
-
162
- // -- Plugin return ---------------------------------------------------------
163
-
164
- return {
165
- dispose: async () => {
166
- await bunFetchInstance.shutdown();
167
- },
168
-
169
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
170
- "experimental.chat.system.transform": (input: Record<string, any>, output: Record<string, any>) => {
171
- const prefix = CLAUDE_CODE_IDENTITY_STRING;
172
- if (!signatureEmulationEnabled && input.model?.providerID === "anthropic") {
173
- output.system.unshift(prefix);
174
- if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1];
175
- }
176
- },
177
-
178
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
179
- config: async (input: Record<string, any>) => {
180
- input.command ??= {};
181
- input.command["anthropic"] = {
182
- template: "/anthropic",
183
- description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)",
184
- };
185
- },
186
-
187
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
188
- "command.execute.before": async (input: Record<string, any>) => {
189
- if (input.command !== "anthropic") return;
190
- try {
191
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- command router accepts the Record-shaped input from OpenCode
192
- await handleAnthropicSlashCommand(input as any, commandDeps);
193
- } catch (err) {
194
- const message = err instanceof Error ? err.message : String(err);
195
- await sendCommandMessage(input.sessionID, `▣ Anthropic (error)\n\n${message}`);
196
- }
197
- throw new Error(ANTHROPIC_COMMAND_HANDLED);
198
- },
199
-
200
- auth: {
201
- provider: "anthropic",
202
- async loader(
203
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode auth loader API boundary
204
- getAuth: () => Promise<Record<string, any>>,
205
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode auth loader API boundary
206
- provider: Record<string, any>,
207
- ) {
208
- const auth = await getAuth();
209
- if (auth.type === "oauth") {
210
- // Zero out cost for max plan and optionally override context limits.
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- model objects carry provider-specific metadata
212
- for (const model of Object.values(provider.models) as Record<string, any>[]) {
213
- model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } };
214
- if (
215
- config.override_model_limits.enabled &&
216
- !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
217
- (hasOneMillionContext(model.id) || isOpus46Model(model.id))
218
- ) {
219
- model.limit = {
220
- ...(model.limit ?? {}),
221
- context: config.override_model_limits.context,
222
- ...(config.override_model_limits.output > 0 ? { output: config.override_model_limits.output } : {}),
223
- };
224
- }
225
- }
226
-
227
- // Initialize AccountManager from disk + OpenCode auth fallback
228
- accountManager = await AccountManager.load(config, {
229
- refresh: auth.refresh,
230
- access: auth.access,
231
- expires: auth.expires,
232
- });
233
- if (accountManager.getAccountCount() > 0) {
234
- await accountManager.saveToDisk();
235
- }
236
-
237
- if (config.cc_credential_reuse?.enabled && config.cc_credential_reuse?.auto_detect) {
238
- const ccCount = accountManager.getCCAccounts().length;
239
- if (ccCount > 0) {
240
- await toast(`Using Claude Code credentials (${ccCount} found)`, "success");
241
- }
242
- }
243
-
244
- // OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pin to a specific account
245
- const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
246
- if (initialAccountEnv && accountManager.getAccountCount() > 1) {
247
- const accounts = accountManager.getEnabledAccounts();
248
- let target: ManagedAccount | null = null;
249
- const asIndex = parseInt(initialAccountEnv, 10);
250
- if (!isNaN(asIndex) && asIndex >= 1 && asIndex <= accounts.length) {
251
- target = accounts[asIndex - 1];
252
- }
253
- if (!target) {
254
- target =
255
- accounts.find((a) => a.email && a.email.toLowerCase() === initialAccountEnv.toLowerCase()) ?? null;
256
- }
257
- if (target && accountManager.forceCurrentIndex(target.index)) {
258
- config.account_selection_strategy = "sticky";
259
- initialAccountPinned = true;
260
- debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pinned to account", {
261
- index: target.index + 1,
262
- email: target.email,
263
- strategy: "sticky (overridden)",
264
- });
265
- } else {
266
- debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: could not resolve account", initialAccountEnv);
267
- }
268
- }
269
-
270
- return {
271
- apiKey: "",
272
- async fetch(
273
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fetch input varies (string | URL | Request) across call sites
274
- input: any,
275
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fetch init is OpenCode-shaped RequestInit-plus
276
- init: any,
277
- ) {
278
- const currentAuth = await getAuth();
279
- if (currentAuth.type !== "oauth") return fetch(input, init);
280
-
281
- const requestInit = { ...(init ?? {}) };
282
- const { requestInput, requestUrl } = transformRequestUrl(input);
283
- const resolvedBody =
284
- requestInit.body !== undefined
285
- ? requestInit.body
286
- : requestInput instanceof Request && requestInput.body
287
- ? await requestInput.clone().text()
288
- : undefined;
289
- if (resolvedBody !== undefined) {
290
- requestInit.body = resolvedBody;
291
- }
292
-
293
- const requestContext: {
294
- attempt: number;
295
- cloneBody: string | undefined;
296
- preparedBody: string | undefined;
297
- } = {
298
- attempt: 0,
299
- cloneBody: typeof resolvedBody === "string" ? cloneBodyForRetry(resolvedBody) : undefined,
300
- preparedBody: undefined,
301
- };
302
-
303
- const requestMethod = String(
304
- requestInit.method || (requestInput instanceof Request ? requestInput.method : "POST"),
305
- ).toUpperCase();
306
- let showUsageToast: boolean;
307
- try {
308
- showUsageToast = new URL(requestUrl!).pathname === "/v1/messages" && requestMethod === "POST";
309
- } catch {
310
- showUsageToast = false;
311
- }
312
-
313
- let lastError: unknown = null;
314
- const transientRefreshSkips = new Set<number>();
315
-
316
- if (accountManager && !initialAccountPinned) {
317
- await accountManager.syncActiveIndexFromDisk();
318
- }
319
-
320
- const maxAttempts = accountManager!.getTotalAccountCount();
321
-
322
- // File-ID account pinning
323
- let pinnedAccount: ManagedAccount | null = null;
324
- if (typeof requestInit.body === "string" && fileAccountMap.size > 0) {
325
- try {
326
- const bodyObj = JSON.parse(requestInit.body);
327
- const fileIds = extractFileIds(bodyObj);
328
- for (const fid of fileIds) {
329
- const pinnedIndex = fileAccountMap.get(fid);
330
- if (pinnedIndex !== undefined) {
331
- const candidates = accountManager!.getEnabledAccounts();
332
- pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
333
- if (pinnedAccount) {
334
- debugLog("file-id pinning: routing to account", {
335
- fileId: fid,
336
- accountIndex: pinnedIndex,
337
- email: pinnedAccount.email,
338
- });
339
- break;
340
- }
341
- }
342
- }
343
- } catch {
344
- /* Non-JSON body */
345
- }
346
- }
347
-
348
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
349
- requestContext.attempt = attempt + 1;
350
-
351
- const account =
352
- attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
353
- ? pinnedAccount
354
- : accountManager!.getCurrentAccount(transientRefreshSkips);
355
-
356
- // Toast account usage
357
- if (showUsageToast && account && accountManager) {
358
- const currentIndex = accountManager.getCurrentIndex();
359
- if (currentIndex !== lastToastedIndex) {
360
- const name = account.email || `Account ${currentIndex + 1}`;
361
- const total = accountManager.getAccountCount();
362
- const msg = total > 1 ? `Claude: ${name} (${currentIndex + 1}/${total})` : `Claude: ${name}`;
363
- await toast(msg, "info", { debounceKey: "account-usage" });
364
- lastToastedIndex = currentIndex;
365
- }
366
- }
116
+ const { executeOAuthFetch } = createRequestOrchestrationHelpers({
117
+ config,
118
+ debugLog,
119
+ toast,
120
+ getAccountManager: () => accountManager,
121
+ getClaudeCliVersion: () => claudeCliVersion,
122
+ getInitialAccountPinned: () => initialAccountPinned,
123
+ getLastToastedIndex: () => lastToastedIndex,
124
+ setLastToastedIndex: (index) => {
125
+ lastToastedIndex = index;
126
+ },
127
+ fileAccountMap,
128
+ fetchWithTransport,
129
+ parseRefreshFailure,
130
+ refreshAccountTokenSingleFlight,
131
+ maybeRefreshIdleAccounts,
132
+ signatureEmulationEnabled,
133
+ promptCompactionMode,
134
+ signatureSanitizeSystemPrompt,
135
+ signatureSessionId,
136
+ signatureUserId,
137
+ });
367
138
 
368
- if (!account) {
369
- const enabledCount = accountManager!.getAccountCount();
370
- if (enabledCount === 0) {
371
- throw new Error(
372
- "No enabled Anthropic accounts available. Enable one with 'opencode-anthropic-auth enable <N>'.",
373
- );
374
- }
375
- throw new Error("No available Anthropic account for request.");
376
- }
139
+ // -- Command deps ----------------------------------------------------------
377
140
 
378
- // Determine access token
379
- let accessToken: string | undefined;
380
- if (!account.access || !account.expires || account.expires < Date.now() + FOREGROUND_EXPIRY_BUFFER_MS) {
381
- const attemptedRefreshToken = account.refreshToken;
382
- try {
383
- accessToken = await refreshAccountTokenSingleFlight(account);
384
- } catch (err) {
385
- let finalError = err;
386
- let details = parseRefreshFailure(err);
387
-
388
- if (details.isInvalidGrant || details.isTerminalStatus) {
389
- const diskAuth = await readDiskAccountAuth(account.id);
390
- const retryToken = diskAuth?.refreshToken;
391
- if (
392
- retryToken &&
393
- retryToken !== attemptedRefreshToken &&
394
- account.refreshToken === attemptedRefreshToken
395
- ) {
396
- debugLog("refresh token on disk differs from in-memory, retrying with disk token", {
397
- accountIndex: account.index,
398
- });
399
- account.refreshToken = retryToken;
400
- if (diskAuth?.tokenUpdatedAt) account.tokenUpdatedAt = diskAuth.tokenUpdatedAt;
401
- else markTokenStateUpdated(account);
402
- } else if (retryToken && retryToken !== attemptedRefreshToken) {
403
- debugLog("skipping disk token adoption because in-memory token already changed", {
404
- accountIndex: account.index,
405
- });
406
- }
407
- try {
408
- accessToken = await refreshAccountTokenSingleFlight(account);
409
- } catch (retryErr) {
410
- finalError = retryErr;
411
- details = parseRefreshFailure(retryErr);
412
- debugLog("retry refresh failed", {
413
- accountIndex: account.index,
414
- status: details.status,
415
- errorCode: details.errorCode,
416
- message: details.message,
417
- });
418
- }
419
- }
141
+ const commandDeps: CommandDeps = {
142
+ sendCommandMessage,
143
+ get accountManager() {
144
+ return accountManager;
145
+ },
146
+ runCliCommand,
147
+ config,
148
+ fileAccountMap,
149
+ get initialAccountPinned() {
150
+ return initialAccountPinned;
151
+ },
152
+ pendingSlashOAuth,
153
+ reloadAccountManagerFromDisk,
154
+ persistOpenCodeAuth,
155
+ refreshAccountTokenSingleFlight,
156
+ };
420
157
 
421
- if (!accessToken) {
422
- accountManager!.markFailure(account);
423
- if (details.isInvalidGrant || details.isTerminalStatus) {
424
- const name = account.email || `Account ${accountManager!.getCurrentIndex() + 1}`;
425
- debugLog("disabling account after terminal refresh failure", {
426
- accountIndex: account.index,
427
- status: details.status,
428
- errorCode: details.errorCode,
429
- message: details.message,
430
- });
431
- account.enabled = false;
432
- accountManager!.requestSaveToDisk();
433
- const statusLabel = Number.isFinite(details.status)
434
- ? `HTTP ${details.status}`
435
- : "unknown status";
436
- await toast(
437
- `Disabled ${name} (token refresh failed: ${details.errorCode || statusLabel})`,
438
- "error",
439
- );
440
- } else {
441
- transientRefreshSkips.add(account.index);
442
- }
443
- lastError = finalError;
444
- continue;
445
- }
446
- }
447
- } else {
448
- accessToken = account.access;
449
- }
158
+ // -- Plugin return ---------------------------------------------------------
450
159
 
451
- maybeRefreshIdleAccounts(account);
160
+ return {
161
+ dispose: async () => {
162
+ await bunFetchInstance.shutdown();
163
+ },
452
164
 
453
- const buildAttemptBody = () => {
454
- const transformedBody = transformRequestBody(
455
- requestContext.cloneBody === undefined ? undefined : cloneBodyForRetry(requestContext.cloneBody),
456
- {
457
- enabled: signatureEmulationEnabled,
458
- claudeCliVersion,
459
- promptCompactionMode,
460
- sanitizeSystemPrompt: signatureSanitizeSystemPrompt,
461
- },
462
- {
463
- persistentUserId: signatureUserId,
464
- sessionId: signatureSessionId,
465
- accountId: getAccountIdentifier(account),
466
- },
467
- config.relocate_third_party_prompts,
468
- debugLog,
469
- );
165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
166
+ "experimental.chat.system.transform": (input: Record<string, any>, output: Record<string, any>) => {
167
+ const prefix = CLAUDE_CODE_IDENTITY_STRING;
168
+ if (!signatureEmulationEnabled && input.model?.providerID === "anthropic") {
169
+ output.system.unshift(prefix);
170
+ if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1];
171
+ }
172
+ },
470
173
 
471
- requestContext.preparedBody =
472
- typeof transformedBody === "string" ? cloneBodyForRetry(transformedBody) : undefined;
174
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
175
+ config: async (input: Record<string, any>) => {
176
+ input.command ??= {};
177
+ input.command["anthropic"] = {
178
+ template: "/anthropic",
179
+ description: "Manage Anthropic auth, config, and betas (usage, login, config, set, betas, switch)",
180
+ };
181
+ },
473
182
 
474
- return transformedBody;
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode plugin hook API boundary
184
+ "command.execute.before": async (input: Record<string, any>) => {
185
+ if (input.command !== "anthropic") return;
186
+ try {
187
+ const slashInput = {
188
+ command: String(input.command),
189
+ arguments: typeof input.arguments === "string" ? input.arguments : undefined,
190
+ sessionID: String(input.sessionID),
475
191
  };
192
+ await handleAnthropicSlashCommand(slashInput, commandDeps);
193
+ } catch (err) {
194
+ const message = err instanceof Error ? err.message : String(err);
195
+ await sendCommandMessage(input.sessionID, `▣ Anthropic (error)\n\n${message}`);
196
+ }
197
+ throw new Error(ANTHROPIC_COMMAND_HANDLED);
198
+ },
476
199
 
477
- const body = buildAttemptBody();
478
- logTransformedSystemPrompt(body);
479
-
480
- const requestHeaders = buildRequestHeaders(input, requestInit, accessToken!, body, requestUrl, {
481
- enabled: signatureEmulationEnabled,
482
- claudeCliVersion,
483
- promptCompactionMode,
484
- sanitizeSystemPrompt: signatureSanitizeSystemPrompt,
485
- customBetas: config.custom_betas,
486
- strategy: config.account_selection_strategy,
487
- });
488
-
489
- // --- Debug: log the exact fingerprint being sent ---
490
- if (config.debug) {
491
- const billingBlock = body
492
- ? (() => {
493
- try {
494
- const parsed = JSON.parse(body) as Record<string, unknown>;
495
- const sys = parsed.system;
496
- if (Array.isArray(sys)) {
497
- return (sys as Array<{ text?: string }>).find(
498
- (b) => typeof b.text === "string" && b.text.startsWith("x-anthropic-billing-header:"),
499
- )?.text;
500
- }
501
- } catch {
502
- // JSON parse failed — body is not valid JSON
200
+ auth: {
201
+ provider: "anthropic",
202
+ async loader(
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode auth loader API boundary
204
+ getAuth: () => Promise<Record<string, any>>,
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OpenCode auth loader API boundary
206
+ provider: Record<string, any>,
207
+ ) {
208
+ const auth = await getAuth();
209
+ if (auth.type === "oauth") {
210
+ // Zero out cost for max plan and optionally override context limits.
211
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- model objects carry provider-specific metadata
212
+ for (const model of Object.values(provider.models) as Record<string, any>[]) {
213
+ model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } };
214
+ if (
215
+ config.override_model_limits.enabled &&
216
+ !isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) &&
217
+ (hasOneMillionContext(model.id) || isOpus46Model(model.id))
218
+ ) {
219
+ model.limit = {
220
+ ...(model.limit ?? {}),
221
+ context: config.override_model_limits.context,
222
+ ...(config.override_model_limits.output > 0
223
+ ? { output: config.override_model_limits.output }
224
+ : {}),
225
+ };
503
226
  }
504
- return undefined;
505
- })()
506
- : undefined;
507
-
508
- debugLog("fingerprint snapshot", {
509
- billingHeader: billingBlock ?? "(not in system prompt)",
510
- userAgent: requestHeaders.get("user-agent"),
511
- anthropicBeta: requestHeaders.get("anthropic-beta"),
512
- stainlessPackageVersion: requestHeaders.get("x-stainless-package-version"),
513
- xApp: requestHeaders.get("x-app"),
514
- claudeCliVersion,
515
- signatureEnabled: signatureEmulationEnabled,
516
- });
517
- }
518
-
519
- let response: Response;
520
- const fetchInput = requestInput as string | URL | Request;
521
- const buildTransportRequestInit = (
522
- headers: Headers,
523
- requestBody: RequestInit["body"],
524
- forceFreshConnection: boolean,
525
- ): RequestInit => {
526
- const requestHeadersForTransport = new Headers(headers);
527
- if (forceFreshConnection) {
528
- requestHeadersForTransport.set("connection", "close");
529
- requestHeadersForTransport.set("x-proxy-disable-keepalive", "true");
530
- } else {
531
- requestHeadersForTransport.delete("connection");
532
- requestHeadersForTransport.delete("x-proxy-disable-keepalive");
533
- }
534
-
535
- return {
536
- ...requestInit,
537
- body: requestBody,
538
- headers: requestHeadersForTransport,
539
- ...(forceFreshConnection ? { keepalive: false } : {}),
540
- };
541
- };
542
-
543
- try {
544
- response = await fetchWithRetry(
545
- async ({ forceFreshConnection }) =>
546
- fetchWithTransport(
547
- fetchInput,
548
- buildTransportRequestInit(requestHeaders, body, forceFreshConnection),
549
- ),
550
- {
551
- maxRetries: 2,
552
- shouldRetryResponse: () => false,
553
- },
554
- );
555
- } catch (err) {
556
- const fetchError = err instanceof Error ? err : new Error(String(err));
557
- if (accountManager && account) {
558
- accountManager.markFailure(account);
559
- transientRefreshSkips.add(account.index);
560
- lastError = fetchError;
561
- debugLog("request fetch threw, trying next account", {
562
- accountIndex: account.index,
563
- message: fetchError.message,
564
- });
565
- continue;
566
- }
567
- throw fetchError;
568
- }
569
-
570
- // On error, check if account-specific or service-wide
571
- if (!response.ok && accountManager && account) {
572
- let errorBody: string | null = null;
573
- try {
574
- errorBody = await response.clone().text();
575
- } catch {
576
- /* ignore */
577
- }
578
-
579
- if (isAccountSpecificError(response.status, errorBody)) {
580
- const reason = parseRateLimitReason(response.status, errorBody);
581
- const retryAfterMs = parseRetryAfterHeader(response);
582
- const authOrPermissionIssue = reason === "AUTH_FAILED";
583
- if (reason === "AUTH_FAILED") {
584
- account.access = undefined;
585
- account.expires = undefined;
586
- markTokenStateUpdated(account);
587
- }
588
- debugLog("account-specific error, switching account", {
589
- accountIndex: account.index,
590
- status: response.status,
591
- reason,
592
- });
593
- accountManager.markRateLimited(account, reason, authOrPermissionIssue ? null : retryAfterMs);
594
- transientRefreshSkips.add(account.index);
595
- const name = account.email || `Account ${accountManager.getCurrentIndex() + 1}`;
596
- const total = accountManager.getAccountCount();
597
- if (total > 1) {
598
- const switchReason = formatSwitchReason(response.status, reason);
599
- await toast(`${name} ${switchReason}, switching account`, "warning", {
600
- debounceKey: "account-switch",
601
- });
602
227
  }
603
- continue;
604
- }
605
228
 
606
- if (response.status === 500 || response.status === 503 || response.status === 529) {
607
- debugLog("service-wide response error, attempting retry", {
608
- status: response.status,
229
+ // Initialize AccountManager from disk + OpenCode auth fallback
230
+ accountManager = await AccountManager.load(config, {
231
+ refresh: auth.refresh,
232
+ access: auth.access,
233
+ expires: auth.expires,
609
234
  });
235
+ if (accountManager.getAccountCount() > 0) {
236
+ await accountManager.saveToDisk();
237
+ }
610
238
 
611
- let retryCount = 0;
612
- const retried = await fetchWithRetry(
613
- async ({ forceFreshConnection }) => {
614
- if (retryCount === 0) {
615
- retryCount += 1;
616
- return response;
239
+ if (config.cc_credential_reuse?.enabled && config.cc_credential_reuse?.auto_detect) {
240
+ const ccCount = accountManager.getCCAccounts().length;
241
+ if (ccCount > 0) {
242
+ await toast(`Using Claude Code credentials (${ccCount} found)`, "success");
617
243
  }
244
+ }
618
245
 
619
- const headersForRetry = new Headers(requestHeaders);
620
- headersForRetry.set("x-stainless-retry-count", String(retryCount));
621
- retryCount += 1;
622
- const retryUrl = fetchInput instanceof Request ? fetchInput.url : fetchInput.toString();
623
- const retryBody =
624
- requestContext.preparedBody === undefined
625
- ? undefined
626
- : cloneBodyForRetry(requestContext.preparedBody);
627
- return fetchWithTransport(
628
- retryUrl,
629
- buildTransportRequestInit(headersForRetry, retryBody, forceFreshConnection),
630
- );
631
- },
632
- { maxRetries: 2 },
633
- );
634
-
635
- if (!retried.ok) {
636
- return finalizeResponse(retried);
246
+ // OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pin to a specific account
247
+ const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
248
+ if (initialAccountEnv && accountManager.getAccountCount() > 1) {
249
+ const accounts = accountManager.getEnabledAccounts();
250
+ let target: ManagedAccount | null = null;
251
+ const asIndex = parseInt(initialAccountEnv, 10);
252
+ if (!isNaN(asIndex) && asIndex >= 1 && asIndex <= accounts.length) {
253
+ target = accounts[asIndex - 1];
254
+ }
255
+ if (!target) {
256
+ target =
257
+ accounts.find(
258
+ (a) => a.email && a.email.toLowerCase() === initialAccountEnv.toLowerCase(),
259
+ ) ?? null;
260
+ }
261
+ if (target && accountManager.forceCurrentIndex(target.index)) {
262
+ config.account_selection_strategy = "sticky";
263
+ initialAccountPinned = true;
264
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pinned to account", {
265
+ index: target.index + 1,
266
+ email: target.email,
267
+ strategy: "sticky (overridden)",
268
+ });
269
+ } else {
270
+ debugLog(
271
+ "OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: could not resolve account",
272
+ initialAccountEnv,
273
+ );
274
+ }
637
275
  }
638
276
 
639
- response = retried;
640
- } else {
641
- debugLog("non-account-specific response error, returning directly", {
642
- status: response.status,
643
- });
644
- return finalizeResponse(response);
645
- }
277
+ return {
278
+ apiKey: "",
279
+ async fetch(
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fetch input varies (string | URL | Request) across call sites
281
+ input: any,
282
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fetch init is OpenCode-shaped RequestInit-plus
283
+ init: any,
284
+ ) {
285
+ const currentAuth = await getAuth();
286
+ if (currentAuth.type !== "oauth") return fetch(input, init);
287
+ return executeOAuthFetch(input, init);
288
+ },
289
+ };
646
290
  }
647
291
 
648
- // Success
649
- if (account && accountManager && response.ok) {
650
- accountManager.markSuccess(account);
651
- }
292
+ return {};
293
+ },
652
294
 
653
- const shouldInspectStream = response.ok && account && accountManager && isEventStreamResponse(response);
654
- const usageCallback = shouldInspectStream
655
- ? (usage: UsageStats) => {
656
- accountManager!.recordUsage(account.index, usage);
657
- }
658
- : null;
659
- const accountErrorCallback = shouldInspectStream
660
- ? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- reason carries opaque rate-limit metadata; shape stabilized at callsite
661
- (details: { reason: any; invalidateToken: boolean }) => {
662
- if (details.invalidateToken) {
663
- account.access = undefined;
664
- account.expires = undefined;
665
- markTokenStateUpdated(account);
666
- }
667
- accountManager!.markRateLimited(account, details.reason, null);
668
- }
669
- : null;
670
- const streamErrorCallback =
671
- response.ok && isEventStreamResponse(response)
672
- ? (error: Error) => {
673
- if (!(error instanceof StreamTruncatedError)) {
674
- return;
295
+ methods: [
296
+ {
297
+ label: "Claude Code Credentials (auto-detected)",
298
+ type: "oauth" as const,
299
+ authorize: async () => {
300
+ const ccCredentials = readCCCredentials();
301
+
302
+ if (ccCredentials.length === 0) {
303
+ return {
304
+ url: "about:blank",
305
+ instructions:
306
+ "No Claude Code credentials found. Please install Claude Code and run 'claude login' first, then return here to use those credentials.",
307
+ method: "code" as const,
308
+ callback: async () => ({
309
+ type: "failed" as const,
310
+ reason: "Claude Code not installed or not authenticated",
311
+ }),
312
+ };
675
313
  }
676
314
 
677
- debugLog("stream truncated during response consumption", {
678
- accountIndex: account?.index,
679
- message: error.message,
680
- context: error.context,
681
- });
682
- }
683
- : null;
684
-
685
- return finalizeResponse(response, usageCallback, accountErrorCallback, streamErrorCallback);
686
- }
687
-
688
- if (lastError) throw lastError;
689
- throw new Error("All accounts exhausted — no account could serve this request");
690
- },
691
- };
692
- }
693
-
694
- return {};
695
- },
696
-
697
- methods: [
698
- {
699
- label: "Claude Code Credentials (auto-detected)",
700
- type: "oauth" as const,
701
- authorize: async () => {
702
- const ccCredentials = readCCCredentials();
703
-
704
- if (ccCredentials.length === 0) {
705
- return {
706
- url: "about:blank",
707
- instructions:
708
- "No Claude Code credentials found. Please install Claude Code and run 'claude login' first, then return here to use those credentials.",
709
- method: "code" as const,
710
- callback: async () => ({
711
- type: "failed" as const,
712
- reason: "Claude Code not installed or not authenticated",
713
- }),
714
- };
715
- }
315
+ const ccCred = ccCredentials[0];
716
316
 
717
- const ccCred = ccCredentials[0];
317
+ if (!accountManager) {
318
+ accountManager = await AccountManager.load(config, null);
319
+ }
718
320
 
719
- if (!accountManager) {
720
- accountManager = await AccountManager.load(config, null);
721
- }
321
+ const identity = resolveIdentityFromCCCredential(ccCred);
322
+ const existing = findByIdentity(accountManager.getCCAccounts(), identity);
323
+
324
+ if (existing) {
325
+ existing.refreshToken = ccCred.refreshToken;
326
+ existing.identity = identity;
327
+ existing.source = ccCred.source;
328
+ existing.label = ccCred.label;
329
+ existing.enabled = true;
330
+ if (ccCred.accessToken) {
331
+ existing.access = ccCred.accessToken;
332
+ }
333
+ if (ccCred.expiresAt >= (existing.expires ?? 0)) {
334
+ existing.expires = ccCred.expiresAt;
335
+ }
336
+ existing.tokenUpdatedAt = Math.max(existing.tokenUpdatedAt || 0, ccCred.expiresAt || 0);
337
+ await accountManager.saveToDisk();
338
+ } else {
339
+ const added = accountManager.addAccount(
340
+ ccCred.refreshToken,
341
+ ccCred.accessToken,
342
+ ccCred.expiresAt,
343
+ undefined,
344
+ {
345
+ identity,
346
+ label: ccCred.label,
347
+ source: ccCred.source,
348
+ },
349
+ );
350
+ if (added) {
351
+ added.source = ccCred.source;
352
+ added.label = ccCred.label;
353
+ added.identity = identity;
354
+ }
355
+ await accountManager.saveToDisk();
356
+ await toast("Added Claude Code credentials", "success");
357
+ }
722
358
 
723
- const identity = resolveIdentityFromCCCredential(ccCred);
724
- const existing = findByIdentity(accountManager.getCCAccounts(), identity);
725
-
726
- if (existing) {
727
- existing.refreshToken = ccCred.refreshToken;
728
- existing.identity = identity;
729
- existing.source = ccCred.source;
730
- existing.label = ccCred.label;
731
- existing.enabled = true;
732
- if (ccCred.accessToken) {
733
- existing.access = ccCred.accessToken;
734
- }
735
- if (ccCred.expiresAt >= (existing.expires ?? 0)) {
736
- existing.expires = ccCred.expiresAt;
737
- }
738
- existing.tokenUpdatedAt = Math.max(existing.tokenUpdatedAt || 0, ccCred.expiresAt || 0);
739
- await accountManager.saveToDisk();
740
- } else {
741
- const added = accountManager.addAccount(
742
- ccCred.refreshToken,
743
- ccCred.accessToken,
744
- ccCred.expiresAt,
745
- undefined,
746
- {
747
- identity,
748
- label: ccCred.label,
749
- source: ccCred.source,
359
+ return {
360
+ type: "success" as const,
361
+ refresh: ccCred.refreshToken,
362
+ access: ccCred.accessToken,
363
+ expires: ccCred.expiresAt,
364
+ };
365
+ },
750
366
  },
751
- );
752
- if (added) {
753
- added.source = ccCred.source;
754
- added.label = ccCred.label;
755
- added.identity = identity;
756
- }
757
- await accountManager.saveToDisk();
758
- await toast("Added Claude Code credentials", "success");
759
- }
760
-
761
- return {
762
- type: "success" as const,
763
- refresh: ccCred.refreshToken,
764
- access: ccCred.accessToken,
765
- expires: ccCred.expiresAt,
766
- };
767
- },
768
- },
769
- {
770
- label: "Claude Pro/Max (multi-account)",
771
- type: "oauth" as const,
772
- authorize: async () => {
773
- const stored = await loadAccounts();
774
- if (stored && stored.accounts.length > 0 && accountManager) {
775
- const action = await promptAccountMenu(accountManager);
776
- if (action === "cancel") {
777
- return {
778
- url: "about:blank",
779
- instructions: "Cancelled.",
780
- method: "code" as const,
781
- callback: async () => ({ type: "failed" as const }),
782
- };
783
- }
784
- if (action === "manage") {
785
- await promptManageAccounts(accountManager);
786
- await accountManager.saveToDisk();
787
- return {
788
- url: "about:blank",
789
- instructions: "Account management complete. Re-run auth to add accounts.",
790
- method: "code" as const,
791
- callback: async () => ({ type: "failed" as const }),
792
- };
793
- }
794
- if (action === "fresh") {
795
- await clearAccounts();
796
- accountManager.clearAll();
797
- }
798
- }
367
+ {
368
+ label: "Claude Pro/Max (multi-account)",
369
+ type: "oauth" as const,
370
+ authorize: async () => {
371
+ const stored = await loadAccounts();
372
+ if (stored && stored.accounts.length > 0 && accountManager) {
373
+ const action = await promptAccountMenu(accountManager);
374
+ if (action === "cancel") {
375
+ return {
376
+ url: "about:blank",
377
+ instructions: "Cancelled.",
378
+ method: "code" as const,
379
+ callback: async () => ({ type: "failed" as const }),
380
+ };
381
+ }
382
+ if (action === "manage") {
383
+ await promptManageAccounts(accountManager);
384
+ await accountManager.saveToDisk();
385
+ return {
386
+ url: "about:blank",
387
+ instructions: "Account management complete. Re-run auth to add accounts.",
388
+ method: "code" as const,
389
+ callback: async () => ({ type: "failed" as const }),
390
+ };
391
+ }
392
+ if (action === "fresh") {
393
+ await clearAccounts();
394
+ accountManager.clearAll();
395
+ }
396
+ }
799
397
 
800
- const { url, verifier, state } = await authorize("max");
801
- return {
802
- url,
803
- instructions: "Paste the authorization code here: ",
804
- method: "code" as const,
805
- callback: async (code: string) => {
806
- const parts = code.split("#");
807
- if (state && parts[1] && parts[1] !== state) {
808
- return {
809
- type: "failed" as const,
810
- reason: "OAuth state mismatch",
811
- };
812
- }
813
- const credentials = await exchange(code, verifier);
814
- if (credentials.type === "failed") return credentials;
815
- if (!accountManager) {
816
- accountManager = await AccountManager.load(config, null);
817
- }
818
- const identity = resolveIdentityFromOAuthExchange(credentials);
819
- const countBefore = accountManager.getAccountCount();
820
- const existing =
821
- identity.kind === "oauth" ? findByIdentity(accountManager.getOAuthAccounts(), identity) : null;
822
-
823
- if (existing) {
824
- existing.refreshToken = credentials.refresh;
825
- existing.access = credentials.access;
826
- existing.expires = credentials.expires;
827
- existing.email = credentials.email ?? existing.email;
828
- existing.identity = identity;
829
- existing.source = "oauth";
830
- existing.enabled = true;
831
- existing.tokenUpdatedAt = Date.now();
832
- } else {
833
- accountManager.addAccount(
834
- credentials.refresh,
835
- credentials.access,
836
- credentials.expires,
837
- credentials.email,
838
- {
839
- identity,
840
- source: "oauth",
398
+ const { url, verifier, state } = await authorize("max");
399
+ return {
400
+ url,
401
+ instructions: "Paste the authorization code here: ",
402
+ method: "code" as const,
403
+ callback: async (code: string) => {
404
+ const parts = code.split("#");
405
+ if (state && parts[1] && parts[1] !== state) {
406
+ return {
407
+ type: "failed" as const,
408
+ reason: "OAuth state mismatch",
409
+ };
410
+ }
411
+ const credentials = await exchange(code, verifier);
412
+ if (credentials.type === "failed") return credentials;
413
+ if (!accountManager) {
414
+ accountManager = await AccountManager.load(config, null);
415
+ }
416
+ const identity = resolveIdentityFromOAuthExchange(credentials);
417
+ const countBefore = accountManager.getAccountCount();
418
+ const candidate =
419
+ identity.kind === "oauth"
420
+ ? findByIdentity(accountManager.getOAuthAccounts(), identity)
421
+ : null;
422
+ // Refuse to reshape a row that was born as a CC import. The id
423
+ // prefix is the only durable proof-of-origin; reshaping it to
424
+ // oauth would re-introduce the dedup bug.
425
+ const existing =
426
+ candidate && /^cc-(cc-keychain|cc-file)-\d+:/.test(candidate.id) ? null : candidate;
427
+
428
+ if (existing) {
429
+ existing.refreshToken = credentials.refresh;
430
+ existing.access = credentials.access;
431
+ existing.expires = credentials.expires;
432
+ existing.email = credentials.email ?? existing.email;
433
+ existing.identity = identity;
434
+ existing.source = "oauth";
435
+ existing.enabled = true;
436
+ existing.tokenUpdatedAt = Date.now();
437
+ } else {
438
+ accountManager.addAccount(
439
+ credentials.refresh,
440
+ credentials.access,
441
+ credentials.expires,
442
+ credentials.email,
443
+ {
444
+ identity,
445
+ source: "oauth",
446
+ },
447
+ );
448
+ }
449
+
450
+ await accountManager.saveToDisk();
451
+ const total = accountManager.getAccountCount();
452
+ const name = credentials.email || "account";
453
+ if (existing) await toast(`Re-authenticated (${name})`, "success");
454
+ else if (countBefore > 0) await toast(`Added ${name} — ${total} accounts`, "success");
455
+ else await toast(`Authenticated (${name})`, "success");
456
+ return credentials;
457
+ },
458
+ };
841
459
  },
842
- );
843
- }
844
-
845
- await accountManager.saveToDisk();
846
- const total = accountManager.getAccountCount();
847
- const name = credentials.email || "account";
848
- if (existing) await toast(`Re-authenticated (${name})`, "success");
849
- else if (countBefore > 0) await toast(`Added ${name} — ${total} accounts`, "success");
850
- else await toast(`Authenticated (${name})`, "success");
851
- return credentials;
852
- },
853
- };
854
- },
855
- },
856
- {
857
- label: "Create an API Key",
858
- type: "oauth" as const,
859
- authorize: async () => {
860
- const { url, verifier, state } = await authorize("console");
861
- return {
862
- url,
863
- instructions: "Paste the authorization code here: ",
864
- method: "code" as const,
865
- callback: async (code: string) => {
866
- const parts = code.split("#");
867
- if (state && parts[1] && parts[1] !== state) {
868
- return {
869
- type: "failed" as const,
870
- reason: "OAuth state mismatch",
871
- };
872
- }
873
- const credentials = await exchange(code, verifier);
874
- if (credentials.type === "failed") return credentials;
875
- const result = (await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
876
- method: "POST",
877
- headers: {
878
- "Content-Type": "application/json",
879
- authorization: `Bearer ${credentials.access}`,
880
- },
881
- }).then((r) => r.json())) as { raw_key: string };
882
- return { type: "success" as const, key: result.raw_key };
883
- },
884
- };
885
- },
886
- },
887
- {
888
- provider: "anthropic",
889
- label: "Manually enter API Key",
890
- type: "api" as const,
460
+ },
461
+ {
462
+ label: "Create an API Key",
463
+ type: "oauth" as const,
464
+ authorize: async () => {
465
+ const { url, verifier, state } = await authorize("console");
466
+ return {
467
+ url,
468
+ instructions: "Paste the authorization code here: ",
469
+ method: "code" as const,
470
+ callback: async (code: string) => {
471
+ const parts = code.split("#");
472
+ if (state && parts[1] && parts[1] !== state) {
473
+ return {
474
+ type: "failed" as const,
475
+ reason: "OAuth state mismatch",
476
+ };
477
+ }
478
+ const credentials = await exchange(code, verifier);
479
+ if (credentials.type === "failed") return credentials;
480
+ const result = (await fetch(
481
+ "https://api.anthropic.com/api/oauth/claude_cli/create_api_key",
482
+ {
483
+ method: "POST",
484
+ headers: {
485
+ "Content-Type": "application/json",
486
+ authorization: `Bearer ${credentials.access}`,
487
+ },
488
+ },
489
+ ).then((r) => r.json())) as { raw_key: string };
490
+ return { type: "success" as const, key: result.raw_key };
491
+ },
492
+ };
493
+ },
494
+ },
495
+ {
496
+ provider: "anthropic",
497
+ label: "Manually enter API Key",
498
+ type: "api" as const,
499
+ },
500
+ ],
891
501
  },
892
- ],
893
- },
894
- };
502
+ };
895
503
  }