Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ Claude Code sends 24 core tools (plus user-specific MCP tools). OpenCode has 10.

The bridge resolves this by:

1. **Replacing OpenCode's tool definitions only for Claude-compatible targets** with Claude Code's exact wire-captured definitions — matching descriptions, JSON schemas, parameter names, and required fields.
2. **Adding 14 Claude-only stub tools**: `AskUserQuestion`, `CronCreate`, `CronDelete`, `CronList`, `EnterPlanMode`, `EnterWorktree`, `ExitPlanMode`, `ExitWorktree`, `Monitor`, `NotebookEdit`, `RemoteTrigger`, `TaskOutput`, `TaskStop`, `WebSearch`.
3. **Sorting all 24 tools alphabetically** to match Claude Code's ordering.
1. **Replacing active OpenCode shared core tool definitions only for Claude-compatible targets** with Claude Code's exact wire-captured definitions — matching descriptions, JSON schemas, parameter names, and required fields.
2. **Preserving active OpenCode custom/MCP tool definitions** so project-specific MCP servers remain available to Claude models.
3. **Selecting Claude-only alias/stub schemas only when the matching OpenCode tool is active** (for example `question` → `AskUserQuestion`, `plan_enter` → `EnterPlanMode`).
4. **Sorting selected tools alphabetically** to match Claude Code's ordering.

If the model calls a stub tool, OpenCode's built-in error handling catches it, tells the model the tool is unavailable, and the model adapts on the next turn.

Expand Down
105 changes: 102 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import {
shouldUseClaudeToolSchemas,
} from "./claude-tools.js";
import { extractOAuthErrorDetail } from "./oauth.js";
import { getClaudeToolsForActiveOpenCodeTools, shouldInjectClaudeTools, stripAssistantPrefillForClaude } from "./index.js";
import {
getClaudeToolsForActiveOpenCodeTools,
getInboundToolNameMapForActiveOpenCodeTools,
mapOutboundToolName,
shouldInjectClaudeTools,
stripAssistantPrefillForClaude,
} from "./index.js";
import { createSseProcessor, parseSseEvent, buildSseEvent } from "./stream.js";
import {
deriveModelDisplayName,
Expand Down Expand Up @@ -235,9 +241,102 @@ describe("tool schema selection", () => {
it("does not advertise WebSearch when only a custom websearch_cited tool is active", () => {
const out = getClaudeToolsForActiveOpenCodeTools([
{ name: "webfetch" },
{ name: "websearch_cited" },
{
name: "websearch_cited",
description: "Search the web with citations",
input_schema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
},
]).map((tool) => tool.name).sort();
assert.deepEqual(out, ["WebFetch"]);
assert.deepEqual(out, ["WebFetch", "websearch_cited"]);
});

it("preserves active MCP and custom OpenCode tools", () => {
const customTool = {
name: "custom_mcp_query_record",
description: "Get a record by ID",
input_schema: {
type: "object",
properties: { recordId: { type: "string" } },
required: ["recordId"],
},
};
const out = getClaudeToolsForActiveOpenCodeTools([
{ name: "bash", description: "OpenCode bash", input_schema: { type: "object" } },
customTool,
]);

const bashTool = out.find((tool) => tool.name === "Bash");
const preservedTool = out.find((tool) => tool.name === customTool.name);

assert.ok(bashTool);
assert.notEqual(bashTool.description, "OpenCode bash");
assert.deepEqual(preservedTool, customTool);
});

it("preserves MCP tools that have no description", () => {
const out = getClaudeToolsForActiveOpenCodeTools([
{
name: "custom_mcp_get_timestamp",
input_schema: { type: "object", properties: {} },
},
]);

assert.deepEqual(out, [
{
name: "custom_mcp_get_timestamp",
description: "",
input_schema: { type: "object", properties: {} },
},
]);
});

it("preserves MCP tools whose names look like prefixed core tools", () => {
const mcpTool = {
name: "mcp_bash",
description: "Run a command through an MCP server",
input_schema: {
type: "object",
properties: { command: { type: "string" } },
required: ["command"],
},
};
const out = getClaudeToolsForActiveOpenCodeTools([mcpTool]);

assert.deepEqual(out, [mcpTool]);
});

it("preserves custom tools whose names collide with Claude core tool names", () => {
const customTool = {
name: "Bash",
description: "Custom Bash-like MCP tool",
input_schema: {
type: "object",
properties: { script: { type: "string" } },
required: ["script"],
},
};
const out = getClaudeToolsForActiveOpenCodeTools([customTool]);

assert.deepEqual(out, [customTool]);
});

it("maps inbound Claude core names only for active OpenCode core tools", () => {
assert.deepEqual(getInboundToolNameMapForActiveOpenCodeTools([
{ name: "bash" },
{ name: "mcp_bash", input_schema: { type: "object" } },
]), { Bash: "bash" });
assert.deepEqual(getInboundToolNameMapForActiveOpenCodeTools([
{ name: "Bash", input_schema: { type: "object" } },
]), {});
});

it("maps outbound OpenCode core history but preserves MCP-prefixed names", () => {
assert.equal(mapOutboundToolName("bash"), "Bash");
assert.equal(mapOutboundToolName("mcp_bash"), "mcp_bash");
});

it("does not advertise AskUserQuestion when OpenCode did not enable question", () => {
Expand Down
103 changes: 79 additions & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
extractFirstUserMessageText,
shouldUseClaudeToolSchemas,
} from "./claude-tools.js";
import type { ToolDefinition } from "./claude-tools.js";
import { createSseProcessor } from "./stream.js";

// ── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -79,18 +80,23 @@ const OUTBOUND_TOOL_NAME_MAP: Record<string, string> = {
webfetch: "WebFetch",
todowrite: "TodoWrite",
skill: "Skill",
mcp_bash: "Bash",
mcp_read: "Read",
mcp_glob: "Glob",
mcp_grep: "Grep",
mcp_edit: "Edit",
mcp_write: "Write",
mcp_task: "Agent",
mcp_webfetch: "WebFetch",
mcp_todowrite: "TodoWrite",
mcp_skill: "Skill",
question: "AskUserQuestion",
mcp_question: "AskUserQuestion",
plan_enter: "EnterPlanMode",
plan_exit: "ExitPlanMode",
};

const ACTIVE_TOOL_SCHEMA_NAME_MAP: Record<string, string> = {
bash: "Bash",
read: "Read",
glob: "Glob",
grep: "Grep",
edit: "Edit",
write: "Write",
task: "Agent",
webfetch: "WebFetch",
todowrite: "TodoWrite",
skill: "Skill",
question: "AskUserQuestion",
plan_enter: "EnterPlanMode",
plan_exit: "ExitPlanMode",
};
Expand Down Expand Up @@ -220,21 +226,68 @@ export function shouldInjectClaudeTools(input: {
return Array.isArray(input.tools) && input.tools.length > 0;
}

function getToolName(tool: unknown): string | undefined {
if (!tool || typeof tool !== "object") return undefined;
const name = (tool as { name?: unknown }).name;
return typeof name === "string" ? name : undefined;
}

function getToolInputSchema(tool: unknown): Record<string, unknown> | undefined {
if (!tool || typeof tool !== "object") return undefined;
const inputSchema = (tool as { input_schema?: unknown }).input_schema;
if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema)) return undefined;
return inputSchema as Record<string, unknown>;
}

export function getClaudeToolsForActiveOpenCodeTools(
tools: unknown,
): ReturnType<typeof getClaudeTools> {
): ToolDefinition[] {
if (!Array.isArray(tools)) return [];
const activeClaudeNames = new Set(
tools
.map((tool) => {
if (!tool || typeof tool !== "object") return undefined;
const name = (tool as { name?: unknown }).name;
if (typeof name !== "string") return undefined;
return OUTBOUND_TOOL_NAME_MAP[name] || name;
})
.filter((name): name is string => typeof name === "string"),
const claudeToolsByName = new Map(getClaudeTools().map((tool) => [tool.name, tool]));
const selectedToolsByName = new Map<string, ToolDefinition>();

for (const tool of tools) {
const name = getToolName(tool);
if (!name) continue;

const claudeName = ACTIVE_TOOL_SCHEMA_NAME_MAP[name];
if (claudeName) {
const claudeTool = claudeToolsByName.get(claudeName);
if (!claudeTool) continue;
selectedToolsByName.set(claudeName, claudeTool);
continue;
}

const inputSchema = getToolInputSchema(tool);
if (!inputSchema) continue;

const description = (tool as { description?: unknown }).description;
selectedToolsByName.set(name, {
name,
description: typeof description === "string" ? description : "",
input_schema: inputSchema,
});
}

return Array.from(selectedToolsByName.values()).sort((a, b) =>
a.name.localeCompare(b.name),
);
return getClaudeTools().filter((tool) => activeClaudeNames.has(tool.name));
}

export function getInboundToolNameMapForActiveOpenCodeTools(
tools: unknown,
): Record<string, string> {
if (!Array.isArray(tools)) return {};
const inboundToolNameMap: Record<string, string> = {};

for (const tool of tools) {
const name = getToolName(tool);
if (!name) continue;
const claudeName = ACTIVE_TOOL_SCHEMA_NAME_MAP[name];
if (claudeName) inboundToolNameMap[claudeName] = name;
}

return inboundToolNameMap;
}

const oauthProfileCache = new Map<string, Promise<OAuthProfile | null>>();
Expand Down Expand Up @@ -392,7 +445,7 @@ function deduplicatePrefix(text: string): string {
return text;
}

function mapOutboundToolName(name: string | undefined): string | undefined {
export function mapOutboundToolName(name: string | undefined): string | undefined {
if (!name) return name;
return OUTBOUND_TOOL_NAME_MAP[name] || name;
}
Expand Down Expand Up @@ -644,6 +697,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {

// ── Body ──
let body = init?.body;
let inboundToolNameMap = INBOUND_TOOL_NAME_MAP;
if (body && typeof body === "string") {
try {
const parsed = JSON.parse(body);
Expand Down Expand Up @@ -758,6 +812,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
// requests or Claude-family models on Anthropic-compatible
// routers such as OpenRouter.
if (shouldInjectClaudeTools({ model: parsed.model, requestUrl, tools: parsed.tools })) {
inboundToolNameMap = getInboundToolNameMapForActiveOpenCodeTools(parsed.tools);
parsed.tools = getClaudeToolsForActiveOpenCodeTools(parsed.tools);
}
delete parsed.tool_choice;
Expand Down Expand Up @@ -869,7 +924,7 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
// split across TCP chunks) that regex-on-raw-bytes can't
// handle. See src/stream.ts for the processor implementation.
const processor = createSseProcessor({
inboundToolNameMap: INBOUND_TOOL_NAME_MAP,
inboundToolNameMap,
translateToolArgs: translateToolArgsJsonString,
});

Expand Down