opencode-anthropic 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aiev
4
+
5
+ Portions of this software are derived from "opencode-anthropic-oauth"
6
+ (https://www.npmjs.com/package/opencode-anthropic-oauth), Copyright (c) 2026
7
+ shahidshabbir-se, released under the MIT License.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,270 @@
1
- # Temporary Holding Version
1
+ # opencode-anthropic
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ OpenCode V2 plugin that brings **Claude Pro/Max OAuth** back to the Anthropic
4
+ provider.
5
+
6
+ OpenCode V2 removed Anthropic OAuth after a legal request from Anthropic, but
7
+ the plumbing is still there: OAuth credentials are stored and sent as
8
+ `Authorization: Bearer`. This plugin completes the picture:
9
+
10
+ - keeps Claude tokens fresh (refresh 10 minutes before expiry, single-flight,
11
+ with backoff and a cross-process fallback);
12
+ - mirrors the captured Claude Code 2.1.289 request profile: billing-header
13
+ system block, identity block, `metadata.user_id` (persistent random device
14
+ id), thinking enabled with `display: omitted`, `context_management`, SDK
15
+ telemetry headers, `x-claude-code-session-id`, and the matching beta flags;
16
+ - exposes OpenCode tools under Claude Code names (`shell` → `Bash`, `subagent`
17
+ → `Agent`, ...) and translates Claude Code argument shapes (`file_path`,
18
+ `old_string`, `run_in_background`) back onto OpenCode schemas before they are
19
+ parsed;
20
+ - honours Anthropic's `retry-after` hint when OpenCode retries a rate limit.
21
+
22
+ It is a V2 port of the community
23
+ [`opencode-anthropic-oauth`](https://www.npmjs.com/package/opencode-anthropic-oauth)
24
+ (MIT) plugin, which only runs on OpenCode V1.
25
+
26
+ > **Warning:** Anthropic's Terms of Service say OAuth tokens from Free/Pro/Max
27
+ > plans must only be used by official clients. Since April 2026, third-party
28
+ > apps authenticated this way are metered against a separate prepaid **extra
29
+ > usage** balance (claude.ai/settings/usage) instead of the plan limits, and
30
+ > requests are blocked while that balance is empty. This plugin is a community
31
+ > workaround: it can stop working without notice and you use it at your own
32
+ > risk.
33
+
34
+ ## Requirements
35
+
36
+ - OpenCode V2 (tested with 2.0.22);
37
+ - Node.js 20+ (for the login script and tests);
38
+ - A Claude Pro/Max subscription with an extra-usage balance, or an Anthropic
39
+ API key (API keys pass through the plugin untouched).
40
+
41
+ ## Install
42
+
43
+ From npm, add the package to the OpenCode config:
44
+
45
+ ```jsonc
46
+ {
47
+ "plugins": [
48
+ "opencode-anthropic"
49
+ ]
50
+ }
51
+ ```
52
+
53
+ Or use the CLI: `opencode plugin add opencode-anthropic`.
54
+
55
+ For a local checkout, point the config at the directory instead (global
56
+ config example, `~/.config/opencode/opencode.json`):
57
+
58
+ ```jsonc
59
+ {
60
+ "plugins": [
61
+ "../path/to/opencode-anthropic"
62
+ ]
63
+ }
64
+ ```
65
+
66
+ Or copy the directory into `~/.config/opencode/plugins/opencode-anthropic/`,
67
+ where local plugins are auto-discovered. Restart the OpenCode service after
68
+ changing plugins: `opencode service restart`.
69
+
70
+ ## Login
71
+
72
+ Use the regular OpenCode connect flow:
73
+
74
+ ```
75
+ /connect → Anthropic → Claude Pro/Max
76
+ ```
77
+
78
+ The plugin registers that method on the Anthropic integration: it opens the
79
+ claude.ai authorization page, asks for the code Claude shows, exchanges it,
80
+ and lets OpenCode store the credential. The bundled script does the same from
81
+ a terminal (useful for headless machines and CI):
82
+
83
+ ```bash
84
+ node login.mjs # from a checkout
85
+ # or, when installed from npm:
86
+ opencode-anthropic login
87
+ ```
88
+
89
+ Add `--no-browser` to only print the authorization URL. The script opens the
90
+ browser flow, asks for the code Anthropic shows, exchanges it for tokens,
91
+ registers the credential in the running OpenCode server over its local HTTP
92
+ API (tokens never travel through process arguments), and saves a local cache
93
+ in `~/.local/share/opencode/opencode-anthropic.json` (mode `0600`).
94
+
95
+ Then pick an `anthropic/claude-*` model in OpenCode. The plugin refreshes the
96
+ token automatically.
97
+
98
+ ## How it works
99
+
100
+ - **Strict gating.** The plugin only acts while an Anthropic **OAuth**
101
+ credential is the active connection. API keys, env credentials, and the
102
+ logged-out state pass through untouched — no surprises with `ps`, no stale
103
+ tokens resurrected after logout.
104
+ - **Connection-bound cache.** Refreshed tokens are stored in plugin storage
105
+ and in the token file, tagged with the OpenCode connection id they belong
106
+ to. Cached tokens from another connection (account switch, re-login) are
107
+ ignored.
108
+ - **Refresh.** Tokens within 10 minutes of expiry are refreshed before the
109
+ request. Concurrent requests share one refresh; a failed refresh backs off
110
+ for 5 minutes. If another process refreshed first, a still-valid cached
111
+ token is used instead of failing the request.
112
+ - **Request shaping.** `http.request` hook (provider `anthropic`): replaces
113
+ the authorization header with a fresh bearer token, removes `x-api-key`,
114
+ sets `user-agent: claude-cli/<version> (external, <entrypoint>)`,
115
+ `x-app: cli`, `x-claude-code-session-id` (a stable UUID per OpenCode
116
+ session), `anthropic-dangerous-direct-browser-access: true`, the
117
+ `x-stainless-*` telemetry headers and `accept: application/json`, and merges
118
+ the `anthropic-beta` flags.
119
+ - **Claude Code request profile.** The body is shaped like the captured
120
+ Claude Code 2.1.289 request: the billing-header line becomes `system[0]`,
121
+ the identity becomes `system[1]`, `metadata.user_id` carries a persistent
122
+ random device id plus the session id, and thinking-capable primary requests
123
+ get `thinking: { type: "enabled", budget_tokens: max_tokens - 1,
124
+ display: "omitted" }` with `context_management.edits` to match. Each piece is
125
+ individually switchable (see Configuration).
126
+ - **Response shaping.** `http.response` hook: parses SSE events, renames
127
+ `tool_use.name` back to the OpenCode name, and translates the arguments of
128
+ mapped tools. Argument fragments are held until the content block closes;
129
+ when nothing changes the original frames are passed through byte for byte,
130
+ and unparseable fragments fall back untouched.
131
+ - **Native `/connect`.** The plugin registers "Claude Pro/Max" as an OAuth
132
+ method on the Anthropic integration, so the normal connect UI (and the
133
+ integration OAuth API) works without the CLI script.
134
+ - **Rate limits.** A 429/503/529 response records Anthropic's `retry-after`
135
+ value; the session `retry` hook hands it back as the retry delay instead of
136
+ letting OpenCode hammer the endpoint.
137
+
138
+ ### System prompt and tools
139
+
140
+ The default `prepend` mode builds the system array like Claude Code does: the
141
+ billing-header line (`x-anthropic-billing-header: cc_version=2.1.289.45c;
142
+ cc_entrypoint=cli;`) as the first block, then the identity ("You are a Claude
143
+ agent, built on Anthropic's Claude Agent SDK."), then the OpenCode prompt.
144
+ Set `ANTHROPIC_OAUTH_SYSTEM_IDENTITY` (or the `identity` option) to pin the
145
+ 2025 wording or your own string.
146
+
147
+ `tools.mjs` holds an explicit compatibility table: only OpenCode tools with a
148
+ Claude Code counterpart are renamed, and each mapped tool also translates the
149
+ argument shapes a Claude Code model may produce:
150
+
151
+ | OpenCode | Exposed as | Arguments translated |
152
+ | --- | --- | --- |
153
+ | `read` | `Read` | `file_path` → `path`; drops `pages` |
154
+ | `write` | `Write` | `file_path` → `path` |
155
+ | `edit` | `Edit` | `file_path`, `old_string`, `new_string`, `replace_all` |
156
+ | `shell` | `Bash` | `run_in_background` → `background`; drops `description` |
157
+ | `grep` | `Grep` | `glob` → `include`, `head_limit` → `limit`, `-i` |
158
+ | `subagent` | `Agent` | `subagent_type` → `agent`, `run_in_background` → `background` |
159
+ | `question` | `AskUserQuestion` | `multiSelect` → `multiple` |
160
+ | `todowrite` | `TodoWrite` | drops `activeForm` |
161
+ | `webfetch` | `WebFetch` | drops `prompt` |
162
+ | `websearch` | `WebSearch` | drops domain filters |
163
+ | `skill` | `Skill` | `skill` → `id`; drops `args` |
164
+ | `glob` | `Glob` | — |
165
+
166
+ OpenCode-only tools (`compress`, `todoread`, `execute`/Code Mode) and unknown
167
+ tools (MCP) are left untouched. `toolAliases` can override any mapping, e.g.
168
+ `{ "subagent": "Task" }` for the pre-2.1 Claude Code name.
169
+
170
+ ## Configuration
171
+
172
+ Options can be passed through the OpenCode config:
173
+
174
+ ```jsonc
175
+ {
176
+ "plugins": [
177
+ {
178
+ "package": "../path/to/opencode-anthropic",
179
+ "options": {
180
+ "systemMode": "prepend",
181
+ "renameTools": true,
182
+ "toolAliases": { "my_tool": "Read" }
183
+ }
184
+ }
185
+ ]
186
+ }
187
+ ```
188
+
189
+ | Option | Default | Description |
190
+ | --- | --- | --- |
191
+ | `systemMode` | `prepend` | `prepend`, `replace` (rewrites the first block), or `off` |
192
+ | `identity` | current Claude Code line | Overrides the injected identity string |
193
+ | `billingHeader` | `true` | Prepend the Claude Code billing-header system block |
194
+ | `metadata` | `true` | Send `metadata.user_id` with a persistent random device id |
195
+ | `thinking` | `true` | Add Claude Code thinking and `context_management` to primary Claude requests |
196
+ | `sdkHeaders` | `true` | Send the `x-stainless-*` telemetry headers |
197
+ | `renameTools` | `true` | Rename tools to Claude Code casing and back |
198
+ | `toolAliases` | `{}` | Extra or overriding tool-name mappings |
199
+
200
+ Environment variables (useful when the plugin is loaded as a directory):
201
+ `ANTHROPIC_OAUTH_SYSTEM_MODE`, `ANTHROPIC_OAUTH_SYSTEM_IDENTITY`,
202
+ `ANTHROPIC_OAUTH_BILLING_HEADER=0`, `ANTHROPIC_OAUTH_METADATA=0`,
203
+ `ANTHROPIC_OAUTH_THINKING=0`, `ANTHROPIC_OAUTH_SDK_HEADERS=0`,
204
+ `ANTHROPIC_OAUTH_RENAME_TOOLS=0`, `ANTHROPIC_OAUTH_TOOL_ALIASES` (JSON),
205
+ `ANTHROPIC_OAUTH_DUMP` (append every transformed OAuth request to a JSONL
206
+ file), `ANTHROPIC_CLI_VERSION`, `ANTHROPIC_CLI_BUILD`,
207
+ `ANTHROPIC_CLI_ENTRYPOINT`, `ANTHROPIC_BILLING_HEADER` (full line override),
208
+ `ANTHROPIC_SDK_VERSION`, `ANTHROPIC_NODE_VERSION`, `ANTHROPIC_BETA_FLAGS`,
209
+ `XDG_DATA_HOME`.
210
+
211
+ ## Troubleshooting
212
+
213
+ - **401/403 on every request** — the refresh token was revoked or the login
214
+ expired. Run the login again.
215
+ - **Provider does not show up** — check `opencode auth list`; the
216
+ "Claude Pro/Max" credential must exist.
217
+ - **Tools do not execute** — try `ANTHROPIC_OAUTH_RENAME_TOOLS=0` and restart
218
+ the service.
219
+ - **Logout** — remove the credential with `/connect`; cached tokens are
220
+ ignored automatically because no OAuth connection is active anymore.
221
+
222
+ ## Development
223
+
224
+ No dependencies; tests use the Node.js built-in runner and a mock token
225
+ server:
226
+
227
+ ```bash
228
+ node --test
229
+ ```
230
+
231
+ The repo includes a headless capture tool that runs the local `claude` binary
232
+ against a mock endpoint with an isolated config dir (no account, no real API):
233
+
234
+ ```bash
235
+ npm run capture # capture + summarise
236
+ node scripts/claude-capture.mjs --compare a.json b.json
237
+ ```
238
+
239
+ `--compare` accepts a Claude capture and an OpenCode side (for example the
240
+ JSONL written by `ANTHROPIC_OAUTH_DUMP`).
241
+
242
+ ## Known limitations
243
+
244
+ - Subscription OAuth usage is billed by Anthropic from the prepaid "extra
245
+ usage" balance (per-token) rather than the plan limits. The plugin cannot
246
+ change which pool a request is metered from; API keys are unaffected.
247
+ - The native `/connect` OAuth method is registered by this plugin; if the
248
+ registration fails (older OpenCode builds), `/connect` will not show
249
+ "Claude Pro/Max" and the bundled `login.mjs` script remains the fallback.
250
+ - The plugin targets a Claude Code compatibility profile
251
+ (`claude-cli/2.1.289`). Newer releases can be selected with
252
+ `ANTHROPIC_CLI_VERSION`, but the beta flags and tool list may need updating.
253
+ - Thinking is enabled by default for `claude-sonnet-*`/`claude-opus-*`
254
+ primary requests, mirroring Claude Code (`budget_tokens: max_tokens - 1`,
255
+ `display: "omitted"`). Disable with `ANTHROPIC_OAUTH_THINKING=0` or the
256
+ `thinking` option to keep OpenCode's own behaviour.
257
+ - The billing header mirrors the captured 2.1.289 line, which has no `cch`
258
+ hash; newer Claude Code builds may include one this plugin cannot compute.
259
+ - The full Claude Code system prompt and tool descriptions are not copied
260
+ (they are Anthropic's content and change every release): OpenCode's prompt
261
+ and schemas are sent with the identity/billing prefix instead. The device id
262
+ is a random per-install id, not a Claude Code install id.
263
+ - Refreshed tokens are kept in plugin storage and in the token file; OpenCode's
264
+ stored credential keeps the original access token until the next login.
265
+
266
+ ## Credits and license
267
+
268
+ MIT. Ported from
269
+ [`opencode-anthropic-oauth`](https://github.com/shahidshabbir-se/opencode-anthropic-oauth)
270
+ (MIT) by shahidshabbir-se. See [LICENSE](LICENSE).
package/index.js ADDED
@@ -0,0 +1,380 @@
1
+ // OpenCode V2 plugin: Claude Pro/Max OAuth support for the Anthropic provider.
2
+ //
3
+ // OpenCode V2 removed Anthropic OAuth, but it still stores OAuth credentials
4
+ // and sends them as `Authorization: Bearer`. This plugin completes the
5
+ // picture: it keeps tokens fresh, injects the Claude Code identity and beta
6
+ // headers, and renames tools so Anthropic routes requests like official
7
+ // Claude Code.
8
+ //
9
+ // Token safety rules:
10
+ // - The plugin only acts while an Anthropic OAuth credential is the active
11
+ // connection. API keys and logged-out states pass through untouched.
12
+ // - Cached tokens are bound to the connection id they were issued for, so a
13
+ // logout or an account switch can never revive an old session from disk.
14
+ import { randomBytes } from "node:crypto";
15
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
+ import {
18
+ BETA_FLAGS,
19
+ BILLING_HEADER,
20
+ DEVICE_FILE,
21
+ SDK_HEADERS,
22
+ TOKENS_FILE,
23
+ USER_AGENT,
24
+ createAuthorizationRequest,
25
+ exchangeCodeForTokens,
26
+ refreshTokens,
27
+ } from "./oauth.mjs";
28
+ import {
29
+ buildToolNameMaps,
30
+ parseAliases,
31
+ sessionUUID,
32
+ transformRequestBody,
33
+ transformResponseStream,
34
+ translateToolInput,
35
+ } from "./transform.mjs";
36
+ import { createRateLimitState } from "./retry.mjs";
37
+
38
+ const REFRESH_BUFFER_MS = 10 * 60 * 1000;
39
+ const REFRESH_RETRY_MS = 5 * 60 * 1000;
40
+ const TOKEN_CACHE_MS = 30 * 1000;
41
+ export const LOGIN_HINT =
42
+ "opencode-anthropic: Claude Pro/Max login is missing or expired. Run `opencode-anthropic login` (or `node <plugin-dir>/login.mjs`) to sign in again.";
43
+
44
+ function isUsableTokens(value) {
45
+ return Boolean(
46
+ value && typeof value.access === "string" && value.access && typeof value.refresh === "string" && value.refresh,
47
+ );
48
+ }
49
+
50
+ function readFileTokens() {
51
+ try {
52
+ const data = JSON.parse(readFileSync(TOKENS_FILE, "utf8"));
53
+ if (isUsableTokens(data)) return data;
54
+ } catch {}
55
+ return null;
56
+ }
57
+
58
+ function writeFileTokens(tokens) {
59
+ try {
60
+ mkdirSync(dirname(TOKENS_FILE), { recursive: true });
61
+ const temp = `${TOKENS_FILE}.tmp`;
62
+ writeFileSync(temp, JSON.stringify(tokens, null, 2), { mode: 0o600 });
63
+ renameSync(temp, TOKENS_FILE);
64
+ chmodSync(TOKENS_FILE, 0o600);
65
+ } catch (error) {
66
+ console.error("opencode-anthropic: failed to persist tokens to disk:", error);
67
+ }
68
+ }
69
+
70
+ export default {
71
+ id: "opencode-anthropic",
72
+ async setup(ctx) {
73
+ const options = {
74
+ systemMode: ctx.options?.systemMode ?? process.env.ANTHROPIC_OAUTH_SYSTEM_MODE ?? "prepend",
75
+ renameTools: ctx.options?.renameTools ?? process.env.ANTHROPIC_OAUTH_RENAME_TOOLS !== "0",
76
+ identity: ctx.options?.identity ?? process.env.ANTHROPIC_OAUTH_SYSTEM_IDENTITY,
77
+ billingHeader: ctx.options?.billingHeader ?? process.env.ANTHROPIC_OAUTH_BILLING_HEADER !== "0",
78
+ metadata: ctx.options?.metadata ?? process.env.ANTHROPIC_OAUTH_METADATA !== "0",
79
+ thinking: ctx.options?.thinking ?? process.env.ANTHROPIC_OAUTH_THINKING !== "0",
80
+ sdkHeaders: ctx.options?.sdkHeaders ?? process.env.ANTHROPIC_OAUTH_SDK_HEADERS !== "0",
81
+ toolAliases: {
82
+ ...parseAliases(process.env.ANTHROPIC_OAUTH_TOOL_ALIASES),
83
+ ...(ctx.options?.toolAliases ?? {}),
84
+ },
85
+ };
86
+ const { forward, reverse } = buildToolNameMaps(options.toolAliases);
87
+ const requestOptions = {
88
+ systemMode: options.systemMode,
89
+ renameTools: options.renameTools,
90
+ identity: options.identity,
91
+ billing: options.billingHeader ? BILLING_HEADER : null,
92
+ forward,
93
+ };
94
+
95
+ let cached = null;
96
+ let cachedAt = 0;
97
+ let refreshPromise = null;
98
+ let refreshFailedAt = 0;
99
+ const rateLimit = createRateLimitState();
100
+ const dumpFile = process.env.ANTHROPIC_OAUTH_DUMP;
101
+
102
+ let device = null;
103
+ async function deviceID() {
104
+ if (device) return device;
105
+ try {
106
+ const stored = await ctx.storage.get("device");
107
+ if (stored && /^[0-9a-f]{64}$/.test(String(stored.id))) return (device = stored.id);
108
+ } catch (error) {
109
+ console.error("opencode-anthropic: failed to read the device id from plugin storage:", error);
110
+ }
111
+ try {
112
+ const file = JSON.parse(readFileSync(DEVICE_FILE, "utf8"));
113
+ if (file && /^[0-9a-f]{64}$/.test(String(file.id))) return (device = file.id);
114
+ } catch {}
115
+ device = randomBytes(32).toString("hex");
116
+ try {
117
+ await ctx.storage.set("device", { id: device });
118
+ } catch (error) {
119
+ console.error("opencode-anthropic: failed to persist the device id in plugin storage:", error);
120
+ }
121
+ try {
122
+ mkdirSync(dirname(DEVICE_FILE), { recursive: true });
123
+ writeFileSync(DEVICE_FILE, JSON.stringify({ id: device }, null, 2), { mode: 0o600 });
124
+ } catch (error) {
125
+ console.error("opencode-anthropic: failed to persist the device id on disk:", error);
126
+ }
127
+ return device;
128
+ }
129
+
130
+ async function activeOAuthConnection() {
131
+ try {
132
+ const connection = await ctx.integration.connection.active("anthropic");
133
+ if (!connection || connection.type !== "credential") return null;
134
+ const value = await ctx.integration.connection.resolve(connection);
135
+ if (value?.type !== "oauth" || !isUsableTokens(value)) return null;
136
+ return { id: connection.id, tokens: value };
137
+ } catch (error) {
138
+ console.error("opencode-anthropic: failed to resolve the Anthropic connection:", error);
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function boundTo(value, connectionID) {
144
+ return isUsableTokens(value) && value.connectionID === connectionID ? value : null;
145
+ }
146
+
147
+ async function storedTokens() {
148
+ try {
149
+ return await ctx.storage.get("tokens");
150
+ } catch (error) {
151
+ console.error("opencode-anthropic: failed to read plugin storage:", error);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ async function loadTokens(force = false) {
157
+ if (!force && cached && Date.now() - cachedAt < TOKEN_CACHE_MS) return cached;
158
+ cached = null;
159
+ cachedAt = Date.now();
160
+ const connection = await activeOAuthConnection();
161
+ if (!connection) return null;
162
+ const candidates = [
163
+ { ...connection.tokens, connectionID: connection.id },
164
+ boundTo(await storedTokens(), connection.id),
165
+ boundTo(readFileTokens(), connection.id),
166
+ ].filter(Boolean);
167
+ // The newest expiry wins: refreshed copies outrank the stale access
168
+ // token that OpenCode keeps in the credential record.
169
+ candidates.sort((a, b) => (b.expires ?? 0) - (a.expires ?? 0));
170
+ cached = candidates[0] ?? null;
171
+ return cached;
172
+ }
173
+
174
+ async function persist(tokens) {
175
+ try {
176
+ await ctx.storage.set("tokens", tokens);
177
+ } catch (error) {
178
+ console.error("opencode-anthropic: failed to persist tokens in plugin storage:", error);
179
+ }
180
+ writeFileTokens(tokens);
181
+ }
182
+
183
+ async function freshAccess() {
184
+ const tokens = await loadTokens();
185
+ if (!tokens) return null;
186
+ if ((tokens.expires ?? 0) > Date.now() + REFRESH_BUFFER_MS) return tokens.access;
187
+ if (refreshFailedAt && Date.now() - refreshFailedAt < REFRESH_RETRY_MS) return tokens.access;
188
+ if (!refreshPromise) {
189
+ refreshPromise = (async () => {
190
+ try {
191
+ const latest = (await loadTokens(true)) ?? tokens;
192
+ if ((latest.expires ?? 0) > Date.now() + REFRESH_BUFFER_MS) return latest.access;
193
+ const refreshed = await refreshTokens(latest.refresh);
194
+ const next = { ...latest, ...refreshed, connectionID: latest.connectionID };
195
+ cached = next;
196
+ cachedAt = Date.now();
197
+ refreshFailedAt = 0;
198
+ await persist(next);
199
+ return next.access;
200
+ } catch (error) {
201
+ refreshFailedAt = Date.now();
202
+ // Another process may have refreshed first: re-read and use a
203
+ // still-valid token instead of failing the request.
204
+ const fallback = (await loadTokens(true)) ?? tokens;
205
+ if ((fallback.expires ?? 0) > Date.now()) return fallback.access;
206
+ throw error;
207
+ }
208
+ })().finally(() => {
209
+ refreshPromise = null;
210
+ });
211
+ }
212
+ return refreshPromise;
213
+ }
214
+
215
+ await ctx.session.hook(
216
+ "http.request",
217
+ async (event) => {
218
+ try {
219
+ const tokens = await loadTokens();
220
+ if (!tokens) return; // API key or logged out: leave the request alone.
221
+ const access = (await freshAccess()) ?? tokens.access;
222
+ const headers = event.request.headers;
223
+ headers.set("authorization", `Bearer ${access}`);
224
+ headers.delete("x-api-key");
225
+ headers.set("x-app", "cli");
226
+ headers.set("anthropic-dangerous-direct-browser-access", "true");
227
+ headers.set("user-agent", USER_AGENT);
228
+ const incoming = (headers.get("anthropic-beta") || "")
229
+ .split(",")
230
+ .map((flag) => flag.trim())
231
+ .filter(Boolean);
232
+ const required = BETA_FLAGS.split(",").map((flag) => flag.trim());
233
+ headers.set("anthropic-beta", [...new Set([...required, ...incoming])].join(","));
234
+ if (event.sessionID) headers.set("x-claude-code-session-id", sessionUUID(event.sessionID));
235
+ if (options.sdkHeaders) {
236
+ headers.set("accept", "application/json");
237
+ for (const [name, value] of Object.entries(SDK_HEADERS)) headers.set(name, value);
238
+ }
239
+
240
+ const shapesBody =
241
+ options.systemMode !== "off" || options.renameTools || options.metadata || options.thinking;
242
+ if (shapesBody && event.request.body) {
243
+ const raw = await event.request.clone().text();
244
+ const metadataUserId =
245
+ options.metadata && event.sessionID
246
+ ? JSON.stringify({
247
+ device_id: await deviceID(),
248
+ account_uuid: "",
249
+ session_id: sessionUUID(event.sessionID),
250
+ })
251
+ : null;
252
+ const transformed = transformRequestBody(raw, {
253
+ ...requestOptions,
254
+ metadataUserId,
255
+ thinking: options.thinking && event.kind === "primary",
256
+ });
257
+ if (transformed !== raw) {
258
+ event.request = new Request(event.request.url, {
259
+ method: event.request.method,
260
+ headers,
261
+ body: transformed,
262
+ signal: event.request.signal,
263
+ });
264
+ }
265
+ if (dumpFile) {
266
+ let body = transformed;
267
+ try {
268
+ body = JSON.parse(transformed);
269
+ } catch {}
270
+ try {
271
+ appendFileSync(
272
+ dumpFile,
273
+ `${JSON.stringify({ at: new Date().toISOString(), kind: event.kind, url: event.request.url, body })}\n`,
274
+ );
275
+ } catch (error) {
276
+ console.error("opencode-anthropic: failed to write ANTHROPIC_OAUTH_DUMP:", error);
277
+ }
278
+ }
279
+ }
280
+ } catch (error) {
281
+ const message = String(error?.message ?? error);
282
+ if (message.includes("Token refresh failed")) console.error(`${LOGIN_HINT} (${message})`);
283
+ else console.error("opencode-anthropic: request hook failed:", error);
284
+ }
285
+ },
286
+ { providerID: "anthropic" },
287
+ );
288
+
289
+ await ctx.session.hook(
290
+ "http.response",
291
+ (event) => {
292
+ try {
293
+ rateLimit.note(event.response);
294
+ if (!options.renameTools || !event.response.ok || !event.response.body) return;
295
+ event.response = transformResponseStream(event.response, { reverse, translateInput: translateToolInput });
296
+ } catch (error) {
297
+ console.error("opencode-anthropic: response hook failed:", error);
298
+ }
299
+ },
300
+ { providerID: "anthropic" },
301
+ );
302
+
303
+ // Let OpenCode retry a rate-limited request using Anthropic's own
304
+ // `retry-after` hint instead of its default backoff.
305
+ try {
306
+ await ctx.session.hook(
307
+ "retry",
308
+ (event) => {
309
+ try {
310
+ const delay = rateLimit.delayMsFor(event?.error?.status);
311
+ if (delay === null) return;
312
+ event.decision = { retry: true, delay };
313
+ } catch (error) {
314
+ console.error("opencode-anthropic: retry hook failed:", error);
315
+ }
316
+ },
317
+ { providerID: "anthropic" },
318
+ );
319
+ } catch (error) {
320
+ console.warn("opencode-anthropic: retry hook unavailable in this OpenCode build:", error?.message ?? error);
321
+ }
322
+
323
+ // Translate Claude Code argument shapes (file_path, old_string, ...)
324
+ // back onto OpenCode's tool schemas before execution.
325
+ try {
326
+ await ctx.tool.hook("execute.before", (event) => {
327
+ try {
328
+ event.input = translateToolInput(event.tool, event.input);
329
+ } catch (error) {
330
+ console.error("opencode-anthropic: tool hook failed:", error);
331
+ }
332
+ });
333
+ } catch (error) {
334
+ console.warn("opencode-anthropic: tool hook unavailable in this OpenCode build:", error?.message ?? error);
335
+ }
336
+
337
+ // Register Claude Pro/Max as a native OAuth method so the regular
338
+ // /connect flow works. The CLI login script stays as a fallback.
339
+ try {
340
+ await ctx.integration.transform((editor) => {
341
+ editor.method.update({
342
+ integrationID: "anthropic",
343
+ method: { id: "claude-pro-max", type: "oauth", label: "Claude Pro/Max" },
344
+ authorize: async () => {
345
+ const { url, verifier } = createAuthorizationRequest();
346
+ return {
347
+ mode: "code",
348
+ url,
349
+ instructions: "Authorize in your browser, then paste the code Claude shows.",
350
+ callback: async (code) => {
351
+ const tokens = await exchangeCodeForTokens(code, verifier);
352
+ return {
353
+ type: "oauth",
354
+ methodID: "claude-pro-max",
355
+ access: tokens.access,
356
+ refresh: tokens.refresh,
357
+ expires: tokens.expires,
358
+ };
359
+ },
360
+ };
361
+ },
362
+ refresh: async (credential) => {
363
+ const tokens = await refreshTokens(credential.refresh);
364
+ return {
365
+ ...credential,
366
+ access: tokens.access,
367
+ refresh: tokens.refresh,
368
+ expires: tokens.expires,
369
+ };
370
+ },
371
+ });
372
+ });
373
+ } catch (error) {
374
+ console.warn(
375
+ "opencode-anthropic: could not register the native OAuth method; /connect will not show Claude Pro/Max. Use the login script instead:",
376
+ error?.message ?? error,
377
+ );
378
+ }
379
+ },
380
+ };