@vanillagreen/pi-claude-bridge 1.0.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/package.json ADDED
@@ -0,0 +1,151 @@
1
+ {
2
+ "name": "@vanillagreen/pi-claude-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "coding-agent",
10
+ "claude-code",
11
+ "claude-agent-sdk"
12
+ ],
13
+ "license": "MIT",
14
+ "pi": {
15
+ "extensions": [
16
+ "./bundle/index.js"
17
+ ],
18
+ "image": "https://raw.githubusercontent.com/vanillagreencom/vstack/main/pi-extensions/pi-claude-bridge/assets/claude-bridge1.png"
19
+ },
20
+ "vstack": {
21
+ "extensionManager": {
22
+ "displayName": "Claude Bridge",
23
+ "settings": [
24
+ {
25
+ "key": "enabled",
26
+ "label": "Enable Claude bridge provider",
27
+ "description": "Register claude-bridge models that route Pi turns through Claude Code via the Claude Agent SDK.",
28
+ "type": "boolean",
29
+ "default": true,
30
+ "category": "General",
31
+ "apply": "reload",
32
+ "requiresReload": true
33
+ },
34
+ {
35
+ "key": "appendSystemPrompt",
36
+ "label": "Forward AGENTS.md + skills",
37
+ "description": "Original bridge behavior: append AGENTS.md and Pi's skills block to Claude Code's claude_code preset prompt.",
38
+ "type": "boolean",
39
+ "default": true,
40
+ "category": "Base Prompt",
41
+ "apply": "live"
42
+ },
43
+ {
44
+ "key": "includeAppendSystemPromptMd",
45
+ "label": "Forward APPEND_SYSTEM.md",
46
+ "description": "Opt in to forwarding project/global APPEND_SYSTEM.md content to Claude Code. Off by default because these files can be Pi-specific.",
47
+ "type": "boolean",
48
+ "default": false,
49
+ "category": "Pi Prompt Context",
50
+ "apply": "live"
51
+ },
52
+ {
53
+ "key": "includeProjectAgentsHook",
54
+ "label": "Forward project agents hook",
55
+ "description": "Forward recognized pi-agents-tmux before_agent_start prompt additions such as the Project Agents/Subagents list. Off by default.",
56
+ "type": "boolean",
57
+ "default": false,
58
+ "category": "Pi Prompt Hooks",
59
+ "apply": "live"
60
+ },
61
+ {
62
+ "key": "includeTaskPanelHook",
63
+ "label": "Forward task panel hook",
64
+ "description": "Forward recognized pi-task-panel workflow reminders from before_agent_start. Off by default.",
65
+ "type": "boolean",
66
+ "default": false,
67
+ "category": "Pi Prompt Hooks",
68
+ "apply": "live"
69
+ },
70
+ {
71
+ "key": "includeCavemanHook",
72
+ "label": "Forward caveman hook",
73
+ "description": "Forward recognized pi-caveman response-style prompt additions from before_agent_start. Off by default.",
74
+ "type": "boolean",
75
+ "default": false,
76
+ "category": "Pi Prompt Hooks",
77
+ "apply": "live"
78
+ },
79
+ {
80
+ "key": "strictMcpConfig",
81
+ "label": "Strict MCP config",
82
+ "description": "When Claude Code filesystem settings are loaded, tell Claude Code to use only MCP servers passed by the bridge, avoiding token-heavy MCP auto-loads.",
83
+ "type": "boolean",
84
+ "default": true,
85
+ "category": "Claude Code",
86
+ "apply": "live"
87
+ },
88
+ {
89
+ "key": "pathToClaudeCodeExecutable",
90
+ "label": "Claude executable path",
91
+ "description": "Optional explicit path to the claude binary. If empty, the bridge auto-detects claude or claude-code on PATH.",
92
+ "type": "path",
93
+ "default": "",
94
+ "category": "Claude Code",
95
+ "apply": "live"
96
+ }
97
+ ]
98
+ }
99
+ },
100
+ "dependencies": {
101
+ "@anthropic-ai/claude-agent-sdk": "0.2.128",
102
+ "@anthropic-ai/sdk": "^0.73.0",
103
+ "cc-session-io": "^0.3.1",
104
+ "change-case": "^5.4.4"
105
+ },
106
+ "peerDependencies": {
107
+ "@mariozechner/pi-ai": "*",
108
+ "@mariozechner/pi-coding-agent": "*"
109
+ },
110
+ "devDependencies": {
111
+ "@mariozechner/pi-ai": "^0.72.1",
112
+ "@mariozechner/pi-coding-agent": "^0.72.1",
113
+ "@types/node": "^24.3.0",
114
+ "esbuild": "^0.28.0",
115
+ "tsx": "^4.21.0",
116
+ "typescript": "^6.0.3"
117
+ },
118
+ "scripts": {
119
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=bundle/index.js --external:@mariozechner/pi-ai --external:@mariozechner/pi-coding-agent",
120
+ "prepack": "npm run build",
121
+ "test:unit": "node --import tsx --test tests/unit-*.mjs",
122
+ "test": "set -a && [ -f .env.test ] && . .env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
123
+ "test:usage": "tests/usage-test.sh",
124
+ "typecheck": "tsc --noEmit"
125
+ },
126
+ "files": [
127
+ "bundle/",
128
+ "src/",
129
+ "assets/",
130
+ "CHANGELOG.md",
131
+ "LICENSE",
132
+ "README.md",
133
+ "package.json"
134
+ ],
135
+ "repository": {
136
+ "type": "git",
137
+ "url": "git+https://github.com/vanillagreencom/vstack.git",
138
+ "directory": "pi-extensions/pi-claude-bridge"
139
+ },
140
+ "homepage": "https://github.com/vanillagreencom/vstack/tree/main/pi-extensions/pi-claude-bridge",
141
+ "bugs": {
142
+ "url": "https://github.com/vanillagreencom/vstack/issues"
143
+ },
144
+ "author": "vanillagreen",
145
+ "publishConfig": {
146
+ "access": "public"
147
+ },
148
+ "engines": {
149
+ "node": ">=20"
150
+ }
151
+ }
@@ -0,0 +1,54 @@
1
+ // AGENTS.md discovery and sanitization for forwarding to Claude Code.
2
+ //
3
+ // Pi uses AGENTS.md for long-lived instructions; Claude Code reads the same
4
+ // content under "# CLAUDE.md". We walk up from cwd looking for AGENTS.md,
5
+ // fall back to ~/.pi/agent/AGENTS.md, and rewrite pi-specific references
6
+ // (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
7
+ // references in the file still resolve inside the CC subprocess.
8
+
9
+ import { existsSync, readFileSync } from "fs";
10
+ import { homedir } from "os";
11
+ import { dirname, join, resolve } from "path";
12
+
13
+ const GLOBAL_AGENTS_PATH = join(homedir(), ".pi", "agent", "AGENTS.md");
14
+
15
+ export function resolveAgentsMdPath(): string | undefined {
16
+ const fromCwd = findAgentsMdInParents(process.cwd());
17
+ if (fromCwd) return fromCwd;
18
+ if (existsSync(GLOBAL_AGENTS_PATH)) return GLOBAL_AGENTS_PATH;
19
+ return undefined;
20
+ }
21
+
22
+ export function findAgentsMdInParents(startDir: string): string | undefined {
23
+ let current = resolve(startDir);
24
+ while (true) {
25
+ const candidate = join(current, "AGENTS.md");
26
+ if (existsSync(candidate)) return candidate;
27
+ const parent = dirname(current);
28
+ if (parent === current) break;
29
+ current = parent;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ export function extractAgentsAppend(): string | undefined {
35
+ const agentsPath = resolveAgentsMdPath();
36
+ if (!agentsPath) return undefined;
37
+ try {
38
+ const content = readFileSync(agentsPath, "utf-8").trim();
39
+ if (!content) return undefined;
40
+ const sanitized = sanitizeAgentsContent(content);
41
+ return sanitized.length > 0 ? `# CLAUDE.md\n\n${sanitized}` : undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ export function sanitizeAgentsContent(content: string): string {
48
+ let sanitized = content;
49
+ sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
50
+ sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
51
+ sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
52
+ sanitized = sanitized.replace(/\bpi\b/gi, "environment");
53
+ return sanitized;
54
+ }
package/src/config.ts ADDED
@@ -0,0 +1,144 @@
1
+ // User-facing extension config. Legacy config is loaded from
2
+ // ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json. vstack extension
3
+ // manager config is loaded from settings.json and overrides legacy files.
4
+
5
+ import type { SettingSource } from "@anthropic-ai/claude-agent-sdk";
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { dirname, join, resolve } from "path";
9
+
10
+ export const PACKAGE_ID = "pi-claude-bridge";
11
+ export const SCOPED_PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
12
+
13
+ export interface Config {
14
+ enabled?: boolean;
15
+ /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
16
+ provider?: {
17
+ appendSystemPrompt?: boolean;
18
+ settingSources?: SettingSource[];
19
+ strictMcpConfig?: boolean;
20
+ pathToClaudeCodeExecutable?: string;
21
+ };
22
+ /** Extra Pi context forwarded to Claude Code on top of AGENTS.md + skills. */
23
+ promptContext?: {
24
+ includeAppendSystemPromptMd?: boolean;
25
+ includeProjectAgentsHook?: boolean;
26
+ includeTaskPanelHook?: boolean;
27
+ includeCavemanHook?: boolean;
28
+ };
29
+ }
30
+
31
+ type SettingsRecord = Record<string, unknown>;
32
+
33
+ function expandHome(input: string): string {
34
+ if (input === "~") return homedir();
35
+ if (input.startsWith("~/")) return join(homedir(), input.slice(2));
36
+ return input;
37
+ }
38
+
39
+ function piUserDir(): string {
40
+ return resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
41
+ }
42
+
43
+ function asRecord(value: unknown): SettingsRecord | undefined {
44
+ return value && typeof value === "object" && !Array.isArray(value) ? value as SettingsRecord : undefined;
45
+ }
46
+
47
+ function mergeDeep<T extends SettingsRecord>(target: T, source: SettingsRecord): T {
48
+ for (const [key, value] of Object.entries(source)) {
49
+ const current = asRecord(target[key]);
50
+ const incoming = asRecord(value);
51
+ if (current && incoming) target[key as keyof T] = mergeDeep({ ...current }, incoming) as T[keyof T];
52
+ else target[key as keyof T] = value as T[keyof T];
53
+ }
54
+ return target;
55
+ }
56
+
57
+ function projectSettingsPath(cwd: string): string {
58
+ let current = resolve(cwd);
59
+ while (true) {
60
+ const candidate = join(current, ".pi", "settings.json");
61
+ if (existsSync(candidate)) return candidate;
62
+ if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".vstack-lock.json"))) return candidate;
63
+ const parent = dirname(current);
64
+ if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
65
+ current = parent;
66
+ }
67
+ }
68
+
69
+ function settingsPaths(cwd: string): string[] {
70
+ return [join(piUserDir(), "settings.json"), projectSettingsPath(cwd)];
71
+ }
72
+
73
+ export function tryParseJson(path: string): Partial<Config> {
74
+ if (!existsSync(path)) return {};
75
+ try {
76
+ return JSON.parse(readFileSync(path, "utf-8"));
77
+ } catch (e) {
78
+ console.error(`claude-bridge: failed to parse ${path}: ${e}`);
79
+ return {};
80
+ }
81
+ }
82
+
83
+ function readManagerConfig(cwd: string): SettingsRecord {
84
+ const merged: SettingsRecord = {};
85
+ for (const path of settingsPaths(cwd)) {
86
+ if (!existsSync(path)) continue;
87
+ try {
88
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
89
+ const configRoot = asRecord(asRecord(asRecord(parsed?.vstack)?.extensionManager)?.config);
90
+ const config = asRecord(configRoot?.[PACKAGE_ID]) ?? asRecord(configRoot?.[SCOPED_PACKAGE_ID]);
91
+ if (config) mergeDeep(merged, config);
92
+ } catch {
93
+ // Ignore malformed optional manager config; Pi will surface settings issues elsewhere.
94
+ }
95
+ }
96
+ return merged;
97
+ }
98
+
99
+ function boolFrom(raw: SettingsRecord, key: string): boolean | undefined {
100
+ return typeof raw[key] === "boolean" ? raw[key] as boolean : undefined;
101
+ }
102
+
103
+ function stringFrom(raw: SettingsRecord, key: string): string | undefined {
104
+ const value = raw[key];
105
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
106
+ }
107
+
108
+ function managerToConfig(raw: SettingsRecord): Partial<Config> {
109
+ const provider: Config["provider"] = {};
110
+ const promptContext: Config["promptContext"] = {};
111
+
112
+ const appendSystemPrompt = boolFrom(raw, "appendSystemPrompt");
113
+ if (appendSystemPrompt !== undefined) provider.appendSystemPrompt = appendSystemPrompt;
114
+ const strictMcpConfig = boolFrom(raw, "strictMcpConfig");
115
+ if (strictMcpConfig !== undefined) provider.strictMcpConfig = strictMcpConfig;
116
+ const claudePath = stringFrom(raw, "pathToClaudeCodeExecutable");
117
+ if (claudePath) provider.pathToClaudeCodeExecutable = claudePath;
118
+
119
+ const includeAppendSystemPromptMd = boolFrom(raw, "includeAppendSystemPromptMd");
120
+ if (includeAppendSystemPromptMd !== undefined) promptContext.includeAppendSystemPromptMd = includeAppendSystemPromptMd;
121
+ const includeProjectAgentsHook = boolFrom(raw, "includeProjectAgentsHook");
122
+ if (includeProjectAgentsHook !== undefined) promptContext.includeProjectAgentsHook = includeProjectAgentsHook;
123
+ const includeTaskPanelHook = boolFrom(raw, "includeTaskPanelHook");
124
+ if (includeTaskPanelHook !== undefined) promptContext.includeTaskPanelHook = includeTaskPanelHook;
125
+ const includeCavemanHook = boolFrom(raw, "includeCavemanHook");
126
+ if (includeCavemanHook !== undefined) promptContext.includeCavemanHook = includeCavemanHook;
127
+
128
+ return {
129
+ ...(boolFrom(raw, "enabled") !== undefined ? { enabled: boolFrom(raw, "enabled") } : {}),
130
+ ...(Object.keys(provider).length ? { provider } : {}),
131
+ ...(Object.keys(promptContext).length ? { promptContext } : {}),
132
+ };
133
+ }
134
+
135
+ export function loadConfig(cwd: string): Config {
136
+ const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
137
+ const project = tryParseJson(join(cwd, ".pi", "claude-bridge.json"));
138
+ const manager = managerToConfig(readManagerConfig(cwd));
139
+ return {
140
+ enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,
141
+ provider: { ...global.provider, ...project.provider, ...manager.provider },
142
+ promptContext: { ...global.promptContext, ...project.promptContext, ...manager.promptContext },
143
+ };
144
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,100 @@
1
+ // Pure pi→Anthropic message conversion helpers.
2
+ // Extracted so they can be tested without pulling in the full extension runtime.
3
+
4
+ import type { Message as PiMessage } from "@mariozechner/pi-ai";
5
+ import type { Message as SessionMessage } from "cc-session-io";
6
+ import { pascalCase } from "change-case";
7
+
8
+ export const PROVIDER_ID = "claude-bridge";
9
+
10
+ export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
11
+ read: "Read", write: "Write", edit: "Edit", bash: "Bash",
12
+ };
13
+
14
+ export function sanitizeToolId(id: string, cache: Map<string, string>): string {
15
+ const existing = cache.get(id);
16
+ if (existing) return existing;
17
+ const clean = id.replace(/[^a-zA-Z0-9_-]/g, "_");
18
+ cache.set(id, clean);
19
+ return clean;
20
+ }
21
+
22
+ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
23
+ if (!name) return "";
24
+ const normalized = name.toLowerCase();
25
+ if (customToolNameToSdk) {
26
+ const mapped = customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized);
27
+ if (mapped) return mapped;
28
+ }
29
+ if (PI_TO_SDK_TOOL_NAME[normalized]) return PI_TO_SDK_TOOL_NAME[normalized];
30
+ return pascalCase(name);
31
+ }
32
+
33
+ export function messageContentToText(
34
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
35
+ ): string {
36
+ if (typeof content === "string") return content;
37
+ if (!Array.isArray(content)) return "";
38
+ const parts = [];
39
+ let hasText = false;
40
+ for (const block of content) {
41
+ if (block.type === "text" && block.text) { parts.push(block.text); hasText = true; }
42
+ else if (block.type !== "text" && block.type !== "image") { parts.push(`[${block.type}]`); }
43
+ }
44
+ return hasText ? parts.join("\n") : "";
45
+ }
46
+
47
+ /** Convert pi message array to Anthropic API format. */
48
+ export function convertPiMessages(
49
+ messages: PiMessage[],
50
+ customToolNameToSdk?: Map<string, string>,
51
+ ): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string> } {
52
+ const anthropicMessages = [];
53
+ const sanitizedIds = new Map();
54
+
55
+ for (const msg of messages) {
56
+ if (msg.role === "user") {
57
+ if (typeof msg.content === "string") {
58
+ anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
59
+ } else if (Array.isArray(msg.content)) {
60
+ const parts = [];
61
+ for (const block of msg.content) {
62
+ if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
63
+ else if (block.type === "image" && block.data && block.mimeType) {
64
+ parts.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
65
+ }
66
+ }
67
+ anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
68
+ } else {
69
+ anthropicMessages.push({ role: "user", content: "[empty]" });
70
+ }
71
+ } else if (msg.role === "assistant") {
72
+ const content = Array.isArray(msg.content) ? msg.content : [];
73
+ const blocks = [];
74
+ for (const block of content) {
75
+ if (block.type === "text" && block.text) {
76
+ blocks.push({ type: "text", text: block.text });
77
+ } else if (block.type === "thinking") {
78
+ const sig = block.thinkingSignature;
79
+ const isAnthropicProvider = msg.provider === PROVIDER_ID || msg.api === "anthropic";
80
+ if (isAnthropicProvider && sig) {
81
+ blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
82
+ }
83
+ } else if (block.type === "toolCall") {
84
+ const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
85
+ blocks.push({ type: "tool_use", id: sanitizeToolId(block.id, sanitizedIds), name: toolName, input: block.arguments ?? {} });
86
+ }
87
+ }
88
+ if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
89
+ anthropicMessages.push({ role: "assistant", content: blocks });
90
+ } else if (msg.role === "toolResult") {
91
+ const text = typeof msg.content === "string" ? msg.content : messageContentToText(msg.content);
92
+ anthropicMessages.push({
93
+ role: "user",
94
+ content: [{ type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: text || "", is_error: msg.isError }],
95
+ });
96
+ }
97
+ }
98
+
99
+ return { anthropicMessages, sanitizedIds };
100
+ }
@@ -0,0 +1,47 @@
1
+ // Tool-result extraction: walks the context tail to collect this turn's
2
+ // tool results. Pi appends results to context and calls the provider again;
3
+ // this scrapes them back out. Walks past user messages (steer/followUp) that
4
+ // pi may inject between toolResults. Stops at the nearest assistant message
5
+ // (turn boundary).
6
+ // Extracted from index.ts so tests can import without activating the extension.
7
+
8
+ export type McpContent = Array<
9
+ | { type: "text"; text: string }
10
+ | { type: "image"; data: string; mimeType: string }
11
+ >;
12
+
13
+ export interface McpResult {
14
+ content: McpContent;
15
+ isError?: boolean;
16
+ toolCallId?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ export function toolResultToMcpContent(
21
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
22
+ ): McpContent {
23
+ if (typeof content === "string") return [{ type: "text", text: content || "" }];
24
+ if (!Array.isArray(content)) return [{ type: "text", text: "" }];
25
+ const blocks: McpContent = [];
26
+ for (const block of content) {
27
+ if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
28
+ else if (block.type === "image" && block.data && block.mimeType) blocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
29
+ }
30
+ return blocks.length ? blocks : [{ type: "text", text: "" }];
31
+ }
32
+
33
+ // Returns { results, stopIdx } so callers can log the walk boundary.
34
+ export function extractAllToolResults(
35
+ messages: Array<{ role: string; content?: unknown; toolCallId?: string; isError?: boolean; [key: string]: unknown }>,
36
+ ): { results: McpResult[]; stopIdx: number } {
37
+ const results: McpResult[] = [];
38
+ let stopIdx = -1;
39
+ for (let i = messages.length - 1; i >= 0; i--) {
40
+ const msg = messages[i];
41
+ if (msg.role === "toolResult") {
42
+ results.unshift({ content: toolResultToMcpContent(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>), isError: msg.isError, toolCallId: msg.toolCallId });
43
+ } else if (msg.role === "assistant") { stopIdx = i; break; }
44
+ // user messages: skip (steer/followUp injected mid-tool-execution)
45
+ }
46
+ return { results, stopIdx };
47
+ }