opencode-claude-code-wrapper 0.0.1 → 0.0.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 (2) hide show
  1. package/index.mjs +327 -54
  2. package/package.json +4 -1
package/index.mjs CHANGED
@@ -1,18 +1,80 @@
1
+ import { generatePKCE } from "@openauthjs/openauth/pkce";
1
2
  import {
2
3
  transformRequestToCLIArgs,
3
4
  spawnClaudeCode,
4
- generateId,
5
5
  } from "./lib/cli-runner.mjs";
6
6
  import {
7
7
  JSONLToSSETransformer,
8
8
  buildCompleteResponse,
9
9
  } from "./lib/transformer.mjs";
10
10
 
11
+ const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
12
+
13
+ /**
14
+ * @param {"max" | "console"} mode
15
+ */
16
+ async function authorize(mode) {
17
+ const pkce = await generatePKCE();
18
+
19
+ const url = new URL(
20
+ `https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
21
+ import.meta.url
22
+ );
23
+ url.searchParams.set("code", "true");
24
+ url.searchParams.set("client_id", CLIENT_ID);
25
+ url.searchParams.set("response_type", "code");
26
+ url.searchParams.set(
27
+ "redirect_uri",
28
+ "https://console.anthropic.com/oauth/code/callback"
29
+ );
30
+ url.searchParams.set(
31
+ "scope",
32
+ "org:create_api_key user:profile user:inference"
33
+ );
34
+ url.searchParams.set("code_challenge", pkce.challenge);
35
+ url.searchParams.set("code_challenge_method", "S256");
36
+ url.searchParams.set("state", pkce.verifier);
37
+ return {
38
+ url: url.toString(),
39
+ verifier: pkce.verifier,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * @param {string} code
45
+ * @param {string} verifier
46
+ */
47
+ async function exchange(code, verifier) {
48
+ const splits = code.split("#");
49
+ const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
50
+ method: "POST",
51
+ headers: {
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify({
55
+ code: splits[0],
56
+ state: splits[1],
57
+ grant_type: "authorization_code",
58
+ client_id: CLIENT_ID,
59
+ redirect_uri: "https://console.anthropic.com/oauth/code/callback",
60
+ code_verifier: verifier,
61
+ }),
62
+ });
63
+ if (!result.ok)
64
+ return {
65
+ type: "failed",
66
+ };
67
+ const json = await result.json();
68
+ return {
69
+ type: "success",
70
+ refresh: json.refresh_token,
71
+ access: json.access_token,
72
+ expires: Date.now() + json.expires_in * 1000,
73
+ };
74
+ }
75
+
11
76
  /**
12
77
  * Create an error response
13
- * @param {Error|string} error - The error
14
- * @param {number} statusCode - HTTP status code
15
- * @returns {Response} Error response
16
78
  */
17
79
  function createErrorResponse(error, statusCode = 500) {
18
80
  return new Response(
@@ -32,9 +94,6 @@ function createErrorResponse(error, statusCode = 500) {
32
94
 
33
95
  /**
34
96
  * Create a streaming response from Claude Code process
35
- * @param {ChildProcess} child - Claude Code child process
36
- * @param {object} requestBody - Original request body
37
- * @returns {Response} Streaming response
38
97
  */
39
98
  function createStreamingResponse(child, requestBody) {
40
99
  const transformer = new JSONLToSSETransformer(requestBody);
@@ -59,12 +118,10 @@ function createStreamingResponse(child, requestBody) {
59
118
  });
60
119
 
61
120
  child.stderr.on("data", (chunk) => {
62
- // Log stderr for debugging but don't fail
63
121
  console.error("[claude-code-wrapper] stderr:", chunk.toString());
64
122
  });
65
123
 
66
124
  child.on("close", (code) => {
67
- // Process any remaining buffer
68
125
  if (buffer.trim()) {
69
126
  const sseEvents = transformer.transformLine(buffer);
70
127
  if (sseEvents) {
@@ -72,7 +129,6 @@ function createStreamingResponse(child, requestBody) {
72
129
  }
73
130
  }
74
131
 
75
- // Send final events if not already sent
76
132
  if (!finalized) {
77
133
  finalized = true;
78
134
  const finalEvents = transformer.finalize();
@@ -105,12 +161,9 @@ function createStreamingResponse(child, requestBody) {
105
161
 
106
162
  /**
107
163
  * Create a non-streaming response from Claude Code process
108
- * @param {ChildProcess} child - Claude Code child process
109
- * @param {object} requestBody - Original request body
110
- * @returns {Promise<Response>} Complete response
111
164
  */
112
165
  async function createNonStreamingResponse(child, requestBody) {
113
- return new Promise((resolve, reject) => {
166
+ return new Promise((resolve) => {
114
167
  let stdout = "";
115
168
  let stderr = "";
116
169
 
@@ -158,13 +211,9 @@ async function createNonStreamingResponse(child, requestBody) {
158
211
  }
159
212
 
160
213
  /**
161
- * Handle incoming request and route to Claude Code CLI
162
- * @param {Request|string} input - Fetch input
163
- * @param {RequestInit} init - Fetch init options
164
- * @returns {Promise<Response>} Response
214
+ * Handle request via Claude Code CLI
165
215
  */
166
216
  async function handleClaudeCodeRequest(input, init) {
167
- // Parse the incoming request URL
168
217
  let requestUrl;
169
218
  try {
170
219
  if (typeof input === "string" || input instanceof URL) {
@@ -176,12 +225,10 @@ async function handleClaudeCodeRequest(input, init) {
176
225
  requestUrl = null;
177
226
  }
178
227
 
179
- // Only intercept messages endpoint
180
228
  if (!requestUrl || !requestUrl.pathname.includes("/v1/messages")) {
181
229
  return fetch(input, init);
182
230
  }
183
231
 
184
- // Parse request body
185
232
  let requestBody;
186
233
  try {
187
234
  let bodyStr = "";
@@ -198,13 +245,8 @@ async function handleClaudeCodeRequest(input, init) {
198
245
  });
199
246
  }
200
247
 
201
- // Check if streaming is requested
202
248
  const isStreaming = requestBody.stream === true;
203
-
204
- // Transform request to CLI args
205
249
  const cliArgs = transformRequestToCLIArgs(requestBody);
206
-
207
- // Spawn Claude Code process
208
250
  const child = spawnClaudeCode(cliArgs, { streaming: isStreaming });
209
251
 
210
252
  if (isStreaming) {
@@ -215,7 +257,7 @@ async function handleClaudeCodeRequest(input, init) {
215
257
  }
216
258
 
217
259
  /**
218
- * OpenCode plugin that wraps Claude Code CLI
260
+ * OpenCode plugin that wraps Claude Code CLI with full auth options
219
261
  * @type {import('@opencode-ai/plugin').Plugin}
220
262
  */
221
263
  export async function ClaudeCodeWrapperPlugin({ client }) {
@@ -223,46 +265,277 @@ export async function ClaudeCodeWrapperPlugin({ client }) {
223
265
  auth: {
224
266
  provider: "anthropic",
225
267
  async loader(getAuth, provider) {
226
- // Zero out costs - Claude Code handles its own billing
227
- for (const model of Object.values(provider.models)) {
228
- model.cost = {
229
- input: 0,
230
- output: 0,
231
- cache: {
232
- read: 0,
233
- write: 0,
268
+ const auth = await getAuth();
269
+
270
+ // Claude Code CLI auth - zero cost, use CLI
271
+ // Detected by special key value set during authorize
272
+ if (auth.type === "api" && auth.key === "claude-code-cli") {
273
+ for (const model of Object.values(provider.models)) {
274
+ model.cost = {
275
+ input: 0,
276
+ output: 0,
277
+ cache: { read: 0, write: 0 },
278
+ };
279
+ }
280
+
281
+ return {
282
+ apiKey: "",
283
+ async fetch(input, init) {
284
+ try {
285
+ return await handleClaudeCodeRequest(input, init);
286
+ } catch (error) {
287
+ console.error("[claude-code-wrapper] error:", error);
288
+ return createErrorResponse(error);
289
+ }
234
290
  },
235
291
  };
236
292
  }
237
293
 
238
- return {
239
- apiKey: "", // Not needed - Claude Code handles auth
240
- /**
241
- * Custom fetch that routes to Claude Code CLI
242
- * @param {any} input - Fetch input
243
- * @param {any} init - Fetch init options
244
- */
245
- async fetch(input, init) {
246
- try {
247
- return await handleClaudeCodeRequest(input, init);
248
- } catch (error) {
249
- console.error("[claude-code-wrapper] error:", error);
250
- return createErrorResponse(error);
251
- }
252
- },
253
- };
294
+ // OAuth auth (Claude Pro/Max)
295
+ if (auth.type === "oauth") {
296
+ for (const model of Object.values(provider.models)) {
297
+ model.cost = {
298
+ input: 0,
299
+ output: 0,
300
+ cache: { read: 0, write: 0 },
301
+ };
302
+ }
303
+
304
+ return {
305
+ apiKey: "",
306
+ async fetch(input, init) {
307
+ const auth = await getAuth();
308
+ if (auth.type !== "oauth") return fetch(input, init);
309
+
310
+ // Refresh token if expired
311
+ if (!auth.access || auth.expires < Date.now()) {
312
+ const response = await fetch(
313
+ "https://console.anthropic.com/v1/oauth/token",
314
+ {
315
+ method: "POST",
316
+ headers: { "Content-Type": "application/json" },
317
+ body: JSON.stringify({
318
+ grant_type: "refresh_token",
319
+ refresh_token: auth.refresh,
320
+ client_id: CLIENT_ID,
321
+ }),
322
+ }
323
+ );
324
+ if (!response.ok) {
325
+ throw new Error(`Token refresh failed: ${response.status}`);
326
+ }
327
+ const json = await response.json();
328
+ await client.auth.set({
329
+ path: { id: "anthropic" },
330
+ body: {
331
+ type: "oauth",
332
+ refresh: json.refresh_token,
333
+ access: json.access_token,
334
+ expires: Date.now() + json.expires_in * 1000,
335
+ },
336
+ });
337
+ auth.access = json.access_token;
338
+ }
339
+
340
+ const requestInit = init ?? {};
341
+ const requestHeaders = new Headers();
342
+
343
+ if (input instanceof Request) {
344
+ input.headers.forEach((value, key) => {
345
+ requestHeaders.set(key, value);
346
+ });
347
+ }
348
+ if (requestInit.headers) {
349
+ if (requestInit.headers instanceof Headers) {
350
+ requestInit.headers.forEach((value, key) => {
351
+ requestHeaders.set(key, value);
352
+ });
353
+ } else if (Array.isArray(requestInit.headers)) {
354
+ for (const [key, value] of requestInit.headers) {
355
+ if (typeof value !== "undefined") {
356
+ requestHeaders.set(key, String(value));
357
+ }
358
+ }
359
+ } else {
360
+ for (const [key, value] of Object.entries(
361
+ requestInit.headers
362
+ )) {
363
+ if (typeof value !== "undefined") {
364
+ requestHeaders.set(key, String(value));
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ const incomingBeta = requestHeaders.get("anthropic-beta") || "";
371
+ const incomingBetasList = incomingBeta
372
+ .split(",")
373
+ .map((b) => b.trim())
374
+ .filter(Boolean);
375
+
376
+ const includeClaudeCode = incomingBetasList.includes(
377
+ "claude-code-20250219"
378
+ );
379
+
380
+ const mergedBetas = [
381
+ "oauth-2025-04-20",
382
+ "interleaved-thinking-2025-05-14",
383
+ ...(includeClaudeCode ? ["claude-code-20250219"] : []),
384
+ ].join(",");
385
+
386
+ requestHeaders.set("authorization", `Bearer ${auth.access}`);
387
+ requestHeaders.set("anthropic-beta", mergedBetas);
388
+ requestHeaders.set(
389
+ "user-agent",
390
+ "claude-cli/2.1.2 (external, cli)"
391
+ );
392
+ requestHeaders.delete("x-api-key");
393
+
394
+ const TOOL_PREFIX = "oc_";
395
+ let body = requestInit.body;
396
+ if (body && typeof body === "string") {
397
+ try {
398
+ const parsed = JSON.parse(body);
399
+ if (parsed.tools && Array.isArray(parsed.tools)) {
400
+ parsed.tools = parsed.tools.map((tool) => ({
401
+ ...tool,
402
+ name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
403
+ }));
404
+ body = JSON.stringify(parsed);
405
+ }
406
+ } catch (e) {
407
+ // ignore parse errors
408
+ }
409
+ }
410
+
411
+ let requestInput = input;
412
+ let requestUrl = null;
413
+ try {
414
+ if (typeof input === "string" || input instanceof URL) {
415
+ requestUrl = new URL(input.toString());
416
+ } else if (input instanceof Request) {
417
+ requestUrl = new URL(input.url);
418
+ }
419
+ } catch {
420
+ requestUrl = null;
421
+ }
422
+
423
+ if (
424
+ requestUrl &&
425
+ requestUrl.pathname === "/v1/messages" &&
426
+ !requestUrl.searchParams.has("beta")
427
+ ) {
428
+ requestUrl.searchParams.set("beta", "true");
429
+ requestInput =
430
+ input instanceof Request
431
+ ? new Request(requestUrl.toString(), input)
432
+ : requestUrl;
433
+ }
434
+
435
+ const response = await fetch(requestInput, {
436
+ ...requestInit,
437
+ body,
438
+ headers: requestHeaders,
439
+ });
440
+
441
+ // Transform streaming response to rename tools back
442
+ if (response.body) {
443
+ const reader = response.body.getReader();
444
+ const decoder = new TextDecoder();
445
+ const encoder = new TextEncoder();
446
+
447
+ const stream = new ReadableStream({
448
+ async pull(controller) {
449
+ const { done, value } = await reader.read();
450
+ if (done) {
451
+ controller.close();
452
+ return;
453
+ }
454
+
455
+ let text = decoder.decode(value, { stream: true });
456
+ text = text.replace(
457
+ /"name"\s*:\s*"oc_([^"]+)"/g,
458
+ '"name": "$1"'
459
+ );
460
+ controller.enqueue(encoder.encode(text));
461
+ },
462
+ });
463
+
464
+ return new Response(stream, {
465
+ status: response.status,
466
+ statusText: response.statusText,
467
+ headers: response.headers,
468
+ });
469
+ }
470
+
471
+ return response;
472
+ },
473
+ };
474
+ }
475
+
476
+ // Default - no special handling
477
+ return {};
254
478
  },
255
479
  methods: [
256
480
  {
257
481
  label: "Claude Code CLI",
258
482
  type: "api",
259
- // Simple passthrough - Claude Code handles auth via its own config
260
- // Users should run 'claude setup-token' or set ANTHROPIC_API_KEY
483
+ authorize: async () => {
484
+ // Return immediately with special key marker
485
+ return { type: "success", key: "claude-code-cli" };
486
+ },
487
+ },
488
+ {
489
+ label: "Claude Pro/Max",
490
+ type: "oauth",
491
+ authorize: async () => {
492
+ const { url, verifier } = await authorize("max");
493
+ return {
494
+ url: url,
495
+ instructions: "Paste the authorization code here: ",
496
+ method: "code",
497
+ callback: async (code) => {
498
+ const credentials = await exchange(code, verifier);
499
+ return credentials;
500
+ },
501
+ };
502
+ },
503
+ },
504
+ {
505
+ label: "Create an API Key",
506
+ type: "oauth",
507
+ authorize: async () => {
508
+ const { url, verifier } = await authorize("console");
509
+ return {
510
+ url: url,
511
+ instructions: "Paste the authorization code here: ",
512
+ method: "code",
513
+ callback: async (code) => {
514
+ const credentials = await exchange(code, verifier);
515
+ if (credentials.type === "failed") return credentials;
516
+ const result = await fetch(
517
+ `https://api.anthropic.com/api/oauth/claude_cli/create_api_key`,
518
+ {
519
+ method: "POST",
520
+ headers: {
521
+ "Content-Type": "application/json",
522
+ authorization: `Bearer ${credentials.access}`,
523
+ },
524
+ }
525
+ ).then((r) => r.json());
526
+ return { type: "success", key: result.raw_key };
527
+ },
528
+ };
529
+ },
530
+ },
531
+ {
532
+ provider: "anthropic",
533
+ label: "Manually enter API Key",
534
+ type: "api",
261
535
  },
262
536
  ],
263
537
  },
264
538
  };
265
539
  }
266
540
 
267
- // Default export for convenience
268
541
  export default ClaudeCodeWrapperPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-code-wrapper",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "OpenCode plugin that wraps Claude Code CLI for API-like access",
5
5
  "main": "./index.mjs",
6
6
  "type": "module",
@@ -25,6 +25,9 @@
25
25
  "type": "git",
26
26
  "url": "https://github.com/elad12390/opencode-claude-code-cli-wrapper"
27
27
  },
28
+ "dependencies": {
29
+ "@openauthjs/openauth": "^0.4.3"
30
+ },
28
31
  "devDependencies": {
29
32
  "@opencode-ai/plugin": "^0.4.45"
30
33
  }