@ex-machina/opencode-anthropic-auth 1.2.0 → 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.
@@ -11,4 +11,22 @@ export declare const REQUIRED_BETAS: string[];
11
11
  export declare const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
12
12
  export declare const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
13
13
  export declare const USER_AGENT = "claude-cli/2.1.2 (external, cli)";
14
- export declare const PRESERVED_TAIL_MARKERS: string[];
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
@@ -21,8 +21,26 @@ export const REQUIRED_BETAS = [
21
21
  export const OPENCODE_IDENTITY = 'You are OpenCode, the best coding agent on the planet.';
22
22
  export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
23
23
  export const USER_AGENT = 'claude-cli/2.1.2 (external, cli)';
24
- export const PRESERVED_TAIL_MARKERS = [
25
- '\nInstructions from: ',
26
- '\nInstructions from command: ',
27
- '# Code References',
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' },
28
46
  ];
package/dist/index.js CHANGED
@@ -110,7 +110,6 @@ export const AnthropicAuthPlugin = async ({ client }) => {
110
110
  ...init,
111
111
  body,
112
112
  headers: requestHeaders,
113
- // biome-ignore lint/suspicious/noExplicitAny: tls option is Bun-specific, not in standard RequestInit
114
113
  ...(isInsecure() && { tls: { rejectUnauthorized: false } }),
115
114
  });
116
115
  return createStrippedStream(response);
@@ -38,12 +38,20 @@ export declare function rewriteUrl(input: FetchInput): {
38
38
  url: URL | null;
39
39
  };
40
40
  /**
41
- * Remove the OpenCode identity section from system prompt text.
42
- * Finds the OpenCode identity marker, then removes everything up to
43
- * the earliest preserved tail marker (user instructions, code references, etc.).
44
- * This preserves user-configured instructions from config.json.
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.
45
53
  */
46
- export declare function sanitizeSystemText(text: string, onError?: (msg: string) => void): string;
54
+ export declare function sanitizeSystemText(text: string): string;
47
55
  type SystemBlock = {
48
56
  type: string;
49
57
  text: string;
@@ -53,11 +61,11 @@ type SystemBlock = {
53
61
  * Sanitize system prompt and prepend Claude Code identity.
54
62
  * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
55
63
  */
56
- export declare function prependClaudeCodeIdentity(system: unknown, onError?: (msg: string) => void): SystemBlock[];
64
+ export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[];
57
65
  /**
58
66
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
59
67
  */
60
- export declare function rewriteRequestBody(body: string, onError?: (msg: string) => void): string;
68
+ export declare function rewriteRequestBody(body: string): string;
61
69
  /**
62
70
  * Create a streaming response that strips the tool prefix from tool names.
63
71
  */
package/dist/transform.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY, PRESERVED_TAIL_MARKERS, 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.
@@ -169,30 +169,47 @@ export function rewriteUrl(input) {
169
169
  return { input: newInput, url: requestUrl };
170
170
  }
171
171
  /**
172
- * Remove the OpenCode identity section from system prompt text.
173
- * Finds the OpenCode identity marker, then removes everything up to
174
- * the earliest preserved tail marker (user instructions, code references, etc.).
175
- * This preserves user-configured instructions from config.json.
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.
176
184
  */
177
- export function sanitizeSystemText(text, onError) {
178
- const startIdx = text.indexOf(OPENCODE_IDENTITY);
179
- if (startIdx === -1)
185
+ export function sanitizeSystemText(text) {
186
+ if (!text.includes(OPENCODE_IDENTITY))
180
187
  return text;
181
- // Find the earliest preserved tail marker after the OpenCode identity
182
- let endIdx = -1;
183
- for (const marker of PRESERVED_TAIL_MARKERS) {
184
- const idx = text.indexOf(marker, startIdx);
185
- if (idx !== -1 && (endIdx === -1 || idx < endIdx)) {
186
- endIdx = idx;
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
187
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);
188
211
  }
189
- if (endIdx === -1) {
190
- onError?.('sanitizeSystemText: could not find any preserved tail marker after OpenCode identity');
191
- return text;
192
- }
193
- // Preserve content from the marker onwards (skip leading newline if present)
194
- const tail = text[endIdx] === '\n' ? text.slice(endIdx + 1) : text.slice(endIdx);
195
- return text.slice(0, startIdx) + tail;
212
+ return result.trim();
196
213
  }
197
214
  function isRecord(value) {
198
215
  return value != null && typeof value === 'object' && !Array.isArray(value);
@@ -201,7 +218,7 @@ function isRecord(value) {
201
218
  * Sanitize system prompt and prepend Claude Code identity.
202
219
  * Handles all Anthropic API system formats: undefined, string, or array of text blocks.
203
220
  */
204
- export function prependClaudeCodeIdentity(system, onError) {
221
+ export function prependClaudeCodeIdentity(system) {
205
222
  const identityBlock = {
206
223
  type: 'text',
207
224
  text: CLAUDE_CODE_IDENTITY,
@@ -209,7 +226,7 @@ export function prependClaudeCodeIdentity(system, onError) {
209
226
  if (system == null)
210
227
  return [identityBlock];
211
228
  if (typeof system === 'string') {
212
- const sanitized = sanitizeSystemText(system, onError);
229
+ const sanitized = sanitizeSystemText(system);
213
230
  if (sanitized === CLAUDE_CODE_IDENTITY)
214
231
  return [identityBlock];
215
232
  return [identityBlock, { type: 'text', text: sanitized }];
@@ -217,16 +234,13 @@ export function prependClaudeCodeIdentity(system, onError) {
217
234
  if (isRecord(system)) {
218
235
  const type = typeof system.type === 'string' ? system.type : 'text';
219
236
  const text = typeof system.text === 'string' ? system.text : '';
220
- return [
221
- identityBlock,
222
- { ...system, type, text: sanitizeSystemText(text, onError) },
223
- ];
237
+ return [identityBlock, { ...system, type, text: sanitizeSystemText(text) }];
224
238
  }
225
239
  if (!Array.isArray(system))
226
240
  return [identityBlock];
227
241
  const sanitized = system.map((item) => {
228
242
  if (typeof item === 'string') {
229
- return { type: 'text', text: sanitizeSystemText(item, onError) };
243
+ return { type: 'text', text: sanitizeSystemText(item) };
230
244
  }
231
245
  if (isRecord(item) &&
232
246
  item.type === 'text' &&
@@ -234,7 +248,7 @@ export function prependClaudeCodeIdentity(system, onError) {
234
248
  return {
235
249
  ...item,
236
250
  type: 'text',
237
- text: sanitizeSystemText(item.text, onError),
251
+ text: sanitizeSystemText(item.text),
238
252
  };
239
253
  }
240
254
  return { type: 'text', text: String(item) };
@@ -248,11 +262,11 @@ export function prependClaudeCodeIdentity(system, onError) {
248
262
  /**
249
263
  * Rewrite the full request body: sanitize system prompt and prefix tool names.
250
264
  */
251
- export function rewriteRequestBody(body, onError) {
265
+ export function rewriteRequestBody(body) {
252
266
  try {
253
267
  const parsed = JSON.parse(body);
254
268
  // Sanitize system prompt and prepend Claude Code identity
255
- parsed.system = prependClaudeCodeIdentity(parsed.system, onError);
269
+ parsed.system = prependClaudeCodeIdentity(parsed.system);
256
270
  // Prefix tool names
257
271
  if (parsed.tools && Array.isArray(parsed.tools)) {
258
272
  parsed.tools = parsed.tools.map((tool) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ex-machina/opencode-anthropic-auth",
3
- "version": "1.2.0",
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
  }
@@ -1 +0,0 @@
1
- export {};
@@ -1,87 +0,0 @@
1
- import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
2
- import { authorize, exchange } from '../auth';
3
- import { CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES } from '../constants';
4
- afterEach(() => {
5
- mock.restore();
6
- });
7
- describe('authorize', () => {
8
- test('returns the hosted callback URL for max mode', async () => {
9
- const result = await authorize('max');
10
- expect(result.url).toBeString();
11
- expect(result.redirectUri).toBe(CODE_CALLBACK_URL);
12
- expect(result.verifier).toBeString();
13
- const url = new URL(result.url);
14
- expect(url.origin).toBe('https://claude.ai');
15
- expect(url.pathname).toBe('/oauth/authorize');
16
- expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
17
- });
18
- test('returns the hosted callback URL for console mode', async () => {
19
- const result = await authorize('console');
20
- const url = new URL(result.url);
21
- expect(url.origin).toBe('https://platform.claude.com');
22
- expect(url.pathname).toBe('/oauth/authorize');
23
- expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
24
- });
25
- test('sets required OAuth query params', async () => {
26
- const result = await authorize('max');
27
- const url = new URL(result.url);
28
- expect(url.searchParams.get('code')).toBe('true');
29
- expect(url.searchParams.get('client_id')).toBe(CLIENT_ID);
30
- expect(url.searchParams.get('response_type')).toBe('code');
31
- expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
32
- expect(url.searchParams.get('scope')).toBe(OAUTH_SCOPES.join(' '));
33
- expect(url.searchParams.get('code_challenge_method')).toBe('S256');
34
- expect(url.searchParams.get('state')).toBe(result.state);
35
- });
36
- test('does not use localhost', async () => {
37
- const result = await authorize('max');
38
- expect(result.redirectUri).not.toContain('localhost');
39
- expect(result.url).not.toContain('localhost');
40
- });
41
- });
42
- describe('exchange', () => {
43
- test('accepts code#state format', async () => {
44
- let capturedBody;
45
- spyOn(globalThis, 'fetch').mockImplementation(((_input, init) => {
46
- capturedBody = init?.body;
47
- return Promise.resolve(new Response(JSON.stringify({
48
- refresh_token: 'r',
49
- access_token: 'a',
50
- expires_in: 3600,
51
- }), { status: 200 }));
52
- }));
53
- const result = await exchange('mycode#mystate', 'myverifier', CODE_CALLBACK_URL, 'mystate');
54
- expect(result.type).toBe('success');
55
- const body = JSON.parse(capturedBody);
56
- expect(body.code).toBe('mycode');
57
- expect(body.state).toBe('mystate');
58
- expect(body.redirect_uri).toBe(CODE_CALLBACK_URL);
59
- });
60
- test('accepts a full callback URL', async () => {
61
- let capturedBody;
62
- spyOn(globalThis, 'fetch').mockImplementation(((_input, init) => {
63
- capturedBody = init?.body;
64
- return Promise.resolve(new Response(JSON.stringify({
65
- refresh_token: 'r',
66
- access_token: 'a',
67
- expires_in: 3600,
68
- }), { status: 200 }));
69
- }));
70
- await exchange('https://platform.claude.com/oauth/code/callback?code=mycode&state=mystate', 'myverifier', CODE_CALLBACK_URL, 'mystate');
71
- const body = JSON.parse(capturedBody);
72
- expect(body.code).toBe('mycode');
73
- expect(body.state).toBe('mystate');
74
- });
75
- test('returns failed on invalid callback input', async () => {
76
- const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => Promise.resolve(new Response(null))));
77
- const result = await exchange('not-a-callback', 'verifier', CODE_CALLBACK_URL);
78
- expect(result.type).toBe('failed');
79
- expect(fetchSpy).not.toHaveBeenCalled();
80
- });
81
- test('returns failed on state mismatch', async () => {
82
- const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => Promise.resolve(new Response(null))));
83
- const result = await exchange('code#wrong', 'verifier', CODE_CALLBACK_URL, 'expected');
84
- expect(result.type).toBe('failed');
85
- expect(fetchSpy).not.toHaveBeenCalled();
86
- });
87
- });
@@ -1 +0,0 @@
1
- export {};