@ex-machina/opencode-anthropic-auth 1.1.2 → 1.3.0

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/README.md CHANGED
@@ -21,7 +21,7 @@ Add the plugin to your OpenCode configuration:
21
21
 
22
22
  ```json
23
23
  {
24
- "plugin": ["@ex-machina/opencode-anthropic-auth@1.1.1"]
24
+ "plugin": ["@ex-machina/opencode-anthropic-auth@1.2.0"]
25
25
  }
26
26
  ```
27
27
 
@@ -41,10 +41,46 @@ For Claude Pro/Max authentication, the plugin:
41
41
  2. Exchanges the authorization code for access and refresh tokens
42
42
  3. Automatically refreshes expired tokens
43
43
  4. Injects the required OAuth headers and beta flags into API requests
44
- 5. Zeros out model costs (since usage is covered by the subscription)
44
+ 5. Sanitizes the system prompt for compatibility (see below)
45
+ 6. Zeros out model costs (since usage is covered by the subscription)
46
+
47
+ ### System Prompt Sanitization
48
+
49
+ The Anthropic API for Max subscriptions requires the system prompt to identify as Claude Code. The plugin rewrites the system prompt on each request using an **anchor-based** approach that minimizes what gets changed:
50
+
51
+ 1. **Identity swap** — The OpenCode identity line is removed and replaced with the Claude Code identity.
52
+ 2. **Paragraph removal by anchor** — Any paragraph containing a known URL anchor (e.g. `github.com/anomalyco/opencode`, `opencode.ai/docs`) is removed entirely. This is resilient to upstream rewording — as long as the anchor URL appears somewhere in the paragraph, the removal works regardless of surrounding text changes.
53
+ 3. **Inline text replacements** — Short branded strings inside paragraphs we want to keep are replaced (e.g. "OpenCode" → "the assistant" in the professional objectivity section).
54
+
55
+ Everything else in the system prompt is preserved: tone/style guidance, task management instructions, tool usage policy, environment info, skills, user/project instructions, and file paths containing "opencode".
45
56
 
46
57
  ## Development
47
58
 
59
+ ### Local Testing
60
+
61
+ Use `bun run dev` to test plugin changes locally without publishing to npm:
62
+
63
+ ```bash
64
+ bun run dev
65
+ ```
66
+
67
+ This does three things:
68
+
69
+ 1. Builds the plugin
70
+ 2. Symlinks the build output into `.opencode/plugins/` so OpenCode loads it as a local plugin
71
+ 3. Starts `tsc --watch` for automatic rebuilds on source changes
72
+
73
+ After starting the dev script, restart OpenCode in this project directory to pick up the local build. Any edits to `src/` will trigger a rebuild — restart OpenCode again to load the new version.
74
+
75
+ Ctrl+C stops the watcher and cleans up the symlink. If the process was killed without cleanup (e.g. `kill -9`), you can manually remove the symlink:
76
+
77
+ ```bash
78
+ bun run dev:clean
79
+ ```
80
+
81
+ > [!NOTE]
82
+ > If you have the npm version of this plugin in your global OpenCode config, both will load. The local version takes precedence for auth handling.
83
+
48
84
  ### Publishing
49
85
 
50
86
  This project uses [changesets](https://github.com/changesets/changesets) for versioning and publishing. See the [changeset README](.changeset/README.md) for more details.
@@ -8,4 +8,25 @@ export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
8
8
  export declare const OAUTH_SCOPES: string[];
9
9
  export declare const TOOL_PREFIX = "mcp_";
10
10
  export declare const REQUIRED_BETAS: string[];
11
+ export declare const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
12
+ export declare const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
11
13
  export declare const USER_AGENT = "claude-cli/2.1.2 (external, cli)";
14
+ /**
15
+ * Anchors that identify paragraphs to remove from the system prompt.
16
+ * Any paragraph (text between blank lines) containing one of these
17
+ * strings is removed entirely.
18
+ *
19
+ * This is resilient to upstream rewording — as long as the anchor
20
+ * string (typically a URL) still appears somewhere in the paragraph,
21
+ * the removal works regardless of how the surrounding text changes.
22
+ */
23
+ export declare const PARAGRAPH_REMOVAL_ANCHORS: string[];
24
+ /**
25
+ * Inline text replacements applied after paragraph removal.
26
+ * These handle cases where "OpenCode" appears inside a paragraph
27
+ * we want to keep (so we can't remove the whole paragraph).
28
+ */
29
+ export declare const TEXT_REPLACEMENTS: {
30
+ match: string;
31
+ replacement: string;
32
+ }[];
package/dist/constants.js CHANGED
@@ -18,4 +18,29 @@ export const REQUIRED_BETAS = [
18
18
  'oauth-2025-04-20',
19
19
  'interleaved-thinking-2025-05-14',
20
20
  ];
21
+ export const OPENCODE_IDENTITY = 'You are OpenCode, the best coding agent on the planet.';
22
+ export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
21
23
  export const USER_AGENT = 'claude-cli/2.1.2 (external, cli)';
24
+ /**
25
+ * Anchors that identify paragraphs to remove from the system prompt.
26
+ * Any paragraph (text between blank lines) containing one of these
27
+ * strings is removed entirely.
28
+ *
29
+ * This is resilient to upstream rewording — as long as the anchor
30
+ * string (typically a URL) still appears somewhere in the paragraph,
31
+ * the removal works regardless of how the surrounding text changes.
32
+ */
33
+ export const PARAGRAPH_REMOVAL_ANCHORS = [
34
+ // Help/feedback block — references the OpenCode GitHub repo
35
+ 'github.com/anomalyco/opencode',
36
+ // OpenCode docs guidance — references the OpenCode docs URL
37
+ 'opencode.ai/docs',
38
+ ];
39
+ /**
40
+ * Inline text replacements applied after paragraph removal.
41
+ * These handle cases where "OpenCode" appears inside a paragraph
42
+ * we want to keep (so we can't remove the whole paragraph).
43
+ */
44
+ export const TEXT_REPLACEMENTS = [
45
+ { match: 'if OpenCode honestly', replacement: 'if the assistant honestly' },
46
+ ];
package/dist/index.js CHANGED
@@ -1,16 +1,8 @@
1
1
  import { authorize, exchange } from './auth';
2
2
  import { CLIENT_ID, TOKEN_URL } from './constants';
3
- import { createStrippedStream, isInsecure, mergeHeaders, prefixToolNames, rewriteUrl, setOAuthHeaders, } from './transform';
3
+ import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from './transform';
4
4
  export const AnthropicAuthPlugin = async ({ client }) => {
5
5
  return {
6
- 'experimental.chat.system.transform': (input, output) => {
7
- const prefix = "You are Claude Code, Anthropic's official CLI for Claude.";
8
- if (input.model?.providerID === 'anthropic') {
9
- output.system.unshift(prefix);
10
- if (output.system[1])
11
- output.system[1] = `${prefix}\n\n${output.system[1]}`;
12
- }
13
- },
14
6
  auth: {
15
7
  provider: 'anthropic',
16
8
  async loader(getAuth, provider) {
@@ -111,14 +103,13 @@ export const AnthropicAuthPlugin = async ({ client }) => {
111
103
  setOAuthHeaders(requestHeaders, auth.access);
112
104
  let body = init?.body;
113
105
  if (body && typeof body === 'string') {
114
- body = prefixToolNames(body);
106
+ body = rewriteRequestBody(body);
115
107
  }
116
108
  const rewritten = rewriteUrl(input);
117
109
  const response = await fetch(rewritten.input, {
118
110
  ...init,
119
111
  body,
120
112
  headers: requestHeaders,
121
- // biome-ignore lint/suspicious/noExplicitAny: tls option is Bun-specific, not in standard RequestInit
122
113
  ...(isInsecure() && { tls: { rejectUnauthorized: false } }),
123
114
  });
124
115
  return createStrippedStream(response);
@@ -37,7 +37,37 @@ export declare function rewriteUrl(input: FetchInput): {
37
37
  input: FetchInput;
38
38
  url: URL | null;
39
39
  };
40
+ /**
41
+ * Sanitize OpenCode-branded strings from the system prompt text.
42
+ *
43
+ * 1. Removes the OPENCODE_IDENTITY line.
44
+ * 2. Removes any paragraph (text between blank lines) that contains
45
+ * one of the PARAGRAPH_REMOVAL_ANCHORS — typically URLs that
46
+ * identify OpenCode-specific content.
47
+ * 3. Applies TEXT_REPLACEMENTS for inline occurrences of "OpenCode"
48
+ * inside paragraphs we want to keep.
49
+ *
50
+ * This approach is resilient to upstream rewording of the OpenCode
51
+ * prompt — as long as the anchor strings (URLs, etc.) still appear
52
+ * somewhere in the paragraph, the removal works.
53
+ */
54
+ export declare function sanitizeSystemText(text: string): string;
55
+ type SystemBlock = {
56
+ type: string;
57
+ text: string;
58
+ [k: string]: unknown;
59
+ };
60
+ /**
61
+ * Sanitize system prompt and prepend Claude Code identity.
62
+ * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
63
+ */
64
+ export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[];
65
+ /**
66
+ * Rewrite the full request body: sanitize system prompt and prefix tool names.
67
+ */
68
+ export declare function rewriteRequestBody(body: string): string;
40
69
  /**
41
70
  * Create a streaming response that strips the tool prefix from tool names.
42
71
  */
43
72
  export declare function createStrippedStream(response: Response): Response;
73
+ export {};
package/dist/transform.js CHANGED
@@ -1,4 +1,4 @@
1
- import { REQUIRED_BETAS, TOOL_PREFIX, USER_AGENT } from './constants';
1
+ import { CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from './constants';
2
2
  /**
3
3
  * Merge headers from a Request object and/or a RequestInit headers value
4
4
  * into a single Headers instance.
@@ -168,6 +168,131 @@ export function rewriteUrl(input) {
168
168
  : requestUrl;
169
169
  return { input: newInput, url: requestUrl };
170
170
  }
171
+ /**
172
+ * Sanitize OpenCode-branded strings from the system prompt text.
173
+ *
174
+ * 1. Removes the OPENCODE_IDENTITY line.
175
+ * 2. Removes any paragraph (text between blank lines) that contains
176
+ * one of the PARAGRAPH_REMOVAL_ANCHORS — typically URLs that
177
+ * identify OpenCode-specific content.
178
+ * 3. Applies TEXT_REPLACEMENTS for inline occurrences of "OpenCode"
179
+ * inside paragraphs we want to keep.
180
+ *
181
+ * This approach is resilient to upstream rewording of the OpenCode
182
+ * prompt — as long as the anchor strings (URLs, etc.) still appear
183
+ * somewhere in the paragraph, the removal works.
184
+ */
185
+ export function sanitizeSystemText(text) {
186
+ if (!text.includes(OPENCODE_IDENTITY))
187
+ return text;
188
+ // Split into paragraphs (separated by one or more blank lines)
189
+ const paragraphs = text.split(/\n\n+/);
190
+ const filtered = paragraphs.filter((paragraph) => {
191
+ // Remove the identity line (may be its own paragraph or part of one)
192
+ if (paragraph.includes(OPENCODE_IDENTITY)) {
193
+ // If the paragraph is JUST the identity, drop it entirely
194
+ if (paragraph.trim() === OPENCODE_IDENTITY)
195
+ return false;
196
+ // Otherwise it's mixed — we'll handle inline below
197
+ }
198
+ // Remove paragraphs containing any removal anchor
199
+ for (const anchor of PARAGRAPH_REMOVAL_ANCHORS) {
200
+ if (paragraph.includes(anchor))
201
+ return false;
202
+ }
203
+ return true;
204
+ });
205
+ let result = filtered.join('\n\n');
206
+ // Remove the identity line if it was part of a larger paragraph
207
+ result = result.replace(OPENCODE_IDENTITY, '').replace(/\n{3,}/g, '\n\n');
208
+ // Apply inline text replacements
209
+ for (const rule of TEXT_REPLACEMENTS) {
210
+ result = result.replace(rule.match, rule.replacement);
211
+ }
212
+ return result.trim();
213
+ }
214
+ function isRecord(value) {
215
+ return value != null && typeof value === 'object' && !Array.isArray(value);
216
+ }
217
+ /**
218
+ * Sanitize system prompt and prepend Claude Code identity.
219
+ * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
220
+ */
221
+ export function prependClaudeCodeIdentity(system) {
222
+ const identityBlock = {
223
+ type: 'text',
224
+ text: CLAUDE_CODE_IDENTITY,
225
+ };
226
+ if (system == null)
227
+ return [identityBlock];
228
+ if (typeof system === 'string') {
229
+ const sanitized = sanitizeSystemText(system);
230
+ if (sanitized === CLAUDE_CODE_IDENTITY)
231
+ return [identityBlock];
232
+ return [identityBlock, { type: 'text', text: sanitized }];
233
+ }
234
+ if (isRecord(system)) {
235
+ const type = typeof system.type === 'string' ? system.type : 'text';
236
+ const text = typeof system.text === 'string' ? system.text : '';
237
+ return [identityBlock, { ...system, type, text: sanitizeSystemText(text) }];
238
+ }
239
+ if (!Array.isArray(system))
240
+ return [identityBlock];
241
+ const sanitized = system.map((item) => {
242
+ if (typeof item === 'string') {
243
+ return { type: 'text', text: sanitizeSystemText(item) };
244
+ }
245
+ if (isRecord(item) &&
246
+ item.type === 'text' &&
247
+ typeof item.text === 'string') {
248
+ return {
249
+ ...item,
250
+ type: 'text',
251
+ text: sanitizeSystemText(item.text),
252
+ };
253
+ }
254
+ return { type: 'text', text: String(item) };
255
+ });
256
+ // Idempotency: don't double-prepend if first block already has the identity
257
+ if (sanitized[0]?.text === CLAUDE_CODE_IDENTITY) {
258
+ return sanitized;
259
+ }
260
+ return [identityBlock, ...sanitized];
261
+ }
262
+ /**
263
+ * Rewrite the full request body: sanitize system prompt and prefix tool names.
264
+ */
265
+ export function rewriteRequestBody(body) {
266
+ try {
267
+ const parsed = JSON.parse(body);
268
+ // Sanitize system prompt and prepend Claude Code identity
269
+ parsed.system = prependClaudeCodeIdentity(parsed.system);
270
+ // Prefix tool names
271
+ if (parsed.tools && Array.isArray(parsed.tools)) {
272
+ parsed.tools = parsed.tools.map((tool) => ({
273
+ ...tool,
274
+ name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
275
+ }));
276
+ }
277
+ if (parsed.messages && Array.isArray(parsed.messages)) {
278
+ parsed.messages = parsed.messages.map((msg) => {
279
+ if (msg.content && Array.isArray(msg.content)) {
280
+ msg.content = msg.content.map((block) => {
281
+ if (block.type === 'tool_use' && block.name) {
282
+ return { ...block, name: `${TOOL_PREFIX}${block.name}` };
283
+ }
284
+ return block;
285
+ });
286
+ }
287
+ return msg;
288
+ });
289
+ }
290
+ return JSON.stringify(parsed);
291
+ }
292
+ catch {
293
+ return body;
294
+ }
295
+ }
171
296
  /**
172
297
  * Create a streaming response that strips the tool prefix from tool names.
173
298
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ex-machina/opencode-anthropic-auth",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ex-machina-co/opencode-anthropic-auth"
@@ -13,6 +13,8 @@
13
13
  "scripts": {
14
14
  "prepare": "tsc -p tsconfig.build.json",
15
15
  "build": "tsc -p tsconfig.build.json",
16
+ "dev": "bun scripts/dev.ts",
17
+ "dev:clean": "bun scripts/dev-clean.ts",
16
18
  "test": "bun test",
17
19
  "types": "tsc",
18
20
  "format": "biome format --write .",
@@ -28,6 +30,7 @@
28
30
  "@opencode-ai/plugin": "1.3.13",
29
31
  "@tsconfig/bun": "1.0.10",
30
32
  "@types/bun": "1.3.11",
33
+ "dedent": "^1.7.2",
31
34
  "lefthook": "2.1.4",
32
35
  "typescript": "6.0.2"
33
36
  }