op-anthropic-auth-v2 0.1.1 → 0.1.3

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 (3) hide show
  1. package/README.md +45 -13
  2. package/index.js +291 -95
  3. package/package.json +11 -2
package/README.md CHANGED
@@ -1,34 +1,46 @@
1
1
  # op-anthropic-auth-v2
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/op-anthropic-auth-v2?style=flat-square&logo=npm&logoColor=white&label=npm&color=teal)](https://www.npmjs.com/package/op-anthropic-auth-v2)
4
+
3
5
  OpenCode 2 plugin for Anthropic OAuth with Claude Pro/Max.
4
6
 
7
+ ## Origin and attribution
8
+
9
+ This package is adapted from
10
+ [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) version `0.1.4`,
11
+ created by [leohenon](https://github.com/leohenon) and released under the MIT
12
+ License. The original project's authentication approach, request shaping, and
13
+ plugin design form the basis of this package.
14
+
15
+ This adaptation ports that work to the OpenCode 2 plugin API. It is maintained as
16
+ a separate package and is not an official OpenCode 2 release from the original
17
+ author. The [license](./LICENSE) retains credit for the original work.
18
+
5
19
  > [!WARNING]
6
20
  > This is an unofficial compatibility plugin. Anthropic may restrict third-party use
7
21
  > of Claude subscription OAuth credentials, and using it could put your account at
8
22
  > risk. Review Anthropic's current terms and use it at your own discretion.
9
23
 
10
- Port of [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) (MIT, by
11
- leohenon) to the [OpenCode 2](https://opencode.ai/v2/docs) plugin API. The original
12
- plugin targets OpenCode 1 and cannot load in v2 — this package is the v2 equivalent.
13
-
14
24
  ## What it does
15
25
 
16
26
  OpenCode 2 beta sends Anthropic OAuth requests as-is, and Anthropic rejects them with
17
27
  an opaque `429` (`{"message":"Error"}`). This plugin rewrites every request to
18
28
  `api.anthropic.com` so it passes OAuth gating:
19
29
 
20
- - sets the `claude-cli` user-agent and required `anthropic-beta` headers
30
+ - registers a `Claude Pro/Max` browser login using OAuth and PKCE
31
+ - lets OpenCode 2 store credentials and refresh expired tokens
32
+ - sets the `claude-cli` user-agent, matching your installed Claude Code version,
33
+ and the required `anthropic-beta` headers
21
34
  - adds the `x-anthropic-billing-header` system block
22
35
  - prepends the Claude Code identity to the system prompt and relocates remaining
23
36
  system text into the first user message
24
37
  - prefixes tool names with `mcp_` and rewrites the response stream back
25
- - refreshes the OAuth token on expiry and persists it to the shared
26
- `~/.local/share/opencode/auth.json`
27
38
 
28
39
  ## Install
29
40
 
30
41
  ```sh
31
42
  opencode2 plugin add op-anthropic-auth-v2
43
+ opencode2 auth login anthropic --method oauth
32
44
  ```
33
45
 
34
46
  or add it to `~/.config/opencode/opencode.jsonc`:
@@ -39,22 +51,42 @@ or add it to `~/.config/opencode/opencode.jsonc`:
39
51
  }
40
52
  ```
41
53
 
42
- Requires existing Anthropic OAuth credentials (`opencode2 auth login`, or credentials
43
- already stored by OpenCode 1 in `~/.local/share/opencode/auth.json`). Users with a
44
- plain API key are unaffected — the plugin leaves non-OAuth setups untouched.
54
+ The login command opens Anthropic's authorization page. Complete authorization, then
55
+ paste the returned code into OpenCode 2. Users with a plain API key are unaffected;
56
+ the plugin leaves non-OAuth connections untouched.
45
57
 
46
- For OpenCode 1, keep using [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth).
47
- Both can coexist: v1 ignores this package's format, v2 ignores the v1 package.
58
+ This adaptation only targets OpenCode 2. OpenCode 1 users should use the original
59
+ [op-anthropic-auth](https://github.com/leohenon/op-anthropic-auth) package.
48
60
 
49
61
  ## Compatibility
50
62
 
51
- Built against `opencode2` `0.0.0-beta-18269`. The v2 plugin API is beta and may
63
+ Built against `opencode2` `0.0.0-beta-18743`. The v2 plugin API is beta and may
52
64
  change; pin accordingly.
53
65
 
66
+ The plugin reports a Claude Code version to meet Anthropic's model compatibility
67
+ check. It reads that version from your own install rather than pinning one, in
68
+ this order:
69
+
70
+ 1. the `CLAUDE_CODE_VERSION` environment variable, if it looks like a version
71
+ 2. a native install — it follows the `claude` symlink on your `PATH` and takes the
72
+ version from the target path
73
+ 3. an npm install — it reads the version from `@anthropic-ai/claude-code`'s
74
+ `package.json`
75
+ 4. `2.1.265`, if no install is found
76
+
77
+ The lookup is cached for 60 seconds, so a Claude Code update takes effect without
78
+ restarting OpenCode. Set `CLAUDE_CODE_VERSION` to report a specific version.
79
+
80
+ The runtime uses `@openauthjs/openauth` for PKCE generation. Development uses the
81
+ matching `@opencode-ai/plugin` beta types, TypeScript, and Node.js types to check the
82
+ published JavaScript against the OpenCode 2 plugin API.
83
+
54
84
  ## Development
55
85
 
56
86
  ```sh
87
+ npm install
57
88
  npm test
89
+ npm run typecheck
58
90
  npm run check
59
91
  npm run pack:dry-run
60
92
  ```
package/index.js CHANGED
@@ -1,13 +1,26 @@
1
1
  // Anthropic OAuth (Claude Pro/Max) support for OpenCode 2.
2
2
  // Port of op-anthropic-auth@0.1.4 to the v2 plugin API.
3
3
  // v1 loads op-anthropic-auth itself; this file targets opencode2 only.
4
- import { createHash } from "node:crypto";
5
- import { readFileSync, writeFileSync, renameSync, chmodSync } from "node:fs";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import {
6
+ chmodSync,
7
+ existsSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ renameSync,
11
+ writeFileSync,
12
+ } from "node:fs";
6
13
  import { homedir } from "node:os";
7
- import { join } from "node:path";
14
+ import { basename, delimiter, dirname, join } from "node:path";
15
+ import { generatePKCE } from "@openauthjs/openauth/pkce";
8
16
 
9
17
  const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
18
+ const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
10
19
  const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
20
+ const CALLBACK_URL = "https://platform.claude.com/oauth/code/callback";
21
+ const AUTH_METHOD_ID = "oauth";
22
+ const AUTH_SCOPE =
23
+ "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
11
24
  const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"];
12
25
  const TOOL_PREFIX = "mcp_";
13
26
  const OPENCODE_IDENTITY_PREFIX = "You are OpenCode";
@@ -20,117 +33,282 @@ const PARAGRAPH_REMOVAL_ANCHORS = [
20
33
  const TEXT_REPLACEMENTS = [
21
34
  { match: "if OpenCode honestly", replacement: "if the assistant honestly" },
22
35
  ];
23
- const CLAUDE_CODE_VERSION = "2.1.257";
36
+ // Used only when the local Claude Code install cannot be found.
37
+ const FALLBACK_CLAUDE_CODE_VERSION = "2.1.265";
24
38
  const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
25
39
  const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
26
40
  const CCH_SALT = "59cf53e54c78";
27
41
  const CCH_POSITIONS = [4, 7, 20];
28
- const REQUEST_USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`;
42
+ const USER_AGENT_PREFIX = "claude-cli/";
29
43
  const TOKEN_USER_AGENT = "axios/1.13.6";
30
-
31
- const AUTH_FILE =
32
- process.env.XDG_DATA_HOME != null
33
- ? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
34
- : join(homedir(), ".local", "share", "opencode", "auth.json");
44
+ const SEMVER = /^\d+\.\d+\.\d+/;
45
+ const VERSION_CACHE_MS = 60_000;
46
+ const NPM_PACKAGE_NAME = "@anthropic-ai/claude-code";
35
47
 
36
48
  function isRecord(value) {
37
49
  return value != null && typeof value === "object" && !Array.isArray(value);
38
50
  }
39
51
 
40
- function isOAuthAuth(value) {
52
+ // ---- Claude Code version discovery ----
53
+
54
+ // Resolve the `claude` launcher through symlinks. The native installer points
55
+ // ~/.local/bin/claude at ~/.local/share/claude/versions/<version>, so the
56
+ // basename is the active version. Never pick the highest entry in versions/ —
57
+ // a failed download leaves a zero-byte file of a version that cannot run.
58
+ function resolveClaudeBinary() {
59
+ const candidates = [];
60
+ const searchPath = process.env.PATH;
61
+ if (searchPath) {
62
+ for (const dir of searchPath.split(delimiter)) {
63
+ if (dir) candidates.push(join(dir, "claude"));
64
+ }
65
+ }
66
+ candidates.push(join(homedir(), ".local", "bin", "claude"));
67
+ for (const candidate of candidates) {
68
+ try {
69
+ if (existsSync(candidate)) return realpathSync(candidate);
70
+ } catch {
71
+ // Unreadable entry — keep looking.
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // npm installs symlink `claude` to <root>/@anthropic-ai/claude-code/cli.js, so
78
+ // walk up from the resolved file to the package manifest.
79
+ function versionFromNpmPackage(binaryPath) {
80
+ let dir = dirname(binaryPath);
81
+ for (let depth = 0; depth < 5; depth++) {
82
+ try {
83
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
84
+ if (isRecord(manifest) && manifest.name === NPM_PACKAGE_NAME) {
85
+ const version = manifest.version;
86
+ return typeof version === "string" && SEMVER.test(version) ? version : null;
87
+ }
88
+ } catch {
89
+ // No manifest here — keep walking up.
90
+ }
91
+ const parent = dirname(dir);
92
+ if (parent === dir) break;
93
+ dir = parent;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function detectClaudeCodeVersion() {
99
+ const override = process.env.CLAUDE_CODE_VERSION;
100
+ if (override && SEMVER.test(override)) return override;
101
+ const binary = resolveClaudeBinary();
102
+ if (!binary) return null;
103
+ const name = basename(binary);
104
+ if (SEMVER.test(name)) return name;
105
+ return versionFromNpmPackage(binary);
106
+ }
107
+
108
+ let versionCache = { value: "", checkedAt: 0 };
109
+
110
+ // Cached with a TTL rather than resolved once at import: Claude Code updates
111
+ // itself while the OpenCode server stays up for days, and the request hook runs
112
+ // on every call.
113
+ function claudeCodeVersion() {
114
+ const now = Date.now();
115
+ if (versionCache.value && now - versionCache.checkedAt < VERSION_CACHE_MS) {
116
+ return versionCache.value;
117
+ }
118
+ let detected = null;
119
+ try {
120
+ detected = detectClaudeCodeVersion();
121
+ } catch {
122
+ detected = null;
123
+ }
124
+ const value = detected ?? FALLBACK_CLAUDE_CODE_VERSION;
125
+ versionCache = { value, checkedAt: now };
126
+ return value;
127
+ }
128
+
129
+ function requestUserAgent() {
130
+ return `${USER_AGENT_PREFIX}${claudeCodeVersion()} (external, cli)`;
131
+ }
132
+
133
+ // Match the prefix, not the whole string. The version can change between a
134
+ // request and its response, and an exact match would skip the rewrite.
135
+ function isOwnUserAgent(value) {
136
+ return typeof value === "string" && value.startsWith(USER_AGENT_PREFIX);
137
+ }
138
+
139
+ function isPluginOAuthCredential(value) {
41
140
  return (
42
141
  isRecord(value) &&
43
142
  value.type === "oauth" &&
143
+ value.methodID === AUTH_METHOD_ID &&
44
144
  typeof value.refresh === "string" &&
145
+ value.refresh.length > 0 &&
146
+ typeof value.access === "string" &&
147
+ value.access.length > 0 &&
45
148
  typeof value.expires === "number"
46
149
  );
47
150
  }
48
151
 
49
- // ---- auth store ----
152
+ function migrateLegacyCredential() {
153
+ const authFile =
154
+ process.env.XDG_DATA_HOME != null
155
+ ? join(process.env.XDG_DATA_HOME, "opencode", "auth.json")
156
+ : join(homedir(), ".local", "share", "opencode", "auth.json");
157
+ try {
158
+ const all = JSON.parse(readFileSync(authFile, "utf8"));
159
+ const credential = all?.anthropic;
160
+ if (
161
+ !isRecord(credential) ||
162
+ credential.type !== "oauth" ||
163
+ credential.methodID != null ||
164
+ typeof credential.refresh !== "string" ||
165
+ credential.refresh.length === 0 ||
166
+ !Number.isSafeInteger(credential.expires)
167
+ ) {
168
+ return false;
169
+ }
50
170
 
51
- function readAuthFile() {
171
+ const hasAccess = typeof credential.access === "string" && credential.access.length > 0;
172
+ all.anthropic = {
173
+ ...credential,
174
+ methodID: AUTH_METHOD_ID,
175
+ access: hasAccess ? credential.access : "",
176
+ expires: hasAccess ? credential.expires : 0,
177
+ };
178
+ const temporary = `${authFile}.tmp-${process.pid}`;
179
+ writeFileSync(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
180
+ chmodSync(temporary, 0o600);
181
+ renameSync(temporary, authFile);
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function parseAuthorizationCode(input) {
189
+ const text = input.trim();
52
190
  try {
53
- return JSON.parse(readFileSync(AUTH_FILE, "utf8"));
191
+ const url = new URL(text);
192
+ const code = url.searchParams.get("code");
193
+ const state = url.searchParams.get("state");
194
+ if (code && state) return { code, state };
54
195
  } catch {
55
- return null;
196
+ // The callback may be a code-state pair instead of a URL.
56
197
  }
198
+
199
+ const [code, state, ...rest] = text.split("#");
200
+ if (code && state && rest.length === 0) return { code, state };
201
+
202
+ const params = new URLSearchParams(text);
203
+ const queryCode = params.get("code");
204
+ const queryState = params.get("state");
205
+ if (queryCode && queryState) return { code: queryCode, state: queryState };
206
+ throw new Error("Authorization callback must include a code and state");
57
207
  }
58
208
 
59
- function writeAuthEntry(entry) {
60
- const all = readAuthFile() ?? {};
61
- all.anthropic = entry;
62
- const tmp = `${AUTH_FILE}.tmp-${process.pid}`;
63
- writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
64
- chmodSync(tmp, 0o600);
65
- renameSync(tmp, AUTH_FILE);
209
+ function makeAuthorizationUrl(challenge, state) {
210
+ const url = new URL(AUTHORIZE_URL);
211
+ url.searchParams.set("code", "true");
212
+ url.searchParams.set("client_id", CLIENT_ID);
213
+ url.searchParams.set("response_type", "code");
214
+ url.searchParams.set("redirect_uri", CALLBACK_URL);
215
+ url.searchParams.set("scope", AUTH_SCOPE);
216
+ url.searchParams.set("code_challenge", challenge);
217
+ url.searchParams.set("code_challenge_method", "S256");
218
+ url.searchParams.set("state", state);
219
+ return url.toString();
66
220
  }
67
221
 
68
- let refreshPromise = null;
222
+ async function requestTokens(body, action, requireRefreshToken = true) {
223
+ const response = await fetch(TOKEN_URL, {
224
+ method: "POST",
225
+ headers: {
226
+ Accept: "application/json, text/plain, */*",
227
+ "Content-Type": "application/json",
228
+ "User-Agent": TOKEN_USER_AGENT,
229
+ },
230
+ body: JSON.stringify(body),
231
+ });
232
+ if (!response.ok) {
233
+ const detail = await response.text().catch(() => "");
234
+ throw new Error(`${action} failed: ${response.status}${detail ? ` ${detail}` : ""}`);
235
+ }
69
236
 
70
- async function refreshAccessToken(auth) {
71
- let refreshToken = auth.refresh;
72
- const maxRetries = 2;
73
- const baseDelayMs = 500;
74
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
75
- if (attempt > 0) {
76
- await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** (attempt - 1)));
77
- }
78
- const response = await fetch(TOKEN_URL, {
79
- method: "POST",
80
- headers: {
81
- Accept: "application/json, text/plain, */*",
82
- "Content-Type": "application/json",
83
- "User-Agent": TOKEN_USER_AGENT,
84
- },
85
- body: JSON.stringify({
86
- grant_type: "refresh_token",
87
- refresh_token: refreshToken,
88
- client_id: CLIENT_ID,
89
- }),
90
- });
91
- if (!response.ok) {
92
- if (response.status >= 500 && attempt < maxRetries) {
93
- await response.body?.cancel();
94
- continue;
95
- }
96
- // On 401, another process may have already rotated the refresh token.
97
- if (response.status === 401 && attempt < maxRetries) {
98
- const updated = readAuthFile()?.anthropic;
99
- if (isOAuthAuth(updated) && updated.refresh !== refreshToken) {
100
- await response.body?.cancel();
101
- if (updated.access && updated.expires > Date.now()) {
102
- return updated.access;
103
- }
104
- refreshToken = updated.refresh;
105
- continue;
106
- }
107
- }
108
- const body = await response.text().catch(() => "");
109
- throw new Error(`Token refresh failed: ${response.status} — ${body}`);
110
- }
111
- const json = await response.json();
112
- const entry = {
113
- type: "oauth",
114
- refresh: json.refresh_token,
115
- access: json.access_token,
116
- expires: Date.now() + json.expires_in * 1000,
117
- };
118
- writeAuthEntry(entry);
119
- return entry.access;
237
+ const result = await response.json();
238
+ if (
239
+ !isRecord(result) ||
240
+ typeof result.access_token !== "string" ||
241
+ result.access_token.length === 0 ||
242
+ !Number.isSafeInteger(result.expires_in) ||
243
+ result.expires_in <= 0 ||
244
+ (requireRefreshToken &&
245
+ (typeof result.refresh_token !== "string" || result.refresh_token.length === 0)) ||
246
+ (!requireRefreshToken &&
247
+ result.refresh_token != null &&
248
+ (typeof result.refresh_token !== "string" || result.refresh_token.length === 0))
249
+ ) {
250
+ throw new Error(`${action} failed: malformed token response`);
120
251
  }
121
- throw new Error("Token refresh failed: retries exhausted");
252
+ return result;
122
253
  }
123
254
 
124
- async function currentAccessToken() {
125
- const auth = readAuthFile()?.anthropic;
126
- if (!isOAuthAuth(auth)) return null;
127
- if (auth.access && auth.expires > Date.now() + 30_000) return auth.access;
128
- if (!refreshPromise) {
129
- refreshPromise = refreshAccessToken(auth).finally(() => {
130
- refreshPromise = null;
131
- });
132
- }
133
- return refreshPromise;
255
+ function toCredential(tokens, metadata, fallbackRefreshToken) {
256
+ return /** @type {import("@opencode-ai/plugin").Credential.OAuth} */ ({
257
+ type: /** @type {"oauth"} */ ("oauth"),
258
+ methodID: AUTH_METHOD_ID,
259
+ refresh: tokens.refresh_token ?? fallbackRefreshToken,
260
+ access: tokens.access_token,
261
+ expires: Date.now() + tokens.expires_in * 1000,
262
+ ...(metadata == null ? {} : { metadata }),
263
+ });
264
+ }
265
+
266
+ async function authorize() {
267
+ const pkce = await generatePKCE();
268
+ const state = randomBytes(16).toString("hex");
269
+ return {
270
+ url: makeAuthorizationUrl(pkce.challenge, state),
271
+ instructions: "Authorize Claude Pro/Max, then paste the returned code here.",
272
+ mode: /** @type {"code"} */ ("code"),
273
+ async callback(input) {
274
+ const parsed = parseAuthorizationCode(input);
275
+ if (parsed.state !== state) throw new Error("Authorization state does not match");
276
+ const tokens = await requestTokens(
277
+ {
278
+ code: parsed.code,
279
+ state: parsed.state,
280
+ grant_type: "authorization_code",
281
+ client_id: CLIENT_ID,
282
+ redirect_uri: CALLBACK_URL,
283
+ code_verifier: pkce.verifier,
284
+ },
285
+ "Token exchange",
286
+ );
287
+ return toCredential(tokens);
288
+ },
289
+ };
290
+ }
291
+
292
+ async function refreshCredential(
293
+ /** @type {import("@opencode-ai/plugin").Credential.OAuth} */ credential,
294
+ ) {
295
+ const tokens = await requestTokens(
296
+ {
297
+ grant_type: "refresh_token",
298
+ refresh_token: credential.refresh,
299
+ client_id: CLIENT_ID,
300
+ },
301
+ "Token refresh",
302
+ false,
303
+ );
304
+ return toCredential(tokens, credential.metadata, credential.refresh);
305
+ }
306
+
307
+ async function currentCredential(ctx) {
308
+ const connection = await ctx.integration.connection.active("anthropic");
309
+ if (!connection) return null;
310
+ const credential = await ctx.integration.connection.resolve(connection);
311
+ return isPluginOAuthCredential(credential) ? credential : null;
134
312
  }
135
313
 
136
314
  // ---- request rewriting ----
@@ -202,21 +380,22 @@ function computeCCH(messageText) {
202
380
  return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
203
381
  }
204
382
 
205
- function computeVersionSuffix(messageText) {
383
+ function computeVersionSuffix(messageText, version) {
206
384
  const chars = CCH_POSITIONS.map((index) => messageText[index] || "0").join("");
207
385
  return createHash("sha256")
208
- .update(`${CCH_SALT}${chars}${CLAUDE_CODE_VERSION}`)
386
+ .update(`${CCH_SALT}${chars}${version}`)
209
387
  .digest("hex")
210
388
  .slice(0, 3);
211
389
  }
212
390
 
213
391
  function buildBillingHeaderValue(messages) {
214
392
  const text = extractFirstUserMessageText(messages);
215
- const suffix = computeVersionSuffix(text);
393
+ const version = claudeCodeVersion();
394
+ const suffix = computeVersionSuffix(text, version);
216
395
  const cch = computeCCH(text);
217
396
  return (
218
397
  `${BILLING_HEADER_PREFIX} ` +
219
- `cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` +
398
+ `cc_version=${version}.${suffix}; ` +
220
399
  `cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; ` +
221
400
  `cch=${cch};`
222
401
  );
@@ -386,9 +565,24 @@ function isAnthropicApi(url) {
386
565
 
387
566
  // ---- plugin ----
388
567
 
389
- export default {
568
+ /** @type {import("@opencode-ai/plugin").Plugin.Plugin} */
569
+ const plugin = {
390
570
  id: "anthropic-oauth-v2",
391
571
  async setup(ctx) {
572
+ migrateLegacyCredential();
573
+ await ctx.integration.transform((draft) => {
574
+ draft.method.update({
575
+ integrationID: "anthropic",
576
+ method: {
577
+ id: AUTH_METHOD_ID,
578
+ type: "oauth",
579
+ label: "Claude Pro/Max",
580
+ },
581
+ authorize,
582
+ refresh: refreshCredential,
583
+ });
584
+ });
585
+
392
586
  await ctx.session.hook(
393
587
  "http.request",
394
588
  async (event) => {
@@ -400,13 +594,13 @@ export default {
400
594
  }
401
595
  if (!isAnthropicApi(url)) return;
402
596
 
403
- const access = await currentAccessToken();
404
- if (!access) return; // not OAuth (API key user) — leave untouched
597
+ const credential = await currentCredential(ctx);
598
+ if (!credential) return;
405
599
 
406
600
  const headers = new Headers(event.request.headers);
407
- headers.set("authorization", `Bearer ${access}`);
601
+ headers.set("authorization", `Bearer ${credential.access}`);
408
602
  headers.set("anthropic-beta", mergeBetaHeaders(headers));
409
- headers.set("user-agent", REQUEST_USER_AGENT);
603
+ headers.set("user-agent", requestUserAgent());
410
604
  headers.delete("x-api-key");
411
605
 
412
606
  if (url.pathname === "/v1/messages" && !url.searchParams.has("beta")) {
@@ -423,7 +617,6 @@ export default {
423
617
  method: event.request.method,
424
618
  headers,
425
619
  body,
426
- duplex: "half",
427
620
  signal: event.request.signal,
428
621
  });
429
622
  },
@@ -432,7 +625,8 @@ export default {
432
625
 
433
626
  await ctx.session.hook(
434
627
  "http.response",
435
- (event) => {
628
+ async (event) => {
629
+ if (!isOwnUserAgent(event.request.headers.get("user-agent"))) return;
436
630
  // Rewrite unless the response URL is present and provably non-Anthropic.
437
631
  try {
438
632
  const url = new URL(event.response.url);
@@ -446,3 +640,5 @@ export default {
446
640
  );
447
641
  },
448
642
  };
643
+
644
+ export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "op-anthropic-auth-v2",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode 2 plugin for Anthropic OAuth with Claude Pro/Max",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -36,7 +36,16 @@
36
36
  },
37
37
  "scripts": {
38
38
  "test": "node --test",
39
- "check": "node --check index.js && npm test",
39
+ "typecheck": "tsc",
40
+ "check": "node --check index.js && npm run typecheck && npm test",
40
41
  "pack:dry-run": "npm pack --dry-run"
42
+ },
43
+ "dependencies": {
44
+ "@openauthjs/openauth": "0.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@opencode-ai/plugin": "0.0.0-beta-18743",
48
+ "@types/node": "24.6.1",
49
+ "typescript": "5.9.3"
41
50
  }
42
51
  }