Skip to content
Merged
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 26 captured core tools (plus user-specific MCP tools). OpenCod

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 16 Claude-only stub tools**: `AskUserQuestion`, `CronCreate`, `CronDelete`, `CronList`, `EnterPlanMode`, `EnterWorktree`, `ExitPlanMode`, `ExitWorktree`, `Monitor`, `NotebookEdit`, `PushNotification`, `RemoteTrigger`, `ScheduleWakeup`, `TaskOutput`, `TaskStop`, `WebSearch`.
3. **Sorting all 26 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
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ function detectClaudeVersion(): string {
return "2.1.98";
}

/**
* Identity opencode registers this plugin under. Required: opencode refuses to
* load a plugin referenced by filesystem path unless its module exports an id.
*/
export const PLUGIN_ID = "opencode-claude-bridge";

export const CLIENT_ID =
process.env.ANTHROPIC_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e";

Expand Down
114 changes: 112 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import {
} from "./claude-tools.js";
import { extractOAuthErrorDetail } from "./oauth.js";
import {
default as pluginModule,
deriveModelDisplayName,
getClaudeToolsForActiveOpenCodeTools,
getInboundToolNameMapForActiveOpenCodeTools,
mapOutboundToolName,
rewriteSystemBlocksForModel,
shouldInjectClaudeTools,
stripAssistantPrefillForClaude,
Expand Down Expand Up @@ -250,9 +253,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 Expand Up @@ -1128,3 +1224,17 @@ describe("rewriteSystemBlocksForModel", () => {
assert.equal(out[1].text, "no identity line here");
});
});

describe("plugin module shape", () => {
it("default-exports { id, server } so opencode's loader recognises it", () => {
// A bare function default export sends opencode down its legacy path, where
// it calls every named export of this module as a plugin factory — which
// crashes on the helpers above and takes the whole plugin down with it.
assert.equal(typeof pluginModule, "object");
assert.equal(typeof pluginModule.server, "function");
});

it("carries an id, which opencode requires for path-referenced plugins", () => {
assert.equal(pluginModule.id, "opencode-claude-bridge");
});
});
122 changes: 97 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
DEFAULT_EFFORT,
CLEAR_THINKING_TYPE,
SESSION_ID,
PLUGIN_ID,
} from "./constants.js";
import {
getClaudeTools,
Expand All @@ -33,6 +34,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 +81,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 @@ -230,21 +237,73 @@ 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) {
console.error(
`[opencode-claude-bridge] Dropping active tool "${name}" — missing or invalid input_schema`,
);
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 @@ -411,7 +470,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 @@ -664,6 +723,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 @@ -779,6 +839,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 @@ -890,7 +951,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 Expand Up @@ -1074,4 +1135,15 @@ const OpenCodeClaudeBridge = async ({ client }: { client: PluginClient }) => {
};
};

export default OpenCodeClaudeBridge;
/**
* opencode plugin module.
*
* The `{ id, server }` shape is what opencode's plugin loader looks for. A bare
* function default export makes it fall back to its legacy path, which treats
* every named export of this module as a plugin factory and invokes it with
* `(input, options)` — crashing on the helpers exported here for tests.
*/
export default {
id: PLUGIN_ID,
server: OpenCodeClaudeBridge,
};