opencode-claude-code-wrapper 0.0.1

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/index.mjs ADDED
@@ -0,0 +1,268 @@
1
+ import {
2
+ transformRequestToCLIArgs,
3
+ spawnClaudeCode,
4
+ generateId,
5
+ } from "./lib/cli-runner.mjs";
6
+ import {
7
+ JSONLToSSETransformer,
8
+ buildCompleteResponse,
9
+ } from "./lib/transformer.mjs";
10
+
11
+ /**
12
+ * Create an error response
13
+ * @param {Error|string} error - The error
14
+ * @param {number} statusCode - HTTP status code
15
+ * @returns {Response} Error response
16
+ */
17
+ function createErrorResponse(error, statusCode = 500) {
18
+ return new Response(
19
+ JSON.stringify({
20
+ type: "error",
21
+ error: {
22
+ type: "api_error",
23
+ message: error.message || String(error),
24
+ },
25
+ }),
26
+ {
27
+ status: statusCode,
28
+ headers: { "Content-Type": "application/json" },
29
+ }
30
+ );
31
+ }
32
+
33
+ /**
34
+ * 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
+ */
39
+ function createStreamingResponse(child, requestBody) {
40
+ const transformer = new JSONLToSSETransformer(requestBody);
41
+ const encoder = new TextEncoder();
42
+
43
+ let buffer = "";
44
+ let finalized = false;
45
+
46
+ const stream = new ReadableStream({
47
+ start(controller) {
48
+ child.stdout.on("data", (chunk) => {
49
+ buffer += chunk.toString();
50
+ const lines = buffer.split("\n");
51
+ buffer = lines.pop() || "";
52
+
53
+ for (const line of lines) {
54
+ const sseEvents = transformer.transformLine(line);
55
+ if (sseEvents) {
56
+ controller.enqueue(encoder.encode(sseEvents));
57
+ }
58
+ }
59
+ });
60
+
61
+ child.stderr.on("data", (chunk) => {
62
+ // Log stderr for debugging but don't fail
63
+ console.error("[claude-code-wrapper] stderr:", chunk.toString());
64
+ });
65
+
66
+ child.on("close", (code) => {
67
+ // Process any remaining buffer
68
+ if (buffer.trim()) {
69
+ const sseEvents = transformer.transformLine(buffer);
70
+ if (sseEvents) {
71
+ controller.enqueue(encoder.encode(sseEvents));
72
+ }
73
+ }
74
+
75
+ // Send final events if not already sent
76
+ if (!finalized) {
77
+ finalized = true;
78
+ const finalEvents = transformer.finalize();
79
+ controller.enqueue(encoder.encode(finalEvents));
80
+ }
81
+
82
+ controller.close();
83
+ });
84
+
85
+ child.on("error", (err) => {
86
+ console.error("[claude-code-wrapper] process error:", err);
87
+ controller.error(err);
88
+ });
89
+ },
90
+
91
+ cancel() {
92
+ child.kill();
93
+ },
94
+ });
95
+
96
+ return new Response(stream, {
97
+ status: 200,
98
+ headers: {
99
+ "Content-Type": "text/event-stream",
100
+ "Cache-Control": "no-cache",
101
+ Connection: "keep-alive",
102
+ },
103
+ });
104
+ }
105
+
106
+ /**
107
+ * 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
+ */
112
+ async function createNonStreamingResponse(child, requestBody) {
113
+ return new Promise((resolve, reject) => {
114
+ let stdout = "";
115
+ let stderr = "";
116
+
117
+ child.stdout.on("data", (chunk) => {
118
+ stdout += chunk.toString();
119
+ });
120
+
121
+ child.stderr.on("data", (chunk) => {
122
+ stderr += chunk.toString();
123
+ });
124
+
125
+ child.on("close", (code) => {
126
+ if (code !== 0 && !stdout.trim()) {
127
+ resolve(
128
+ new Response(
129
+ JSON.stringify({
130
+ type: "error",
131
+ error: {
132
+ type: "api_error",
133
+ message: `Claude Code exited with code ${code}: ${stderr}`,
134
+ },
135
+ }),
136
+ {
137
+ status: 500,
138
+ headers: { "Content-Type": "application/json" },
139
+ }
140
+ )
141
+ );
142
+ return;
143
+ }
144
+
145
+ const response = buildCompleteResponse(stdout, requestBody);
146
+ resolve(
147
+ new Response(JSON.stringify(response), {
148
+ status: 200,
149
+ headers: { "Content-Type": "application/json" },
150
+ })
151
+ );
152
+ });
153
+
154
+ child.on("error", (err) => {
155
+ resolve(createErrorResponse(err));
156
+ });
157
+ });
158
+ }
159
+
160
+ /**
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
165
+ */
166
+ async function handleClaudeCodeRequest(input, init) {
167
+ // Parse the incoming request URL
168
+ let requestUrl;
169
+ try {
170
+ if (typeof input === "string" || input instanceof URL) {
171
+ requestUrl = new URL(input.toString());
172
+ } else if (input instanceof Request) {
173
+ requestUrl = new URL(input.url);
174
+ }
175
+ } catch {
176
+ requestUrl = null;
177
+ }
178
+
179
+ // Only intercept messages endpoint
180
+ if (!requestUrl || !requestUrl.pathname.includes("/v1/messages")) {
181
+ return fetch(input, init);
182
+ }
183
+
184
+ // Parse request body
185
+ let requestBody;
186
+ try {
187
+ let bodyStr = "";
188
+ if (init?.body) {
189
+ bodyStr = init.body;
190
+ } else if (input instanceof Request) {
191
+ bodyStr = await input.text();
192
+ }
193
+ requestBody = JSON.parse(bodyStr);
194
+ } catch (e) {
195
+ return new Response(JSON.stringify({ error: "Invalid request body" }), {
196
+ status: 400,
197
+ headers: { "Content-Type": "application/json" },
198
+ });
199
+ }
200
+
201
+ // Check if streaming is requested
202
+ const isStreaming = requestBody.stream === true;
203
+
204
+ // Transform request to CLI args
205
+ const cliArgs = transformRequestToCLIArgs(requestBody);
206
+
207
+ // Spawn Claude Code process
208
+ const child = spawnClaudeCode(cliArgs, { streaming: isStreaming });
209
+
210
+ if (isStreaming) {
211
+ return createStreamingResponse(child, requestBody);
212
+ } else {
213
+ return createNonStreamingResponse(child, requestBody);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * OpenCode plugin that wraps Claude Code CLI
219
+ * @type {import('@opencode-ai/plugin').Plugin}
220
+ */
221
+ export async function ClaudeCodeWrapperPlugin({ client }) {
222
+ return {
223
+ auth: {
224
+ provider: "anthropic",
225
+ 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,
234
+ },
235
+ };
236
+ }
237
+
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
+ };
254
+ },
255
+ methods: [
256
+ {
257
+ label: "Claude Code CLI",
258
+ 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
261
+ },
262
+ ],
263
+ },
264
+ };
265
+ }
266
+
267
+ // Default export for convenience
268
+ export default ClaudeCodeWrapperPlugin;
@@ -0,0 +1,174 @@
1
+ import { spawn } from "child_process";
2
+
3
+ /**
4
+ * Model name mapping from Anthropic API model IDs to Claude Code CLI model aliases
5
+ */
6
+ const MODEL_MAP = {
7
+ "claude-sonnet-4-5-20250929": "sonnet",
8
+ "claude-opus-4-5-20251101": "opus",
9
+ "claude-3-5-sonnet-20241022": "sonnet",
10
+ "claude-3-5-haiku-20241022": "haiku",
11
+ "claude-3-opus-20240229": "opus",
12
+ "claude-3-sonnet-20240229": "sonnet",
13
+ "claude-3-haiku-20240307": "haiku",
14
+ };
15
+
16
+ /**
17
+ * Transform Anthropic API request body to Claude Code CLI arguments
18
+ * @param {object} requestBody - The API request body
19
+ * @returns {object} CLI arguments configuration
20
+ */
21
+ export function transformRequestToCLIArgs(requestBody) {
22
+ const args = {
23
+ prompt: "",
24
+ model: null,
25
+ systemPrompt: null,
26
+ };
27
+
28
+ // Map model name
29
+ if (requestBody.model) {
30
+ args.model = MODEL_MAP[requestBody.model] || requestBody.model;
31
+ }
32
+
33
+ // Extract system prompt
34
+ if (requestBody.system) {
35
+ if (typeof requestBody.system === "string") {
36
+ args.systemPrompt = requestBody.system;
37
+ } else if (Array.isArray(requestBody.system)) {
38
+ args.systemPrompt = requestBody.system
39
+ .filter((s) => s.type === "text")
40
+ .map((s) => s.text)
41
+ .join("\n");
42
+ }
43
+ }
44
+
45
+ // Build prompt from messages
46
+ if (requestBody.messages && Array.isArray(requestBody.messages)) {
47
+ args.prompt = formatMessages(requestBody.messages);
48
+ }
49
+
50
+ return args;
51
+ }
52
+
53
+ /**
54
+ * Format messages array into a prompt string for Claude Code
55
+ * @param {Array} messages - Array of message objects
56
+ * @returns {string} Formatted prompt
57
+ */
58
+ function formatMessages(messages) {
59
+ const parts = [];
60
+
61
+ for (const msg of messages) {
62
+ const role = msg.role === "user" ? "Human" : "Assistant";
63
+ let content = "";
64
+
65
+ if (typeof msg.content === "string") {
66
+ content = msg.content;
67
+ } else if (Array.isArray(msg.content)) {
68
+ const textParts = [];
69
+ const toolResults = [];
70
+
71
+ for (const block of msg.content) {
72
+ if (block.type === "text") {
73
+ textParts.push(block.text);
74
+ } else if (block.type === "tool_result") {
75
+ toolResults.push(
76
+ `[Tool Result for ${block.tool_use_id}]: ${typeof block.content === "string" ? block.content : JSON.stringify(block.content)}`
77
+ );
78
+ } else if (block.type === "tool_use") {
79
+ textParts.push(
80
+ `[Tool Call: ${block.name}]\nInput: ${JSON.stringify(block.input, null, 2)}`
81
+ );
82
+ }
83
+ }
84
+
85
+ if (toolResults.length > 0) {
86
+ content = toolResults.join("\n");
87
+ }
88
+ if (textParts.length > 0) {
89
+ content = (content ? content + "\n" : "") + textParts.join("\n");
90
+ }
91
+ }
92
+
93
+ if (content) {
94
+ parts.push(`${role}: ${content}`);
95
+ }
96
+ }
97
+
98
+ // Return only the last user message if it's a simple request,
99
+ // or the full conversation for context
100
+ if (parts.length === 1) {
101
+ return parts[0].replace(/^Human: /, "");
102
+ }
103
+
104
+ return parts.join("\n\n");
105
+ }
106
+
107
+ /**
108
+ * Spawn Claude Code CLI process
109
+ * @param {object} cliArgs - Arguments from transformRequestToCLIArgs
110
+ * @param {object} options - Additional options
111
+ * @param {boolean} options.streaming - Whether to enable streaming output
112
+ * @param {number} options.timeout - Timeout in milliseconds (default: 5 minutes)
113
+ * @returns {ChildProcess} Spawned child process
114
+ */
115
+ export function spawnClaudeCode(cliArgs, options = {}) {
116
+ const { streaming = true, timeout = 300000 } = options;
117
+
118
+ const args = [
119
+ "-p",
120
+ "--output-format",
121
+ "stream-json",
122
+ "--verbose", // Required for stream-json output
123
+ // Disable all MCP servers - Claude Code acts as pure API backend
124
+ "--strict-mcp-config",
125
+ "--mcp-config",
126
+ '{"mcpServers":{}}',
127
+ ];
128
+
129
+ if (cliArgs.model) {
130
+ args.push("--model", cliArgs.model);
131
+ }
132
+
133
+ if (cliArgs.systemPrompt) {
134
+ args.push("--system-prompt", cliArgs.systemPrompt);
135
+ }
136
+
137
+ // Add the prompt as the last argument
138
+ args.push(cliArgs.prompt);
139
+
140
+ const child = spawn("claude", args, {
141
+ stdio: ["pipe", "pipe", "pipe"],
142
+ env: { ...process.env },
143
+ });
144
+
145
+ // Close stdin immediately - Claude Code waits for stdin to close in -p mode
146
+ child.stdin.end();
147
+
148
+ // Set up timeout
149
+ if (timeout > 0) {
150
+ const timeoutId = setTimeout(() => {
151
+ child.kill("SIGTERM");
152
+ setTimeout(() => {
153
+ if (!child.killed) {
154
+ child.kill("SIGKILL");
155
+ }
156
+ }, 5000);
157
+ }, timeout);
158
+
159
+ child.on("close", () => clearTimeout(timeoutId));
160
+ }
161
+
162
+ return child;
163
+ }
164
+
165
+ /**
166
+ * Generate a random message ID
167
+ * @returns {string} Random ID
168
+ */
169
+ export function generateId() {
170
+ return (
171
+ Math.random().toString(36).substring(2, 15) +
172
+ Math.random().toString(36).substring(2, 15)
173
+ );
174
+ }
@@ -0,0 +1,381 @@
1
+ import { generateId } from "./cli-runner.mjs";
2
+
3
+ /**
4
+ * Transforms Claude Code JSONL output to Anthropic SSE streaming format
5
+ */
6
+ export class JSONLToSSETransformer {
7
+ constructor(requestBody) {
8
+ this.requestBody = requestBody;
9
+ this.messageId = `msg_${generateId()}`;
10
+ this.contentBlockIndex = 0;
11
+ this.hasStarted = false;
12
+ this.currentBlockStarted = false;
13
+ this.outputTokens = 0;
14
+ this.inputTokens = 0;
15
+ }
16
+
17
+ /**
18
+ * Create an SSE event string
19
+ * @param {string} eventType - The event type
20
+ * @param {object} data - The event data
21
+ * @returns {string} Formatted SSE event
22
+ */
23
+ createSSEEvent(eventType, data) {
24
+ return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
25
+ }
26
+
27
+ /**
28
+ * Transform a single JSONL line to SSE events
29
+ * @param {string} line - JSONL line
30
+ * @returns {string} SSE events string
31
+ */
32
+ transformLine(line) {
33
+ if (!line.trim()) return "";
34
+
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(line);
38
+ } catch (e) {
39
+ return "";
40
+ }
41
+
42
+ const events = [];
43
+
44
+ // Handle system init - emit message_start
45
+ if (parsed.type === "system" && parsed.subtype === "init") {
46
+ if (!this.hasStarted) {
47
+ events.push(this.createMessageStart());
48
+ this.hasStarted = true;
49
+ }
50
+ return events.join("");
51
+ }
52
+
53
+ // Handle assistant message events
54
+ if (parsed.type === "assistant") {
55
+ if (!this.hasStarted) {
56
+ events.push(this.createMessageStart());
57
+ this.hasStarted = true;
58
+ }
59
+
60
+ const msg = parsed.message;
61
+
62
+ if (msg && msg.content && Array.isArray(msg.content)) {
63
+ for (const block of msg.content) {
64
+ if (block.type === "text") {
65
+ // Start content block if needed
66
+ if (!this.currentBlockStarted) {
67
+ events.push(
68
+ this.createContentBlockStart(this.contentBlockIndex, "text")
69
+ );
70
+ this.currentBlockStarted = true;
71
+ }
72
+
73
+ // Send text delta
74
+ events.push(
75
+ this.createSSEEvent("content_block_delta", {
76
+ type: "content_block_delta",
77
+ index: this.contentBlockIndex,
78
+ delta: { type: "text_delta", text: block.text },
79
+ })
80
+ );
81
+
82
+ // Estimate tokens
83
+ this.outputTokens += Math.ceil(block.text.length / 4);
84
+ } else if (block.type === "tool_use") {
85
+ // Close current text block if open
86
+ if (this.currentBlockStarted) {
87
+ events.push(
88
+ this.createSSEEvent("content_block_stop", {
89
+ type: "content_block_stop",
90
+ index: this.contentBlockIndex,
91
+ })
92
+ );
93
+ this.contentBlockIndex++;
94
+ this.currentBlockStarted = false;
95
+ }
96
+
97
+ // Emit tool_use block
98
+ events.push(this.createToolUseBlock(block));
99
+ }
100
+ }
101
+ }
102
+
103
+ // Handle usage if present
104
+ if (msg && msg.usage) {
105
+ this.inputTokens = msg.usage.input_tokens || this.inputTokens;
106
+ this.outputTokens = msg.usage.output_tokens || this.outputTokens;
107
+ }
108
+
109
+ // Handle stop_reason
110
+ if (msg && msg.stop_reason) {
111
+ // Close any open content block
112
+ if (this.currentBlockStarted) {
113
+ events.push(
114
+ this.createSSEEvent("content_block_stop", {
115
+ type: "content_block_stop",
116
+ index: this.contentBlockIndex,
117
+ })
118
+ );
119
+ this.currentBlockStarted = false;
120
+ }
121
+
122
+ events.push(this.createMessageDelta(msg.stop_reason));
123
+ events.push(
124
+ this.createSSEEvent("message_stop", { type: "message_stop" })
125
+ );
126
+ }
127
+
128
+ return events.join("");
129
+ }
130
+
131
+ // Handle content streaming events
132
+ if (parsed.type === "content_block_delta") {
133
+ if (!this.hasStarted) {
134
+ events.push(this.createMessageStart());
135
+ this.hasStarted = true;
136
+ }
137
+
138
+ if (!this.currentBlockStarted) {
139
+ events.push(
140
+ this.createContentBlockStart(
141
+ parsed.index ?? this.contentBlockIndex,
142
+ "text"
143
+ )
144
+ );
145
+ this.currentBlockStarted = true;
146
+ }
147
+
148
+ events.push(this.createSSEEvent("content_block_delta", parsed));
149
+ return events.join("");
150
+ }
151
+
152
+ // Handle result events (end of response)
153
+ if (parsed.type === "result") {
154
+ if (!this.hasStarted) {
155
+ events.push(this.createMessageStart());
156
+ this.hasStarted = true;
157
+ }
158
+
159
+ // Close any open content block
160
+ if (this.currentBlockStarted) {
161
+ events.push(
162
+ this.createSSEEvent("content_block_stop", {
163
+ type: "content_block_stop",
164
+ index: this.contentBlockIndex,
165
+ })
166
+ );
167
+ this.currentBlockStarted = false;
168
+ }
169
+
170
+ // Update usage from result
171
+ if (parsed.usage) {
172
+ this.inputTokens = parsed.usage.input_tokens || this.inputTokens;
173
+ this.outputTokens = parsed.usage.output_tokens || this.outputTokens;
174
+ }
175
+
176
+ events.push(this.createMessageDelta(parsed.stop_reason || "end_turn"));
177
+ events.push(
178
+ this.createSSEEvent("message_stop", { type: "message_stop" })
179
+ );
180
+ return events.join("");
181
+ }
182
+
183
+ return "";
184
+ }
185
+
186
+ /**
187
+ * Create message_start SSE event
188
+ * @returns {string} SSE event string
189
+ */
190
+ createMessageStart() {
191
+ return this.createSSEEvent("message_start", {
192
+ type: "message_start",
193
+ message: {
194
+ id: this.messageId,
195
+ type: "message",
196
+ role: "assistant",
197
+ content: [],
198
+ model: this.requestBody.model || "claude-sonnet-4-5-20250929",
199
+ stop_reason: null,
200
+ stop_sequence: null,
201
+ usage: { input_tokens: 0, output_tokens: 0 },
202
+ },
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Create content_block_start SSE event
208
+ * @param {number} index - Content block index
209
+ * @param {string} type - Block type (text or tool_use)
210
+ * @returns {string} SSE event string
211
+ */
212
+ createContentBlockStart(index, type) {
213
+ return this.createSSEEvent("content_block_start", {
214
+ type: "content_block_start",
215
+ index: index,
216
+ content_block: type === "text" ? { type: "text", text: "" } : { type },
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Create tool_use content block events
222
+ * @param {object} toolBlock - Tool use block from Claude Code
223
+ * @returns {string} SSE events string
224
+ */
225
+ createToolUseBlock(toolBlock) {
226
+ const events = [];
227
+
228
+ // content_block_start for tool_use
229
+ events.push(
230
+ this.createSSEEvent("content_block_start", {
231
+ type: "content_block_start",
232
+ index: this.contentBlockIndex,
233
+ content_block: {
234
+ type: "tool_use",
235
+ id: toolBlock.id || `toolu_${generateId()}`,
236
+ name: toolBlock.name,
237
+ input: {},
238
+ },
239
+ })
240
+ );
241
+
242
+ // Stream the input as input_json_delta
243
+ const inputStr = JSON.stringify(toolBlock.input || {});
244
+ events.push(
245
+ this.createSSEEvent("content_block_delta", {
246
+ type: "content_block_delta",
247
+ index: this.contentBlockIndex,
248
+ delta: {
249
+ type: "input_json_delta",
250
+ partial_json: inputStr,
251
+ },
252
+ })
253
+ );
254
+
255
+ // content_block_stop
256
+ events.push(
257
+ this.createSSEEvent("content_block_stop", {
258
+ type: "content_block_stop",
259
+ index: this.contentBlockIndex,
260
+ })
261
+ );
262
+
263
+ this.contentBlockIndex++;
264
+ return events.join("");
265
+ }
266
+
267
+ /**
268
+ * Create message_delta SSE event
269
+ * @param {string} stopReason - Stop reason
270
+ * @returns {string} SSE event string
271
+ */
272
+ createMessageDelta(stopReason) {
273
+ return this.createSSEEvent("message_delta", {
274
+ type: "message_delta",
275
+ delta: {
276
+ stop_reason: stopReason || "end_turn",
277
+ stop_sequence: null,
278
+ },
279
+ usage: { output_tokens: this.outputTokens },
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Finalize the stream with closing events
285
+ * @returns {string} Final SSE events string
286
+ */
287
+ finalize() {
288
+ const events = [];
289
+
290
+ // Ensure message started
291
+ if (!this.hasStarted) {
292
+ events.push(this.createMessageStart());
293
+ }
294
+
295
+ // Close any open content block
296
+ if (this.currentBlockStarted) {
297
+ events.push(
298
+ this.createSSEEvent("content_block_stop", {
299
+ type: "content_block_stop",
300
+ index: this.contentBlockIndex,
301
+ })
302
+ );
303
+ }
304
+
305
+ events.push(this.createMessageDelta("end_turn"));
306
+ events.push(this.createSSEEvent("message_stop", { type: "message_stop" }));
307
+
308
+ return events.join("");
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Build a complete non-streaming response from JSONL output
314
+ * @param {string} jsonlOutput - Full JSONL output from Claude Code
315
+ * @param {object} requestBody - Original request body
316
+ * @returns {object} Anthropic Messages API response
317
+ */
318
+ export function buildCompleteResponse(jsonlOutput, requestBody) {
319
+ const lines = jsonlOutput.split("\n").filter((l) => l.trim());
320
+ const content = [];
321
+ let usage = { input_tokens: 0, output_tokens: 0 };
322
+ let stopReason = "end_turn";
323
+
324
+ for (const line of lines) {
325
+ try {
326
+ const parsed = JSON.parse(line);
327
+
328
+ if (parsed.type === "assistant" && parsed.message?.content) {
329
+ for (const block of parsed.message.content) {
330
+ if (block.type === "text") {
331
+ // Merge consecutive text blocks
332
+ const lastBlock = content[content.length - 1];
333
+ if (lastBlock && lastBlock.type === "text") {
334
+ lastBlock.text += block.text;
335
+ } else {
336
+ content.push({ type: "text", text: block.text });
337
+ }
338
+ } else if (block.type === "tool_use") {
339
+ content.push({
340
+ type: "tool_use",
341
+ id: block.id || `toolu_${generateId()}`,
342
+ name: block.name,
343
+ input: block.input || {},
344
+ });
345
+ }
346
+ }
347
+
348
+ if (parsed.message.usage) {
349
+ usage = parsed.message.usage;
350
+ }
351
+
352
+ if (parsed.message.stop_reason) {
353
+ stopReason = parsed.message.stop_reason;
354
+ }
355
+ }
356
+
357
+ // Handle result event
358
+ if (parsed.type === "result") {
359
+ if (parsed.usage) {
360
+ usage = parsed.usage;
361
+ }
362
+ if (parsed.stop_reason) {
363
+ stopReason = parsed.stop_reason;
364
+ }
365
+ }
366
+ } catch (e) {
367
+ // Skip malformed lines
368
+ }
369
+ }
370
+
371
+ return {
372
+ id: `msg_${generateId()}`,
373
+ type: "message",
374
+ role: "assistant",
375
+ content: content,
376
+ model: requestBody.model || "claude-sonnet-4-5-20250929",
377
+ stop_reason: stopReason,
378
+ stop_sequence: null,
379
+ usage: usage,
380
+ };
381
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "opencode-claude-code-wrapper",
3
+ "version": "0.0.1",
4
+ "description": "OpenCode plugin that wraps Claude Code CLI for API-like access",
5
+ "main": "./index.mjs",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "files": [
11
+ "index.mjs",
12
+ "lib/"
13
+ ],
14
+ "keywords": [
15
+ "opencode",
16
+ "claude",
17
+ "claude-code",
18
+ "anthropic",
19
+ "ai",
20
+ "plugin"
21
+ ],
22
+ "author": "Elad Ben Haim",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/elad12390/opencode-claude-code-cli-wrapper"
27
+ },
28
+ "devDependencies": {
29
+ "@opencode-ai/plugin": "^0.4.45"
30
+ }
31
+ }