How the Conversion Pipeline Handles Hooks and Events in the Compound Engineering Plugin
The conversion pipeline processes Claude hooks through three stages: loading and merging hook definitions from src/parsers/claude.ts, mapping Claude hook events to OpenCode events via the HOOK_EVENT_MAP in src/converters/claude-to-opencode.ts, and rendering TypeScript handler blocks that become executable OpenCode plugins.
The Compound Engineering Plugin converts Claude Code plugins into OpenCode bundles, transforming proprietary hook definitions into standardized event handlers. When a plugin contains Claude-style hooks, the conversion pipeline handles the translation through a systematic three-stage process that preserves the original logic while adapting it to the OpenCode event system.
Stage 1: Loading and Merging Claude Hook Definitions
The pipeline begins by ingesting hook definitions from the source Claude plugin. The loadHooks function in src/parsers/claude.ts (lines 21-45) reads the default hooks/hooks.json file and any additional hook files referenced in the plugin manifest.
const hooks = await loadHooks(root, manifest.hooks) // src/parsers/claude.ts
If a hooks field is present in plugin.json or a hooks/hooks.json file exists, both are read and merged via mergeHooks into a single ClaudeHooks object. This unified structure serves as the input for the subsequent conversion stages.
Stage 2: Mapping Claude Hook Events to OpenCode Events
Once loaded, the pipeline translates Claude hook event names into their OpenCode equivalents. The convertClaudeToOpenCode function calls convertHooks, which references the HOOK_EVENT_MAP constant defined in src/converters/claude-to-opencode.ts (lines 48-62).
const HOOK_EVENT_MAP: Record<string, HookEventMapping> = {
PreToolUse: { events: ["tool.execute.before"], type: "tool" },
PostToolUse: { events: ["tool.execute.after"], type: "tool" },
// … other mappings …
};
Each Claude hook name (e.g., PreToolUse) maps to one or more OpenCode event identifiers (tool.execute.before). The mapping also specifies the handler type—such as tool, session, permission, or message—which determines how the subsequent code generation handles conditional logic and action rendering. Unmapped events are collected and added as comments in the generated file for debugging purposes.
Stage 3: Rendering Hook Handlers
The final stage generates executable TypeScript code. For every matching event, renderHookHandlers creates a handler block. The function iterates over Claude matchers, builds conditional statements, and embeds hook actions (command, prompt, or agent).
for (const [eventName, matchers] of Object.entries(hookMap)) {
const mapping = HOOK_EVENT_MAP[eventName];
if (!mapping) { unmappedEvents.push(eventName); continue; }
for (const event of mapping.events) {
handlerBlocks.push(
renderHookHandlers(event, matchers, {
useToolMatcher: mapping.type === "tool" || mapping.type === "permission",
requireError: !!mapping.requireError,
note: mapping.note,
})
);
}
}
When the hook is a tool hook, the matcher's matcher string is split into individual tool names. If a specific tool list is present, the pipeline generates a condition such as input.tool === "read" || input.tool === "write".
Action rendering depends on the hook.type:
- command →
await $\`` (optionally guarded by the condition) - prompt → a comment describing the prompt
- agent → a comment describing the agent hook
The result is a TypeScript object where each OpenCode event is an async handler returning the composed statements. This file (converted-hooks.ts) becomes part of the OpenCode bundle returned by convertClaudeToOpenCode and is written to the target output directory via src/targets/opencode.ts.
Code Examples
Example Claude Hook Definition
Here is a typical Claude hook configuration in hooks/hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "read | write",
"hooks": [
{ "type": "command", "command": "log-start", "timeout": 5 }
]
}
],
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{ "type": "prompt", "prompt": "User said: {{input.message}}" }
]
}
]
}
}
Generated OpenCode Hook File
The pipeline generates the following converted-hooks.ts:
// Unmapped Claude hook events: … (none in this example)
import type { Plugin } from "@opencode-ai/plugin"
export const ConvertedHooks: Plugin = async ({ $ }) => {
return {
"tool.execute.before": async (input) => {
// PreToolUse → tool.execute.before
if (input.tool === "read" || input.tool === "write") { await $`log-start` }
// timeout: 5s (not enforced)
},
"message.created": async (input) => {
// UserPromptSubmit → message.created
// Prompt hook for *: User said: {{input.message}}
}
}
}
export default ConvertedHooks
Notice how the matcher becomes a conditional if statement, and each Claude hook type is turned into a comment or an executable command.
Summary
- The conversion pipeline processes Claude hooks through three distinct stages: loading and merging definitions, mapping events via
HOOK_EVENT_MAP, and rendering TypeScript handlers. - The
loadHooksfunction insrc/parsers/claude.tsingests and merges hook files into a unifiedClaudeHooksobject. - The
HOOK_EVENT_MAPinsrc/converters/claude-to-opencode.tstranslates Claude event names likePreToolUseinto OpenCode events liketool.execute.before. - The
renderHookHandlersfunction generates conditional logic and action blocks, convertingcommandhooks into executable shell calls andprompt/agenthooks into descriptive comments. - The final output is a
converted-hooks.tsfile exported as an OpenCode plugin viasrc/targets/opencode.ts.
Frequently Asked Questions
How does the conversion pipeline handle unsupported Claude hook events?
Unmapped events are collected during the conversion process and added as comments at the top of the generated converted-hooks.ts file. This allows developers to identify which hooks require manual implementation while ensuring the conversion does not fail on unrecognized event types.
What is the difference between tool hooks and message hooks in the conversion process?
Tool hooks (such as PreToolUse and PostToolUse) trigger the useToolMatcher flag in renderHookHandlers, which generates conditional statements checking input.tool against specific tool names. Message hooks (such as UserPromptSubmit) map to message.created events and typically render as comments describing the prompt content rather than executable conditions.
Can the conversion pipeline handle multiple hook files?
Yes. The loadHooks function in src/parsers/claude.ts reads both the default hooks/hooks.json file and any additional hook files referenced in the plugin manifest's hooks field. The mergeHooks utility combines these into a single ClaudeHooks object, allowing distributed hook definitions to be consolidated before conversion.
How are command timeouts handled in the generated OpenCode plugin?
The conversion pipeline preserves timeout values as comments in the generated code (e.g., // timeout: 5s (not enforced)), but does not implement automatic timeout enforcement in the rendered handler. Developers must manually implement timeout logic in the OpenCode environment if strict timeout control is required for command hooks.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →