Commit 81bbedeaf66c

Vincent Demeester <vincent@sbr.pm>
2026-02-02 10:10:09
feat(opencode): add plugin to wrap Claude Code hooks
Port Claude Code hooks to OpenCode via TypeScript plugin: - tool.execute.before: validate-git-push (blocks bare git push) - tool.execute.after: capture-tool-output, update-terminal-title - session.created: initialize-session Plugin translates OpenCode event format to Claude Code JSON format and shells out to existing Go binaries. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ae65494
Changed files (2)
dots
.config
opencode
dots/.config/opencode/plugin/claude-hooks.ts
@@ -0,0 +1,121 @@
+/**
+ * OpenCode plugin that wraps Claude Code hooks (Go binaries)
+ *
+ * This plugin provides compatibility with existing claude-hooks-* binaries
+ * by translating OpenCode's event format to Claude Code's JSON format.
+ *
+ * Hooks ported:
+ * - tool.execute.before -> claude-hooks-validate-git-push
+ * - tool.execute.after  -> claude-hooks-capture-tool-output, claude-hooks-update-terminal-title
+ * - session.created     -> claude-hooks-initialize-session
+ * - session.idle        -> claude-hooks-save-session (approximation of SessionEnd)
+ */
+
+import type { Plugin } from "@opencode-ai/plugin"
+
+/**
+ * Convert OpenCode tool input to Claude Code PreToolUse format
+ */
+function toClaudePreToolUse(input: any): string {
+  return JSON.stringify({
+    tool_name: capitalizeFirst(input.tool || input.call?.name || "unknown"),
+    tool_input: input.args || input.call?.input || {},
+    conversation_id: "opencode",
+  })
+}
+
+/**
+ * Convert OpenCode tool result to Claude Code PostToolUse format
+ */
+function toClaudePostToolUse(input: any, result: any): string {
+  return JSON.stringify({
+    tool_name: capitalizeFirst(input.tool || input.call?.name || "unknown"),
+    tool_input: input.args || input.call?.input || {},
+    tool_response: result || {},
+    conversation_id: "opencode",
+    timestamp: new Date().toISOString(),
+  })
+}
+
+function capitalizeFirst(str: string): string {
+  return str.charAt(0).toUpperCase() + str.slice(1)
+}
+
+export const ClaudeHooksPlugin: Plugin = async ({ $ }) => {
+  // Track if we've already run session init (debounce)
+  let sessionInitialized = false
+
+  return {
+    /**
+     * PreToolUse equivalent - validate git push commands
+     */
+    "tool.execute.before": async (input, output) => {
+      // Only check bash/shell commands
+      const toolName = input.tool || input.call?.name || ""
+      if (toolName.toLowerCase() !== "bash" && toolName.toLowerCase() !== "shell") {
+        return
+      }
+
+      try {
+        const jsonInput = toClaudePreToolUse(input)
+        const result = await $`echo ${jsonInput} | claude-hooks-validate-git-push`.quiet()
+
+        if (result.exitCode !== 0) {
+          // Block the command
+          const stderr = result.stderr.toString().trim()
+          output.abort = stderr || "Blocked by claude-hooks-validate-git-push"
+        }
+      } catch (error) {
+        // Don't block on hook errors - fail open
+        console.error("[claude-hooks] validate-git-push error:", error)
+      }
+    },
+
+    /**
+     * PostToolUse equivalent - capture tool output and update terminal title
+     */
+    "tool.execute.after": async (input, output) => {
+      try {
+        const jsonInput = toClaudePostToolUse(input, output)
+
+        // Run both hooks in parallel (fire and forget)
+        await Promise.allSettled([
+          $`echo ${jsonInput} | claude-hooks-capture-tool-output`.quiet(),
+          $`echo ${jsonInput} | claude-hooks-update-terminal-title`.quiet(),
+        ])
+      } catch (error) {
+        // Silent failure - don't disrupt workflow
+        console.error("[claude-hooks] post-tool error:", error)
+      }
+    },
+
+    /**
+     * Event handler for session lifecycle
+     */
+    event: async ({ event }) => {
+      // SessionStart equivalent
+      if (event.type === "session.created" && !sessionInitialized) {
+        sessionInitialized = true
+        try {
+          await $`claude-hooks-initialize-session`.quiet()
+        } catch (error) {
+          console.error("[claude-hooks] initialize-session error:", error)
+        }
+      }
+
+      // SessionEnd equivalent (session.idle is closest approximation)
+      // Note: This triggers when the session becomes idle, not on explicit end
+      // Uncomment if you want save-session prompts on idle:
+      // if (event.type === "session.idle") {
+      //   try {
+      //     await $`claude-hooks-save-session`.quiet()
+      //   } catch (error) {
+      //     console.error("[claude-hooks] save-session error:", error)
+      //   }
+      // }
+    },
+  }
+}
+
+// Default export for OpenCode plugin discovery
+export default ClaudeHooksPlugin
dots/Makefile
@@ -53,9 +53,10 @@ lazypr : ~/.config/lazypr/config.toml
 all += gh-news
 gh-news : ~/.config/gh-news/config.toml
 
-all += git-template copilot-hooks
+all += git-template copilot-hooks opencode-plugin
 git-template : ~/.config/git/template
 copilot-hooks : ~/.config/copilot-hooks
+opencode-plugin : ~/.config/opencode/plugin
 
 # Backward compatibility: symlink ~/.claude to ~/.config/claude
 ~/.claude : force