opencode-claude-code-wrapper 0.0.5 → 0.0.7

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 CHANGED
@@ -2,14 +2,11 @@ import { generatePKCE } from "@openauthjs/openauth/pkce";
2
2
  import { writeFileSync, appendFileSync, mkdirSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
- import {
6
- transformRequestToCLIArgs,
7
- spawnClaudeCode,
8
- } from "./lib/cli-runner.mjs";
9
- import {
10
- JSONLToSSETransformer,
11
- buildCompleteResponse,
12
- } from "./lib/transformer.mjs";
5
+ import * as fs from "fs";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import { AcpClient } from "./lib/acp-protocol.mjs";
9
+ import { AcpToSSETransformer } from "./lib/acp-transformer.mjs";
13
10
 
14
11
  const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
15
12
 
@@ -280,6 +277,13 @@ async function handleClaudeCodeRequest(input, init) {
280
277
 
281
278
  // Log full request body to separate file for detailed inspection
282
279
  try {
280
+ // Save requests with tools to a separate file
281
+ if (requestBody.tools?.length > 0) {
282
+ writeFileSync(
283
+ join(METRICS_DIR, "last_request_with_tools.json"),
284
+ JSON.stringify(requestBody, null, 2)
285
+ );
286
+ }
283
287
  writeFileSync(
284
288
  join(METRICS_DIR, "last_request.json"),
285
289
  JSON.stringify(requestBody, null, 2)
@@ -298,33 +302,347 @@ async function handleClaudeCodeRequest(input, init) {
298
302
  }
299
303
 
300
304
  /**
301
- * OpenCode plugin that wraps Claude Code CLI with full auth options
305
+ * OpenCode plugin that wraps Claude Code via ACP protocol
302
306
  * @type {import('@opencode-ai/plugin').Plugin}
303
307
  */
304
308
  export async function ClaudeCodeWrapperPlugin({ client }) {
309
+ // Store ACP client instances per model
310
+ const acpClients = new Map();
311
+ const transformers = new Map();
312
+
305
313
  return {
306
314
  auth: {
307
315
  provider: "anthropic",
308
316
  async loader(getAuth, provider) {
309
317
  const auth = await getAuth();
310
318
 
311
- // Claude Code CLI auth - zero cost, use CLI
312
- // Detected by special key value: user enters "cli" or "claude-code" as API key
313
- const isClaudeCodeCLI =
314
- auth.type === "api" &&
315
- (auth.key === "claude-code-cli" ||
316
- auth.key === "cli" ||
317
- auth.key === "claude-code" ||
318
- auth.key === "cc");
319
- if (isClaudeCodeCLI) {
320
- for (const model of Object.values(provider.models)) {
321
- model.cost = {
322
- input: 0,
323
- output: 0,
324
- cache: { read: 0, write: 0 },
319
+ // Claude Code ACP agent handles auth internally
320
+ // We just need to provide zero-cost wrapper
321
+ for (const model of Object.values(provider.models)) {
322
+ model.cost = {
323
+ input: 0,
324
+ output: 0,
325
+ cache: { read: 0, write: 0 },
326
+ };
327
+ }
328
+
329
+ return {
330
+ apiKey: auth.key || "",
331
+ async fetch(input, init) {
332
+ const auth = await getAuth();
333
+
334
+ // Claude Code CLI - detected by special key
335
+ const isClaudeCodeCLI = auth.key === "claude-code" || auth.key === "cc";
336
+
337
+ if (isClaudeCodeCLI) {
338
+ try {
339
+ return await handleAcpRequest(input, init, auth);
340
+ } catch (error) {
341
+ console.error("[claude-code-acp] error:", error);
342
+ return createErrorResponse(error);
343
+ }
344
+ }
345
+
346
+ // OAuth auth (Claude Pro/Max) - pass through
347
+ if (auth.type === "oauth") {
348
+ return fetch(input, init);
349
+ }
350
+
351
+ // Default - no special handling
352
+ return fetch(input, init);
353
+ },
354
+ };
355
+ },
356
+ methods: [
357
+ {
358
+ label: "Claude Code (ACP)",
359
+ type: "api",
360
+ authorize: async () => {
361
+ return {
362
+ url: "https://console.anthropic.com/oauth/authorize",
363
+ instructions: "Use the ACP agent to authenticate via Claude Code",
364
+ method: "external",
365
+ };
366
+ },
367
+ },
368
+ {
369
+ label: "Claude Pro/Max (OAuth)",
370
+ type: "oauth",
371
+ authorize: async () => {
372
+ const { url, verifier } = await authorize("max");
373
+ return {
374
+ url: url,
375
+ instructions: "Paste authorization code here: ",
376
+ method: "code",
377
+ callback: async (code) => {
378
+ const credentials = await exchange(code, verifier);
379
+ return credentials;
380
+ },
325
381
  };
382
+ },
383
+ },
384
+ {
385
+ label: "Create an API Key",
386
+ type: "oauth",
387
+ authorize: async () => {
388
+ const { url, verifier } = await authorize("console");
389
+ return {
390
+ url: url,
391
+ instructions: "Paste authorization code here: ",
392
+ method: "code",
393
+ callback: async (code) => {
394
+ const credentials = await exchange(code, verifier);
395
+ if (credentials.type === "failed") return credentials;
396
+ const result = await fetch(
397
+ `https://api.anthropic.com/oauth/claude_cli/create_api_key`,
398
+ {
399
+ method: "POST",
400
+ headers: {
401
+ "Content-Type": "application/json",
402
+ authorization: `Bearer ${credentials.access}`,
403
+ },
404
+ }
405
+ ).then((r) => r.json());
406
+ return { type: "success", key: result.raw_key };
407
+ },
408
+ };
409
+ },
410
+ },
411
+ {
412
+ provider: "anthropic",
413
+ label: "Manually enter API Key",
414
+ type: "api",
415
+ },
416
+ ],
417
+ },
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Handle request via ACP agent (claude-code-acp)
423
+ */
424
+ async function handleAcpRequest(input, init, auth) {
425
+ // Get or create ACP client for this request
426
+ let requestBody;
427
+ try {
428
+ if (typeof input === "string" || input instanceof URL) {
429
+ const url = new URL(input.toString());
430
+ // Only handle /v1/messages endpoint via ACP
431
+ if (url.pathname !== "/v1/messages") {
432
+ return fetch(input, init);
433
+ }
434
+ requestBody = init?.body ? JSON.parse(init.body) : {};
435
+ } else if (input instanceof Request) {
436
+ const url = new URL(input.url);
437
+ if (url.pathname !== "/v1/messages") {
438
+ return fetch(input, init);
439
+ }
440
+ requestBody = init?.body ? JSON.parse(init.body) : await input.text();
441
+ } else {
442
+ return fetch(input, init);
443
+ }
444
+ } catch (e) {
445
+ return createErrorResponse(new Error("Invalid request"));
446
+ }
447
+
448
+ // Log incoming request
449
+ logMetric("request", {
450
+ model: requestBody.model,
451
+ stream: requestBody.stream,
452
+ system: requestBody.system ? (typeof requestBody.system === "string" ? requestBody.system.substring(0, 200) : "[array]") : null,
453
+ messages_count: requestBody.messages?.length,
454
+ tools_count: requestBody.tools?.length,
455
+ });
456
+
457
+ try {
458
+ // Get ACP client and transformer
459
+ const model = requestBody.model || "claude-sonnet-4-5-20250929";
460
+ let acpClient = acpClients.get(model);
461
+ let transformer = transformers.get(model);
462
+
463
+ if (!acpClient) {
464
+ acpClient = new AcpClient();
465
+ acpClients.set(model, acpClient);
466
+ await acpClient.connect();
467
+ }
468
+
469
+ if (!transformer) {
470
+ transformer = new AcpToSSETransformer();
471
+ transformers.set(model, transformer);
472
+ }
473
+
474
+ // Convert API request to ACP format
475
+ const resources = extractResources(requestBody.messages || []);
476
+ const text = extractUserText(requestBody.messages || []);
477
+
478
+ // Create new session if needed
479
+ if (!acpClient.sessionId) {
480
+ await acpClient.newSession({
481
+ mcpServers: extractMcpServers(auth),
482
+ });
483
+ }
484
+
485
+ const isStreaming = requestBody.stream === true;
486
+
487
+ // Send prompt to ACP
488
+ const response = await acpClient.prompt(text, resources);
489
+
490
+ if (isStreaming && response.body) {
491
+ // Transform streaming ACP responses to SSE
492
+ const reader = response.body.getReader();
493
+ const decoder = new TextDecoder();
494
+ const encoder = new TextEncoder();
495
+
496
+ const stream = new ReadableStream({
497
+ async pull(controller) {
498
+ const { done, value } = await reader.read();
499
+ if (done) {
500
+ // Finalize transformer
501
+ const finalEvents = transformer.finalize();
502
+ controller.enqueue(encoder.encode(finalEvents));
503
+ controller.close();
504
+ return;
326
505
  }
327
506
 
507
+ const text = decoder.decode(value, { stream: true });
508
+ // Parse ndjson from ACP
509
+ const lines = text.split("\n").filter((line) => line.trim());
510
+
511
+ for (const line of lines) {
512
+ try {
513
+ const message = JSON.parse(line);
514
+ // Transform ACP message to SSE
515
+ const sseEvents = transformer.transformMessage(message);
516
+ if (sseEvents) {
517
+ controller.enqueue(encoder.encode(sseEvents));
518
+ }
519
+ } catch (e) {
520
+ // Skip malformed lines
521
+ }
522
+ }
523
+ },
524
+ });
525
+
526
+ return new Response(stream, {
527
+ status: 200,
528
+ headers: {
529
+ "Content-Type": "text/event-stream",
530
+ "Cache-Control": "no-cache",
531
+ "Connection": "keep-alive",
532
+ },
533
+ });
534
+ } else {
535
+ return response;
536
+ }
537
+ } catch (error) {
538
+ console.error("[claude-code-acp] error:", error);
539
+ return createErrorResponse(error);
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Extract resources (files, images) from messages
545
+ */
546
+ function extractResources(messages) {
547
+ const resources = [];
548
+
549
+ for (const message of messages) {
550
+ if (message.role === "user" && Array.isArray(message.content)) {
551
+ for (const block of message.content) {
552
+ if (block.type === "image") {
553
+ resources.push({
554
+ type: "image",
555
+ data: block.source.type === "base64" ? block.source.data : "",
556
+ mimeType: block.source.type === "base64" ? block.source.media_type : "",
557
+ uri: block.source.type === "url" ? block.source.url : undefined,
558
+ });
559
+ } else if (block.type === "resource_link") {
560
+ resources.push({
561
+ type: "resource",
562
+ uri: block.uri,
563
+ });
564
+ } else if (block.type === "resource" && block.resource?.uri && block.resource?.text) {
565
+ resources.push({
566
+ type: "resource",
567
+ uri: block.resource.uri,
568
+ text: block.resource.text,
569
+ });
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ return resources;
576
+ }
577
+
578
+ /**
579
+ * Extract user text from messages
580
+ */
581
+ function extractUserText(messages) {
582
+ for (let i = messages.length - 1; i >= 0; i--) {
583
+ const message = messages[i];
584
+ if (message.role === "user") {
585
+ if (typeof message.content === "string") {
586
+ return message.content;
587
+ }
588
+ if (Array.isArray(message.content)) {
589
+ return message.content
590
+ .filter((block) => block.type === "text")
591
+ .map((block) => block.text)
592
+ .join("\n");
593
+ }
594
+ }
595
+ }
596
+ return "";
597
+ }
598
+
599
+ /**
600
+ * Extract MCP servers from auth
601
+ */
602
+ function extractMcpServers(auth) {
603
+ // Extract any MCP servers from settings
604
+ // This could be extended to support custom MCP configs
605
+ return [];
606
+ }
607
+
608
+ /**
609
+ * Create an error response
610
+ */
611
+ function createErrorResponse(error, statusCode = 500) {
612
+ return new Response(
613
+ JSON.stringify({
614
+ type: "error",
615
+ error: {
616
+ type: "api_error",
617
+ message: error.message || String(error),
618
+ },
619
+ }),
620
+ {
621
+ status: statusCode,
622
+ headers: { "Content-Type": "application/json" },
623
+ }
624
+ );
625
+ }
626
+
627
+ /**
628
+ * Log metrics
629
+ */
630
+ function logMetric(type, data) {
631
+ try {
632
+ const METRICS_DIR = process.env.HOME ? join(homedir(), ".opencode-claude-code-wrapper") : "/tmp";
633
+ const METRICS_FILE = join(METRICS_DIR, "metrics.jsonl");
634
+ fs.mkdirSync(METRICS_DIR, { recursive: true });
635
+ const entry = {
636
+ timestamp: new Date().toISOString(),
637
+ type,
638
+ ...data,
639
+ };
640
+ fs.appendFileSync(METRICS_FILE, JSON.stringify(entry) + "\n");
641
+ } catch (e) {
642
+ console.error("[metrics] Failed to log:", e.message);
643
+ }
644
+ }
645
+
328
646
  return {
329
647
  apiKey: "",
330
648
  async fetch(input, init) {
@@ -0,0 +1,23 @@
1
+ import { spawn } from "child_process";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ /**
5
+ * Spawn ACP agent (claude-code-acp) binary
6
+ * @returns {ChildProcess} Spawned ACP process
7
+ */
8
+ export function spawnAcpAgent() {
9
+ const child = spawn("claude-code-acp", [], {
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ env: { ...process.env },
12
+ });
13
+
14
+ return child;
15
+ }
16
+
17
+ /**
18
+ * Create a random message ID
19
+ * @returns {string} Random ID
20
+ */
21
+ export function generateId() {
22
+ return randomUUID();
23
+ }
@@ -0,0 +1,292 @@
1
+ import { spawnAcpAgent, generateId } from "./acp-client.mjs";
2
+
3
+ /**
4
+ * ACP protocol client
5
+ * Communicates with claude-code-acp via ndjson (newline-delimited JSON)
6
+ */
7
+ export class AcpClient {
8
+ constructor() {
9
+ this.child = null;
10
+ this.sessionId = null;
11
+ this.handlers = new Map();
12
+ this.pendingRequests = new Map();
13
+ }
14
+
15
+ /**
16
+ * Connect to ACP agent
17
+ */
18
+ async connect() {
19
+ if (this.child) {
20
+ return;
21
+ }
22
+
23
+ this.child = spawnAcpAgent();
24
+
25
+ // Start reading responses
26
+ this.child.stdout.setEncoding("utf8");
27
+ this.child.stdout.on("data", (data) => {
28
+ this.handleResponse(data.toString());
29
+ });
30
+
31
+ this.child.stderr.on("data", (data) => {
32
+ console.error("[ACP] stderr:", data.toString());
33
+ });
34
+
35
+ this.child.on("error", (err) => {
36
+ console.error("[ACP] Process error:", err);
37
+ });
38
+
39
+ this.child.on("close", (code) => {
40
+ console.log("[ACP] Process closed with code:", code);
41
+ });
42
+
43
+ // Send initialize request
44
+ await this.sendRequest({
45
+ type: "initialize",
46
+ clientCapabilities: {
47
+ fs: {
48
+ readTextFile: true,
49
+ writeTextFile: true,
50
+ },
51
+ terminal: true,
52
+ },
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Create a new session
58
+ * @param {object} options - Session options
59
+ */
60
+ async newSession(options = {}) {
61
+ const requestId = generateId();
62
+
63
+ const request = {
64
+ type: "newSession",
65
+ sessionId: null,
66
+ cwd: process.cwd(),
67
+ mcpServers: options.mcpServers || [],
68
+ _meta: options._meta,
69
+ };
70
+
71
+ const response = await this.sendRequest(request, requestId);
72
+ this.sessionId = response.sessionId;
73
+ return response;
74
+ }
75
+
76
+ /**
77
+ * Send a prompt to the session
78
+ * @param {string} text - User message text
79
+ * @param {Array} resources - Context resources (files, images)
80
+ */
81
+ async prompt(text, resources = []) {
82
+ if (!this.sessionId) {
83
+ throw new Error("No active session. Call newSession() first.");
84
+ }
85
+
86
+ const requestId = generateId();
87
+ const prompt = [];
88
+
89
+ // Add text chunks
90
+ if (text) {
91
+ prompt.push({ type: "text", text });
92
+ }
93
+
94
+ // Add resource chunks
95
+ for (const resource of resources) {
96
+ prompt.push(resource);
97
+ }
98
+
99
+ const request = {
100
+ type: "prompt",
101
+ sessionId: this.sessionId,
102
+ prompt,
103
+ };
104
+
105
+ return this.sendRequest(request, requestId);
106
+ }
107
+
108
+ /**
109
+ * Cancel current prompt
110
+ */
111
+ async cancel() {
112
+ if (!this.sessionId) {
113
+ return;
114
+ }
115
+
116
+ const requestId = generateId();
117
+ await this.sendRequest({
118
+ type: "cancel",
119
+ sessionId: this.sessionId,
120
+ }, requestId);
121
+ }
122
+
123
+ /**
124
+ * Set session mode
125
+ * @param {string} mode - Mode to set
126
+ */
127
+ async setMode(mode) {
128
+ if (!this.sessionId) {
129
+ return;
130
+ }
131
+
132
+ const requestId = generateId();
133
+ await this.sendRequest({
134
+ type: "setSessionMode",
135
+ sessionId: this.sessionId,
136
+ modeId: mode,
137
+ }, requestId);
138
+ }
139
+
140
+ /**
141
+ * Kill the ACP process
142
+ */
143
+ kill() {
144
+ if (this.child) {
145
+ this.child.kill();
146
+ this.child = null;
147
+ }
148
+ this.sessionId = null;
149
+ }
150
+
151
+ /**
152
+ * Send a request to ACP agent
153
+ * @param {object} request - Request object
154
+ * @param {string} requestId - Request ID for response matching
155
+ */
156
+ async sendRequest(request, requestId) {
157
+ return new Promise((resolve, reject) => {
158
+ this.pendingRequests.set(requestId, { resolve, reject });
159
+
160
+ this.child.stdin.write(JSON.stringify({
161
+ ...request,
162
+ _requestId: requestId,
163
+ }) + "\n");
164
+
165
+ // Set timeout (2 minutes)
166
+ setTimeout(() => {
167
+ const pending = this.pendingRequests.get(requestId);
168
+ if (pending) {
169
+ this.pendingRequests.delete(requestId);
170
+ reject(new Error("Request timeout"));
171
+ }
172
+ }, 120000);
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Handle responses from ACP agent
178
+ * @param {string} data - Response data chunk
179
+ */
180
+ handleResponse(data) {
181
+ const lines = data.split("\n").filter((line) => line.trim());
182
+
183
+ for (const line of lines) {
184
+ try {
185
+ const message = JSON.parse(line);
186
+ this.handleMessage(message);
187
+ } catch (e) {
188
+ console.error("[ACP] Failed to parse response:", line, e);
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Handle individual message from ACP agent
195
+ * @param {object} message - Parsed message
196
+ */
197
+ handleMessage(message) {
198
+ switch (message.type) {
199
+ case "initialize_response":
200
+ this.handleInitializeResponse(message);
201
+ break;
202
+
203
+ case "newSession_response":
204
+ this.handleNewSessionResponse(message);
205
+ break;
206
+
207
+ case "prompt_response":
208
+ this.handlePromptResponse(message);
209
+ break;
210
+
211
+ case "sessionUpdate":
212
+ this.handleSessionUpdate(message);
213
+ break;
214
+
215
+ default:
216
+ console.warn("[ACP] Unknown message type:", message.type);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Handle initialize response
222
+ */
223
+ handleInitializeResponse(message) {
224
+ const pending = this.pendingRequests.get(message._requestId);
225
+ if (pending) {
226
+ this.pendingRequests.delete(message._requestId);
227
+ pending.resolve(message);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Handle new session response
233
+ */
234
+ handleNewSessionResponse(message) {
235
+ const pending = this.pendingRequests.get(message._requestId);
236
+ if (pending) {
237
+ this.pendingRequests.delete(message._requestId);
238
+ pending.resolve(message);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Handle prompt response (streamed updates)
244
+ */
245
+ handlePromptResponse(message) {
246
+ if (message._requestId) {
247
+ // Final response
248
+ const pending = this.pendingRequests.get(message._requestId);
249
+ if (pending) {
250
+ this.pendingRequests.delete(message._requestId);
251
+ pending.resolve(message);
252
+ }
253
+ } else {
254
+ // Session update during streaming
255
+ this.handleSessionUpdate(message);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Handle session updates (streaming events)
261
+ */
262
+ handleSessionUpdate(update) {
263
+ // Emit events for streaming
264
+ if (this.handlers.has("update")) {
265
+ for (const handler of this.handlers.get("update")) {
266
+ handler(update);
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Register an event handler
273
+ * @param {string} event - Event type
274
+ * @param {Function} handler - Event handler
275
+ */
276
+ on(event, handler) {
277
+ if (!this.handlers.has(event)) {
278
+ this.handlers.set(event, []);
279
+ }
280
+ this.handlers.get(event).push(handler);
281
+ }
282
+
283
+ /**
284
+ * Remove all event handlers for an event type
285
+ * @param {string} event - Event type
286
+ */
287
+ off(event) {
288
+ this.handlers.delete(event);
289
+ }
290
+ }
291
+
292
+ export default AcpClient;
@@ -0,0 +1,434 @@
1
+ import { generateId } from "./cli-runner.mjs";
2
+
3
+ /**
4
+ * Transforms ACP ndjson responses to Anthropic SSE streaming format
5
+ */
6
+ export class AcpToSSETransformer {
7
+ constructor() {
8
+ this.messageId = `msg_${generateId()}`;
9
+ this.contentBlockIndex = 0;
10
+ this.hasStarted = false;
11
+ this.currentBlockStarted = false;
12
+ this.outputTokens = 0;
13
+ this.inputTokens = 0;
14
+ }
15
+
16
+ /**
17
+ * Create an SSE event string
18
+ * @param {string} eventType - The event type
19
+ * @param {object} data - The event data
20
+ * @returns {string} Formatted SSE event
21
+ */
22
+ createSSEEvent(eventType, data) {
23
+ return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
24
+ }
25
+
26
+ /**
27
+ * Transform ACP message to SSE events
28
+ * @param {object} message - ACP message
29
+ * @returns {string} SSE events string
30
+ */
31
+ transformMessage(message) {
32
+ const events = [];
33
+
34
+ switch (message.type) {
35
+ case "sessionUpdate":
36
+ // Handle different session update types
37
+ const update = message.update;
38
+ switch (update.sessionUpdate) {
39
+ case "agent_message_chunk":
40
+ return this.transformAssistantMessageChunk(update.content);
41
+ case "tool_call":
42
+ return this.transformToolCall(update);
43
+ case "tool_call_update":
44
+ return this.transformToolCallUpdate(update);
45
+ case "plan":
46
+ return this.transformPlan(update.entries);
47
+ case "current_mode_update":
48
+ return []; // Mode updates don't need SSE events
49
+ case "available_commands_update":
50
+ return []; // Command updates don't need SSE events
51
+ default:
52
+ return [];
53
+ }
54
+
55
+ default:
56
+ console.warn("[Transformer] Unknown ACP message type:", message.type);
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Transform assistant message chunk
63
+ * @param {object} content - Message content
64
+ * @returns {string} SSE events
65
+ */
66
+ transformAssistantMessageChunk(content) {
67
+ if (!this.hasStarted) {
68
+ this.hasStarted = true;
69
+ return [
70
+ this.createSSEEvent("message_start", {
71
+ type: "message",
72
+ message: {
73
+ id: this.messageId,
74
+ type: "assistant",
75
+ content: [],
76
+ model: "claude-sonnet-4-5-20250929",
77
+ stop_reason: null,
78
+ stop_sequence: null,
79
+ usage: { input_tokens: 0, output_tokens: 0 },
80
+ },
81
+ })
82
+ ];
83
+ }
84
+
85
+ const events = [];
86
+
87
+ if (typeof content === "string") {
88
+ events.push(
89
+ this.createSSEEvent("content_block_delta", {
90
+ type: "content_block_delta",
91
+ index: this.contentBlockIndex,
92
+ delta: { type: "text_delta", text: content },
93
+ })
94
+ );
95
+ this.outputTokens += Math.ceil(content.length / 4);
96
+ }
97
+
98
+ return events.join("");
99
+ }
100
+
101
+ /**
102
+ * Transform tool call
103
+ * @param {object} toolCall - Tool call data
104
+ * @returns {string} SSE events
105
+ */
106
+ transformToolCall(toolCall) {
107
+ if (!this.hasStarted) {
108
+ this.hasStarted = true;
109
+ return [
110
+ this.createSSEEvent("message_start", {
111
+ type: "message",
112
+ message: {
113
+ id: this.messageId,
114
+ type: "assistant",
115
+ content: [],
116
+ model: "claude-sonnet-4-5-20250929",
117
+ stop_reason: null,
118
+ stop_sequence: null,
119
+ usage: { input_tokens: 0, output_tokens: 0 },
120
+ },
121
+ })
122
+ ];
123
+ }
124
+
125
+ // Close current text block if open
126
+ if (this.currentBlockStarted) {
127
+ this.closeCurrentBlock();
128
+ }
129
+
130
+ const events = [];
131
+
132
+ // Start tool_use block
133
+ events.push(
134
+ this.createSSEEvent("content_block_start", {
135
+ type: "content_block_start",
136
+ index: this.contentBlockIndex,
137
+ content_block: {
138
+ type: "tool_use",
139
+ id: toolCall.toolCallId || `toolu_${generateId()}`,
140
+ name: toolCall.title || "unknown",
141
+ input: toolCall.rawInput || {},
142
+ },
143
+ })
144
+ );
145
+
146
+ // Stream input as input_json_delta
147
+ const inputStr = JSON.stringify(toolCall.rawInput || {});
148
+ events.push(
149
+ this.createSSEEvent("content_block_delta", {
150
+ type: "content_block_delta",
151
+ index: this.contentBlockIndex,
152
+ delta: {
153
+ type: "input_json_delta",
154
+ partial_json: inputStr,
155
+ },
156
+ })
157
+ );
158
+
159
+ // End tool_use block
160
+ events.push(
161
+ this.createSSEEvent("content_block_stop", {
162
+ type: "content_block_stop",
163
+ index: this.contentBlockIndex,
164
+ })
165
+ );
166
+
167
+ this.contentBlockIndex++;
168
+
169
+ return events.join("");
170
+ }
171
+
172
+ /**
173
+ * Transform tool call update (result)
174
+ * @param {object} update - Tool call update
175
+ * @returns {string} SSE events
176
+ */
177
+ transformToolCallUpdate(update) {
178
+ if (update.status === "completed") {
179
+ // Emit result as text block
180
+ if (!this.hasStarted) {
181
+ this.hasStarted = true;
182
+ return [
183
+ this.createSSEEvent("message_start", {
184
+ type: "message",
185
+ message: {
186
+ id: this.messageId,
187
+ type: "assistant",
188
+ content: [],
189
+ model: "claude-sonnet-4-5-20250929",
190
+ stop_reason: null,
191
+ stop_sequence: null,
192
+ usage: { input_tokens: 0, output_tokens: 0 },
193
+ },
194
+ })
195
+ ];
196
+ }
197
+
198
+ const events = [];
199
+
200
+ // Start content block
201
+ events.push(
202
+ this.createSSEEvent("content_block_start", {
203
+ type: "content_block_start",
204
+ index: this.contentBlockIndex,
205
+ content_block: { type: "text", text: "" },
206
+ })
207
+ );
208
+
209
+ // Add content (tool result)
210
+ const content = this.formatToolResult(update);
211
+ events.push(
212
+ this.createSSEEvent("content_block_delta", {
213
+ type: "content_block_delta",
214
+ index: this.contentBlockIndex,
215
+ delta: { type: "text_delta", text: content },
216
+ })
217
+ );
218
+
219
+ // End content block
220
+ events.push(
221
+ this.createSSEEvent("content_block_stop", {
222
+ type: "content_block_stop",
223
+ index: this.contentBlockIndex,
224
+ })
225
+ );
226
+
227
+ this.contentBlockIndex++;
228
+ return events.join("");
229
+ } else if (update.status === "failed") {
230
+ // Show error
231
+ if (!this.hasStarted) {
232
+ this.hasStarted = true;
233
+ return [
234
+ this.createSSEEvent("message_start", {
235
+ type: "message",
236
+ message: {
237
+ id: this.messageId,
238
+ type: "assistant",
239
+ content: [],
240
+ model: "claude-sonnet-4-5-20250929",
241
+ stop_reason: null,
242
+ stop_sequence: null,
243
+ usage: { input_tokens: 0, output_tokens: 0 },
244
+ },
245
+ })
246
+ ];
247
+ }
248
+
249
+ const events = [];
250
+
251
+ // Start content block
252
+ events.push(
253
+ this.createSSEEvent("content_block_start", {
254
+ type: "content_block_start",
255
+ index: this.contentBlockIndex,
256
+ content_block: { type: "text", text: "" },
257
+ })
258
+ );
259
+
260
+ // Add error content
261
+ const errorContent = this.formatError(update);
262
+ events.push(
263
+ this.createSSEEvent("content_block_delta", {
264
+ type: "content_block_delta",
265
+ index: this.contentBlockIndex,
266
+ delta: { type: "text_delta", text: errorContent },
267
+ })
268
+ );
269
+
270
+ // End content block
271
+ events.push(
272
+ this.createSSEEvent("content_block_stop", {
273
+ type: "content_block_stop",
274
+ index: this.contentBlockIndex,
275
+ })
276
+ );
277
+
278
+ this.contentBlockIndex++;
279
+ return events.join("");
280
+ }
281
+
282
+ return [];
283
+ }
284
+
285
+ /**
286
+ * Transform plan/TODO entries
287
+ * @param {Array} entries - Plan entries
288
+ * @returns {string} SSE events
289
+ */
290
+ transformPlan(entries) {
291
+ if (!this.hasStarted) {
292
+ this.hasStarted = true;
293
+ return [
294
+ this.createSSEEvent("message_start", {
295
+ type: "message",
296
+ message: {
297
+ id: this.messageId,
298
+ type: "assistant",
299
+ content: [],
300
+ model: "claude-sonnet-4-5-20250929",
301
+ stop_reason: null,
302
+ stop_sequence: null,
303
+ usage: { input_tokens: 0, output_tokens: 0 },
304
+ },
305
+ })
306
+ ];
307
+ }
308
+
309
+ if (!entries || entries.length === 0) {
310
+ return [];
311
+ }
312
+
313
+ const events = [];
314
+ const planText = entries.map((e) => `- [${e.status === "completed" ? "x" : " "}] ${e.content}`).join("\n");
315
+
316
+ // Start content block
317
+ events.push(
318
+ this.createSSEEvent("content_block_start", {
319
+ type: "content_block_start",
320
+ index: this.contentBlockIndex,
321
+ content_block: { type: "text", text: "" },
322
+ })
323
+ );
324
+
325
+ // Add plan text
326
+ events.push(
327
+ this.createSSEEvent("content_block_delta", {
328
+ type: "content_block_delta",
329
+ index: this.contentBlockIndex,
330
+ delta: { type: "text_delta", text: planText },
331
+ })
332
+ );
333
+
334
+ // End content block
335
+ events.push(
336
+ this.createSSEEvent("content_block_stop", {
337
+ type: "content_block_stop",
338
+ index: this.contentBlockIndex,
339
+ })
340
+ );
341
+
342
+ this.contentBlockIndex++;
343
+
344
+ return events.join("");
345
+ }
346
+
347
+ /**
348
+ * Format tool result for display
349
+ * @param {object} update - Tool call update
350
+ * @returns {string} Formatted result
351
+ */
352
+ formatToolResult(update) {
353
+ if (update.content && Array.isArray(update.content)) {
354
+ // Join multiple content blocks
355
+ return update.content
356
+ .map((block) => {
357
+ if (block.type === "text") {
358
+ return block.text;
359
+ }
360
+ return "";
361
+ })
362
+ .filter((text) => text)
363
+ .join("\n");
364
+ } else if (typeof update.content === "string") {
365
+ return update.content;
366
+ }
367
+ return "";
368
+ }
369
+
370
+ /**
371
+ * Format error message
372
+ * @param {object} update - Tool call update
373
+ * @returns {string} Formatted error
374
+ */
375
+ formatError(update) {
376
+ if (update.status === "failed" && update.content) {
377
+ if (typeof update.content === "string") {
378
+ return `Error: ${update.content}`;
379
+ }
380
+ if (Array.isArray(update.content) && update.content.length > 0) {
381
+ const firstBlock = update.content[0];
382
+ if (firstBlock.type === "text") {
383
+ return `Error: ${firstBlock.text}`;
384
+ }
385
+ }
386
+ }
387
+ return "Error: Tool execution failed";
388
+ }
389
+
390
+ /**
391
+ * Close current content block
392
+ */
393
+ closeCurrentBlock() {
394
+ this.currentBlockStarted = false;
395
+ }
396
+
397
+ /**
398
+ * Finalize the stream with closing events
399
+ * @returns {string} Final SSE events
400
+ */
401
+ finalize() {
402
+ const events = [];
403
+
404
+ if (this.hasStarted) {
405
+ // Close any open content block
406
+ if (this.currentBlockStarted) {
407
+ events.push(
408
+ this.createSSEEvent("content_block_stop", {
409
+ type: "content_block_stop",
410
+ index: this.contentBlockIndex,
411
+ })
412
+ );
413
+ this.currentBlockStarted = false;
414
+ }
415
+
416
+ events.push(
417
+ this.createSSEEvent("message_delta", {
418
+ type: "message_delta",
419
+ delta: {
420
+ stop_reason: "end_turn",
421
+ stop_sequence: null,
422
+ },
423
+ usage: { output_tokens: this.outputTokens },
424
+ })
425
+ );
426
+
427
+ events.push(
428
+ this.createSSEEvent("message_stop", { type: "message_stop" })
429
+ );
430
+ }
431
+
432
+ return events.join("");
433
+ }
434
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-code-wrapper",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "OpenCode plugin that wraps Claude Code CLI for API-like access",
5
5
  "main": "./index.mjs",
6
6
  "type": "module",