@saccolabs/pi-claude-cli 0.4.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.
@@ -0,0 +1,385 @@
1
+ import type { ClaudeApiEvent, TrackedContentBlock } from "./types";
2
+ import { calculateCost } from "@earendil-works/pi-ai";
3
+ import type {
4
+ AssistantMessage,
5
+ AssistantMessageEventStream,
6
+ Model,
7
+ TextContent,
8
+ ThinkingContent,
9
+ ToolCall,
10
+ } from "@earendil-works/pi-ai";
11
+ import {
12
+ mapClaudeToolNameToPi,
13
+ translateClaudeArgsToPi,
14
+ isPiKnownClaudeTool,
15
+ } from "./tool-mapping.js";
16
+
17
+ /**
18
+ * Extended tracking for tool_use content blocks during streaming.
19
+ * Stores the Claude tool name for argument translation at block_stop.
20
+ */
21
+ interface TrackedToolBlock {
22
+ type: "tool_use";
23
+ index: number;
24
+ id: string;
25
+ name: string; // Already mapped to pi name
26
+ claudeName: string; // Original Claude name for arg translation
27
+ arguments: Record<string, unknown>;
28
+ partialJson: string;
29
+ }
30
+
31
+ /** Union of tracked block types for the blocks array. */
32
+ type TrackedBlock = TrackedContentBlock | TrackedToolBlock;
33
+
34
+ /**
35
+ * The event bridge interface returned by createEventBridge.
36
+ * handleEvent processes each Claude API streaming event and pushes
37
+ * the appropriate pi events to the stream.
38
+ * getOutput returns the accumulated AssistantMessage.
39
+ */
40
+ export interface EventBridge {
41
+ handleEvent(event: ClaudeApiEvent): void;
42
+ getOutput(): AssistantMessage;
43
+ }
44
+
45
+ /**
46
+ * Map Claude API stop reasons to pi's stop reason format.
47
+ */
48
+ function mapStopReason(
49
+ reason: string | undefined,
50
+ ): "stop" | "length" | "toolUse" {
51
+ switch (reason) {
52
+ case "tool_use":
53
+ return "toolUse";
54
+ case "max_tokens":
55
+ return "length";
56
+ case "end_turn":
57
+ default:
58
+ return "stop";
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Create an event bridge that translates Claude API streaming events
64
+ * into pi's AssistantMessageEventStream events.
65
+ *
66
+ * The bridge maintains internal state to track content blocks and
67
+ * accumulate the final AssistantMessage. It handles:
68
+ * - text content blocks (start/delta/stop -> text_start/text_delta/text_end)
69
+ * - message lifecycle (message_start for usage, message_delta for stop reason, message_stop for done)
70
+ * - unsupported block types (tool_use, thinking) with warnings
71
+ */
72
+ export function createEventBridge(
73
+ stream: AssistantMessageEventStream,
74
+ model: Model<any>,
75
+ ): EventBridge {
76
+ // Tracked content blocks indexed by Claude's content_block index
77
+ const blocks: TrackedBlock[] = [];
78
+
79
+ // The accumulated output message
80
+ const output: AssistantMessage = {
81
+ role: "assistant" as const,
82
+ content: [] as (TextContent | ThinkingContent | ToolCall)[],
83
+ api: "pi-claude-cli",
84
+ provider: model.provider,
85
+ model: model.id,
86
+ usage: {
87
+ input: 0,
88
+ output: 0,
89
+ cacheRead: 0,
90
+ cacheWrite: 0,
91
+ totalTokens: 0,
92
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
93
+ },
94
+ stopReason: "stop" as const,
95
+ timestamp: Date.now(),
96
+ };
97
+
98
+ let started = false;
99
+
100
+ function handleEvent(event: ClaudeApiEvent): void {
101
+ // Emit start event on first message — tells pi to begin incremental rendering
102
+ if (!started) {
103
+ stream.push({ type: "start", partial: output });
104
+ started = true;
105
+ }
106
+
107
+ switch (event.type) {
108
+ case "message_start":
109
+ handleMessageStart(event);
110
+ break;
111
+ case "content_block_start":
112
+ handleContentBlockStart(event);
113
+ break;
114
+ case "content_block_delta":
115
+ handleContentBlockDelta(event);
116
+ break;
117
+ case "content_block_stop":
118
+ handleContentBlockStop(event);
119
+ break;
120
+ case "message_delta":
121
+ handleMessageDelta(event);
122
+ break;
123
+ case "message_stop":
124
+ handleMessageStop();
125
+ break;
126
+ // Unknown event types are silently ignored
127
+ }
128
+ }
129
+
130
+ function handleMessageStart(event: ClaudeApiEvent): void {
131
+ const usage = event.message?.usage;
132
+ if (usage) {
133
+ output.usage.input = usage.input_tokens ?? 0;
134
+ output.usage.output = usage.output_tokens ?? 0;
135
+ output.usage.cacheRead = usage.cache_read_input_tokens ?? 0;
136
+ output.usage.cacheWrite = usage.cache_creation_input_tokens ?? 0;
137
+ output.usage.totalTokens =
138
+ output.usage.input +
139
+ output.usage.output +
140
+ output.usage.cacheRead +
141
+ output.usage.cacheWrite;
142
+ calculateCost(model, output.usage);
143
+ }
144
+ }
145
+
146
+ function handleContentBlockStart(event: ClaudeApiEvent): void {
147
+ const blockType = event.content_block?.type;
148
+
149
+ if (blockType === "text") {
150
+ const block: TrackedContentBlock = {
151
+ type: "text",
152
+ text: "",
153
+ index: event.index ?? 0,
154
+ };
155
+ blocks.push(block);
156
+ output.content.push({ type: "text" as const, text: "" });
157
+
158
+ stream.push({
159
+ type: "text_start",
160
+ contentIndex: output.content.length - 1,
161
+ partial: output,
162
+ });
163
+ } else if (blockType === "thinking") {
164
+ const block: TrackedContentBlock = {
165
+ type: "thinking",
166
+ text: "",
167
+ index: event.index ?? 0,
168
+ };
169
+ blocks.push(block);
170
+ output.content.push({
171
+ type: "thinking" as const,
172
+ thinking: "",
173
+ thinkingSignature: "",
174
+ });
175
+
176
+ stream.push({
177
+ type: "thinking_start",
178
+ contentIndex: output.content.length - 1,
179
+ partial: output,
180
+ });
181
+ } else if (blockType === "tool_use") {
182
+ const claudeName = event.content_block!.name!;
183
+
184
+ // Skip internal Claude Code tools (ToolSearch, Task, Agent, etc.)
185
+ // that pi cannot execute — only emit pi-known tools
186
+ if (!isPiKnownClaudeTool(claudeName)) {
187
+ return;
188
+ }
189
+
190
+ const piName = mapClaudeToolNameToPi(claudeName);
191
+ const id = event.content_block!.id!;
192
+
193
+ const block: TrackedToolBlock = {
194
+ type: "tool_use",
195
+ index: event.index ?? 0,
196
+ id,
197
+ name: piName,
198
+ claudeName,
199
+ arguments: {},
200
+ partialJson: "",
201
+ };
202
+ blocks.push(block);
203
+ output.content.push({
204
+ type: "toolCall" as const,
205
+ id,
206
+ name: piName,
207
+ arguments: {},
208
+ } as ToolCall);
209
+
210
+ stream.push({
211
+ type: "toolcall_start",
212
+ contentIndex: output.content.length - 1,
213
+ partial: output,
214
+ });
215
+ }
216
+ // Unknown block types silently ignored
217
+ }
218
+
219
+ function handleContentBlockDelta(event: ClaudeApiEvent): void {
220
+ const deltaType = event.delta?.type;
221
+
222
+ if (deltaType === "text_delta" && event.delta!.text != null) {
223
+ const idx = blocks.findIndex((b) => b.index === event.index);
224
+ if (idx === -1) return;
225
+
226
+ const block = blocks[idx];
227
+ if (block.type === "text") {
228
+ block.text += event.delta!.text;
229
+ const contentBlock = output.content[idx] as TextContent;
230
+ contentBlock.text = block.text;
231
+
232
+ stream.push({
233
+ type: "text_delta",
234
+ contentIndex: idx,
235
+ delta: event.delta!.text,
236
+ partial: output,
237
+ });
238
+ }
239
+ } else if (
240
+ deltaType === "thinking_delta" &&
241
+ event.delta!.thinking != null
242
+ ) {
243
+ const idx = blocks.findIndex((b) => b.index === event.index);
244
+ if (idx === -1) return;
245
+
246
+ const block = blocks[idx];
247
+ if (block.type === "thinking") {
248
+ block.text += event.delta!.thinking;
249
+ const contentBlock = output.content[idx] as ThinkingContent;
250
+ contentBlock.thinking = block.text;
251
+
252
+ stream.push({
253
+ type: "thinking_delta",
254
+ contentIndex: idx,
255
+ delta: event.delta!.thinking,
256
+ partial: output,
257
+ });
258
+ }
259
+ } else if (
260
+ deltaType === "input_json_delta" &&
261
+ event.delta!.partial_json != null
262
+ ) {
263
+ const idx = blocks.findIndex((b) => b.index === event.index);
264
+ if (idx === -1) return;
265
+
266
+ const block = blocks[idx];
267
+ if (block.type === "tool_use") {
268
+ block.partialJson += event.delta!.partial_json;
269
+
270
+ // Try to parse accumulated JSON -- on success update args, on failure keep previous
271
+ try {
272
+ block.arguments = JSON.parse(block.partialJson);
273
+ (output.content[idx] as any).arguments = block.arguments;
274
+ } catch {
275
+ // Partial JSON not yet parseable -- keep previous arguments
276
+ }
277
+
278
+ stream.push({
279
+ type: "toolcall_delta",
280
+ contentIndex: idx,
281
+ delta: event.delta!.partial_json,
282
+ partial: output,
283
+ });
284
+ }
285
+ } else if (
286
+ deltaType === "signature_delta" &&
287
+ event.delta!.signature != null
288
+ ) {
289
+ // Accumulate signature on the thinking block
290
+ const idx = blocks.findIndex((b) => b.index === event.index);
291
+ if (idx === -1) return;
292
+
293
+ const block = blocks[idx];
294
+ if (block.type === "thinking") {
295
+ const contentBlock = output.content[idx] as ThinkingContent;
296
+ contentBlock.thinkingSignature =
297
+ (contentBlock.thinkingSignature || "") + event.delta!.signature;
298
+ }
299
+ }
300
+ }
301
+
302
+ function handleContentBlockStop(event: ClaudeApiEvent): void {
303
+ const idx = blocks.findIndex((b) => b.index === event.index);
304
+ if (idx === -1) return;
305
+
306
+ const block = blocks[idx];
307
+ // Clean up the tracking index from the block (no longer needed)
308
+ delete (block as any).index;
309
+
310
+ if (block.type === "text") {
311
+ stream.push({
312
+ type: "text_end",
313
+ contentIndex: idx,
314
+ content: block.text,
315
+ partial: output,
316
+ });
317
+ } else if (block.type === "thinking") {
318
+ stream.push({
319
+ type: "thinking_end",
320
+ contentIndex: idx,
321
+ content: block.text,
322
+ partial: output,
323
+ });
324
+ } else if (block.type === "tool_use") {
325
+ // Final JSON parse with fallback to raw string
326
+ let finalArgs: Record<string, unknown> | string;
327
+ try {
328
+ const parsed = JSON.parse(block.partialJson);
329
+ finalArgs = translateClaudeArgsToPi(block.claudeName, parsed);
330
+ } catch {
331
+ finalArgs = block.partialJson;
332
+ }
333
+
334
+ // Update output.content with final arguments
335
+ const contentBlock = output.content[idx] as ToolCall;
336
+ (contentBlock as any).arguments = finalArgs;
337
+
338
+ // ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
339
+ // intentionally emit a raw string when JSON parse fails completely.
340
+ // Pi handles string arguments gracefully at runtime.
341
+ const toolCall = {
342
+ type: "toolCall" as const,
343
+ id: block.id,
344
+ name: block.name,
345
+ arguments: finalArgs,
346
+ } as ToolCall;
347
+
348
+ stream.push({
349
+ type: "toolcall_end",
350
+ contentIndex: idx,
351
+ toolCall,
352
+ partial: output,
353
+ });
354
+ }
355
+ }
356
+
357
+ function handleMessageDelta(event: ClaudeApiEvent): void {
358
+ if (event.delta?.stop_reason) {
359
+ output.stopReason = mapStopReason(event.delta.stop_reason);
360
+ }
361
+
362
+ const usage = event.usage;
363
+ if (usage) {
364
+ if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
365
+ if (usage.output_tokens != null)
366
+ output.usage.output = usage.output_tokens;
367
+ output.usage.totalTokens =
368
+ output.usage.input +
369
+ output.usage.output +
370
+ output.usage.cacheRead +
371
+ output.usage.cacheWrite;
372
+ calculateCost(model, output.usage);
373
+ }
374
+ }
375
+
376
+ function handleMessageStop(): void {
377
+ // No-op: done event is pushed by the provider after readline closes.
378
+ // Pushing done here (synchronously) prevents pi from executing tools.
379
+ }
380
+
381
+ return {
382
+ handleEvent,
383
+ getOutput: () => output,
384
+ };
385
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Custom tool discovery and MCP config file generation.
3
+ *
4
+ * Discovers non-built-in tools from pi, writes their schemas to a temp file,
5
+ * and generates an MCP config that points to the schema-only MCP server.
6
+ */
7
+
8
+ import { writeFileSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ /** The 6 built-in tools that pi handles natively (match pi tool names). */
14
+ const BUILT_IN_TOOL_NAMES = new Set([
15
+ "read",
16
+ "write",
17
+ "edit",
18
+ "bash",
19
+ "grep",
20
+ "find",
21
+ ]);
22
+
23
+ /** A custom tool definition with MCP-compatible schema. */
24
+ export interface McpToolDef {
25
+ name: string;
26
+ description: string;
27
+ inputSchema: Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * Get custom tool definitions from pi, filtering out built-in tools.
32
+ *
33
+ * @param pi - The pi ExtensionAPI instance
34
+ * @returns Array of custom tool definitions (empty if all tools are built-in)
35
+ */
36
+ export function getCustomToolDefs(pi: any): McpToolDef[] {
37
+ const allTools = pi.getAllTools();
38
+
39
+ if (!Array.isArray(allTools)) {
40
+ return [];
41
+ }
42
+
43
+ return allTools
44
+ .filter((tool: any) => !BUILT_IN_TOOL_NAMES.has(tool.name))
45
+ .map((tool: any) => ({
46
+ name: tool.name,
47
+ description: tool.description,
48
+ inputSchema: tool.parameters,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Write MCP config and tool schemas to temp files.
54
+ *
55
+ * Creates two temp files:
56
+ * 1. Schema file: JSON array of tool definitions
57
+ * 2. Config file: MCP config pointing to the schema-only server
58
+ *
59
+ * @param toolDefs - Array of custom tool definitions
60
+ * @returns Path to the MCP config file
61
+ */
62
+ export function writeMcpConfig(toolDefs: McpToolDef[]): string {
63
+ // Write tool schemas to temp file
64
+ const schemaFilePath = join(
65
+ tmpdir(),
66
+ `pi-claude-mcp-schemas-${process.pid}.json`,
67
+ );
68
+ writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
69
+
70
+ // Resolve path to the schema server .cjs file (sibling of this module)
71
+ const __filename = fileURLToPath(import.meta.url);
72
+ const __dirname = dirname(__filename);
73
+ const serverPath = join(__dirname, "mcp-schema-server.cjs");
74
+
75
+ // Build MCP config
76
+ const config = {
77
+ mcpServers: {
78
+ "custom-tools": {
79
+ command: "node",
80
+ args: [serverPath, schemaFilePath],
81
+ },
82
+ },
83
+ };
84
+
85
+ // Write config to temp file
86
+ const configFilePath = join(
87
+ tmpdir(),
88
+ `pi-claude-mcp-config-${process.pid}.json`,
89
+ );
90
+ writeFileSync(configFilePath, JSON.stringify(config));
91
+
92
+ return configFilePath;
93
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Schema-only MCP server. Reads tool schemas from a JSON file.
3
+ // Only implements initialize + tools/list. tools/call is never reached
4
+ // because the parent process kills the Claude subprocess at message_stop
5
+ // before tool execution (break-early pattern).
6
+ "use strict";
7
+
8
+ const fs = require("fs");
9
+ const readline = require("readline");
10
+
11
+ const schemaPath = process.argv[2];
12
+ if (!schemaPath) {
13
+ process.exit(1);
14
+ }
15
+
16
+ let tools = [];
17
+ try {
18
+ tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
19
+ } catch {
20
+ process.exit(1);
21
+ }
22
+
23
+ const rl = readline.createInterface({ input: process.stdin });
24
+ rl.on("line", (line) => {
25
+ let msg;
26
+ try {
27
+ msg = JSON.parse(line);
28
+ } catch {
29
+ return;
30
+ }
31
+
32
+ if (msg.method === "initialize") {
33
+ const resp = {
34
+ jsonrpc: "2.0",
35
+ id: msg.id,
36
+ result: {
37
+ protocolVersion: "2024-11-05",
38
+ capabilities: { tools: {} },
39
+ serverInfo: { name: "custom-tools", version: "1.0.0" },
40
+ },
41
+ };
42
+ process.stdout.write(JSON.stringify(resp) + "\n");
43
+ } else if (msg.method === "tools/list") {
44
+ const resp = { jsonrpc: "2.0", id: msg.id, result: { tools } };
45
+ process.stdout.write(JSON.stringify(resp) + "\n");
46
+ }
47
+ // notifications/initialized: no response needed (notification)
48
+ // tools/call: never reached (break-early kills subprocess first)
49
+ });