op-anthropic-auth-v2 0.1.2 → 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 +18 -4
  2. package/index.js +109 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -29,7 +29,8 @@ an opaque `429` (`{"message":"Error"}`). This plugin rewrites every request to
29
29
 
30
30
  - registers a `Claude Pro/Max` browser login using OAuth and PKCE
31
31
  - lets OpenCode 2 store credentials and refresh expired tokens
32
- - sets the `claude-cli` user-agent and required `anthropic-beta` headers
32
+ - sets the `claude-cli` user-agent, matching your installed Claude Code version,
33
+ and the required `anthropic-beta` headers
33
34
  - adds the `x-anthropic-billing-header` system block
34
35
  - prepends the Claude Code identity to the system prompt and relocates remaining
35
36
  system text into the first user message
@@ -59,9 +60,22 @@ This adaptation only targets OpenCode 2. OpenCode 1 users should use the origina
59
60
 
60
61
  ## Compatibility
61
62
 
62
- Built against `opencode2` `0.0.0-beta-18743`. Version `0.1.2` reports Claude Code
63
- `2.1.257` to meet Anthropic's model compatibility check. The v2 plugin API is beta
64
- and may change; pin accordingly.
63
+ Built against `opencode2` `0.0.0-beta-18743`. The v2 plugin API is beta and may
64
+ change; pin accordingly.
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.
65
79
 
66
80
  The runtime uses `@openauthjs/openauth` for PKCE generation. Development uses the
67
81
  matching `@opencode-ai/plugin` beta types, TypeScript, and Node.js types to check the
package/index.js CHANGED
@@ -2,9 +2,16 @@
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
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { chmodSync, readFileSync, renameSync, writeFileSync } from "node:fs";
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";
8
15
  import { generatePKCE } from "@openauthjs/openauth/pkce";
9
16
 
10
17
  const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
@@ -26,18 +33,109 @@ const PARAGRAPH_REMOVAL_ANCHORS = [
26
33
  const TEXT_REPLACEMENTS = [
27
34
  { match: "if OpenCode honestly", replacement: "if the assistant honestly" },
28
35
  ];
29
- 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";
30
38
  const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
31
39
  const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
32
40
  const CCH_SALT = "59cf53e54c78";
33
41
  const CCH_POSITIONS = [4, 7, 20];
34
- const REQUEST_USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`;
42
+ const USER_AGENT_PREFIX = "claude-cli/";
35
43
  const TOKEN_USER_AGENT = "axios/1.13.6";
44
+ const SEMVER = /^\d+\.\d+\.\d+/;
45
+ const VERSION_CACHE_MS = 60_000;
46
+ const NPM_PACKAGE_NAME = "@anthropic-ai/claude-code";
36
47
 
37
48
  function isRecord(value) {
38
49
  return value != null && typeof value === "object" && !Array.isArray(value);
39
50
  }
40
51
 
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
+
41
139
  function isPluginOAuthCredential(value) {
42
140
  return (
43
141
  isRecord(value) &&
@@ -282,21 +380,22 @@ function computeCCH(messageText) {
282
380
  return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
283
381
  }
284
382
 
285
- function computeVersionSuffix(messageText) {
383
+ function computeVersionSuffix(messageText, version) {
286
384
  const chars = CCH_POSITIONS.map((index) => messageText[index] || "0").join("");
287
385
  return createHash("sha256")
288
- .update(`${CCH_SALT}${chars}${CLAUDE_CODE_VERSION}`)
386
+ .update(`${CCH_SALT}${chars}${version}`)
289
387
  .digest("hex")
290
388
  .slice(0, 3);
291
389
  }
292
390
 
293
391
  function buildBillingHeaderValue(messages) {
294
392
  const text = extractFirstUserMessageText(messages);
295
- const suffix = computeVersionSuffix(text);
393
+ const version = claudeCodeVersion();
394
+ const suffix = computeVersionSuffix(text, version);
296
395
  const cch = computeCCH(text);
297
396
  return (
298
397
  `${BILLING_HEADER_PREFIX} ` +
299
- `cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` +
398
+ `cc_version=${version}.${suffix}; ` +
300
399
  `cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; ` +
301
400
  `cch=${cch};`
302
401
  );
@@ -501,7 +600,7 @@ const plugin = {
501
600
  const headers = new Headers(event.request.headers);
502
601
  headers.set("authorization", `Bearer ${credential.access}`);
503
602
  headers.set("anthropic-beta", mergeBetaHeaders(headers));
504
- headers.set("user-agent", REQUEST_USER_AGENT);
603
+ headers.set("user-agent", requestUserAgent());
505
604
  headers.delete("x-api-key");
506
605
 
507
606
  if (url.pathname === "/v1/messages" && !url.searchParams.has("beta")) {
@@ -527,7 +626,7 @@ const plugin = {
527
626
  await ctx.session.hook(
528
627
  "http.response",
529
628
  async (event) => {
530
- if (event.request.headers.get("user-agent") !== REQUEST_USER_AGENT) return;
629
+ if (!isOwnUserAgent(event.request.headers.get("user-agent"))) return;
531
630
  // Rewrite unless the response URL is present and provably non-Anthropic.
532
631
  try {
533
632
  const url = new URL(event.response.url);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "op-anthropic-auth-v2",
3
- "version": "0.1.2",
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",