@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 +38 -2
- package/dist/constants.d.ts +21 -0
- package/dist/constants.js +25 -0
- package/dist/index.js +2 -11
- package/dist/transform.d.ts +30 -0
- package/dist/transform.js +126 -1
- package/package.json +4 -1
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.
|
|
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.
|
|
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.
|
package/dist/constants.d.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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);
|
package/dist/transform.d.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|