Commit bc1f668bce38

Vincent Demeester <vincent@sbr.pm>
2026-02-11 06:49:00
feat: rewrite opencode plugin natively, add skills/instructions
Rewrote opencode plugin with native path validation and git push checks instead of shelling out to Claude hooks. Added instructions and skills config to opencode.json.example. Config stays machine-local (not symlinked) to avoid leaking provider credentials. Plugin reads shared path-policies.json and logs sessions to ~/.local/share/ai/ like pi and claude.
1 parent e02115a
Changed files (2)
dots
dots/config/opencode/plugin/claude-hooks.ts
@@ -1,121 +1,258 @@
 /**
- * OpenCode plugin that wraps Claude Code hooks (TypeScript hooks)
+ * OpenCode plugin for unified AI agent behavior
  *
- * This plugin provides compatibility with existing TypeScript hooks in ~/.config/claude/hooks/
- * by translating OpenCode's event format to Claude Code's JSON format.
+ * Provides:
+ * - validate-git-push: Block unsafe git push/add commands
+ * - validate-write-path: Block writes to old locations, enforce filename format
+ * - session logging: Log session start to unified AI storage
+ * - terminal title: Update terminal title on tool use
  *
- * Hooks ported:
- * - tool.execute.before -> bun run ~/.config/claude/hooks/validate-git-push.ts
- * - tool.execute.after  -> bun run ~/.config/claude/hooks/capture-tool-output.ts, bun run ~/.config/claude/hooks/update-terminal-title.ts
- * - session.created     -> bun run ~/.config/claude/hooks/initialize-session.ts
- * - session.idle        -> bun run ~/.config/claude/hooks/save-session.ts (approximation of SessionEnd)
+ * Shares behavior with Claude hooks (~/.config/claude/hooks/) and
+ * pi extensions (~/.pi/agent/extensions/).
  */
 
 import type { Plugin } from "@opencode-ai/plugin"
+import { readFileSync, appendFileSync, mkdirSync, existsSync } from "fs"
+import { join, basename } from "path"
+import { homedir } from "os"
+import { execSync } from "child_process"
 
-/**
- * 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",
-  })
+// ── Shared utilities (matches lib.ts from Claude hooks) ──
+
+const AI_BASE = join(homedir(), ".local", "share", "ai")
+
+function todayStr(): string {
+  const d = new Date()
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
 }
 
-/**
- * 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 monthStr(): string {
+  const d = new Date()
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
 }
 
-function capitalizeFirst(str: string): string {
-  return str.charAt(0).toUpperCase() + str.slice(1)
+function slug(title: string): string {
+  return title
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-+|-+$/g, "")
+    .substring(0, 60)
 }
 
-export const ClaudeHooksPlugin: Plugin = async ({ $ }) => {
-  // Track if we've already run session init (debounce)
+function expandHome(p: string): string {
+  return p.replace(/^~/, homedir())
+}
+
+function getHostname(): string {
+  try {
+    return execSync("hostname", { encoding: "utf-8" }).trim()
+  } catch {
+    return "unknown"
+  }
+}
+
+// ── Path policies (shared with Claude and Pi) ──
+
+interface PathPolicy {
+  name: string
+  description: string
+  filenamePattern?: string
+  pathPattern?: string
+  allowedPaths?: string[]
+  blockedPaths?: string[]
+  action: "block" | "warn"
+  enabled: boolean
+}
+
+function loadPathPolicies(): PathPolicy[] {
+  try {
+    const raw = readFileSync(
+      expandHome("~/.config/ai/path-policies.json"),
+      "utf-8"
+    )
+    const data = JSON.parse(raw)
+    return [
+      ...(data.filenameRules || []),
+      ...(data.locationPolicies || []),
+    ].filter((p: PathPolicy) => p.enabled)
+  } catch {
+    return []
+  }
+}
+
+function checkPathPolicies(
+  filePath: string
+): { blocked: boolean; reason?: string } {
+  const policies = loadPathPolicies()
+  const expanded = expandHome(filePath)
+  const fname = basename(expanded)
+
+  for (const policy of policies) {
+    // Check filename pattern
+    if (policy.filenamePattern) {
+      const re = new RegExp(policy.filenamePattern)
+      if (!re.test(fname)) continue
+    }
+
+    // Check path pattern
+    if (policy.pathPattern) {
+      const re = new RegExp(policy.pathPattern.replace(/\//g, "\\/"))
+      if (!re.test(expanded)) continue
+    }
+
+    // Check blocked paths
+    if (policy.blockedPaths) {
+      const inBlocked = policy.blockedPaths.some((bp) => {
+        const exp = expandHome(bp).replace(/\*/g, ".*")
+        return new RegExp(`^${exp}`).test(expanded)
+      })
+      if (inBlocked && policy.action === "block") {
+        return {
+          blocked: true,
+          reason: `[${policy.name}] ${policy.description}`,
+        }
+      }
+    }
+
+    // Check allowed paths (if specified, path must match one)
+    if (policy.allowedPaths && !policy.blockedPaths) {
+      const inAllowed = policy.allowedPaths.some((ap) => {
+        const exp = expandHome(ap).replace(/\*/g, ".*")
+        return new RegExp(`^${exp}`).test(expanded)
+      })
+      if (!inAllowed && policy.action === "block") {
+        return {
+          blocked: true,
+          reason: `[${policy.name}] ${policy.description}`,
+        }
+      }
+    }
+  }
+
+  return { blocked: false }
+}
+
+// ── Git validation (matches validate-git-push.ts) ──
+
+function isUnsafeGitCommand(command: string): string | null {
+  // Split on && || ; to check each subcommand
+  const commands = command.split(/\s*(?:&&|\|\||;)\s*/)
+
+  for (const cmd of commands) {
+    const trimmed = cmd.trim()
+
+    // git push without explicit refspec
+    if (/^git\s+push\b/.test(trimmed)) {
+      const parts = trimmed.split(/\s+/)
+      // git push = 2 parts (bare push)
+      // git push origin = 3 parts (no refspec)
+      // git push origin branch:branch = 4+ parts (safe)
+      // git push --flags origin = need to skip flags
+      const nonFlags = parts.filter((p) => !p.startsWith("-") && p !== "git" && p !== "push")
+      if (nonFlags.length < 2) {
+        return `Unsafe: \`${trimmed}\` — must specify refspec (e.g., git push origin branch:branch)`
+      }
+      // Check refspec has colon
+      const lastArg = parts[parts.length - 1]
+      if (!lastArg.includes(":") && !lastArg.startsWith("-")) {
+        return `Unsafe: \`${trimmed}\` — refspec must use branch:branch format`
+      }
+    }
+
+    // git add . or git add -A (too broad)
+    if (/^git\s+add\s+(\.|--all|-A)\s*$/.test(trimmed)) {
+      return `Unsafe: \`${trimmed}\` — use explicit file paths instead`
+    }
+  }
+
+  return null
+}
+
+// ── Plugin ──
+
+const plugin: Plugin = async ({ $ }) => {
   let sessionInitialized = false
 
   return {
     /**
-     * PreToolUse equivalent - validate git push commands
+     * Validate tool calls before execution
      */
     "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
+      const toolName = (input.tool || "").toLowerCase()
+
+      // Validate git commands in bash/shell
+      if (toolName === "bash" || toolName === "shell") {
+        const command = output.args?.command || output.args?.input || ""
+        if (typeof command === "string") {
+          const reason = isUnsafeGitCommand(command)
+          if (reason) {
+            output.args = { ...output.args, command: `echo "BLOCKED: ${reason}"` }
+            return
+          }
+        }
       }
 
-      try {
-        const jsonInput = toClaudePreToolUse(input)
-        const result = await $`echo ${jsonInput} | bun run ~/.config/claude/hooks/validate-git-push.ts`.quiet()
-
-        if (result.exitCode !== 0) {
-          // Block the command
-          const stderr = result.stderr.toString().trim()
-          output.abort = stderr || "Blocked by bun run ~/.config/claude/hooks/validate-git-push.ts"
+      // Validate write/edit paths
+      if (toolName === "write" || toolName === "edit") {
+        const filePath = output.args?.file_path || output.args?.path || output.args?.filePath || ""
+        if (typeof filePath === "string" && filePath) {
+          const check = checkPathPolicies(filePath)
+          if (check.blocked) {
+            if (toolName === "write") {
+              output.args = {
+                ...output.args,
+                command: `echo "BLOCKED: ${check.reason}"`,
+              }
+            }
+            // For edit, we can't easily block, but we log a warning
+            console.error(`[opencode-hooks] BLOCKED ${toolName}: ${check.reason}`)
+            return
+          }
         }
-      } 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
+     * Update terminal title after tool use
      */
     "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} | bun run ~/.config/claude/hooks/capture-tool-output.ts`.quiet(),
-          $`echo ${jsonInput} | bun run ~/.config/claude/hooks/update-terminal-title.ts`.quiet(),
-        ])
-      } catch (error) {
-        // Silent failure - don't disrupt workflow
-        console.error("[claude-hooks] post-tool error:", error)
+        // Simple terminal title update
+        const cwd = process.cwd()
+        const project = basename(cwd)
+        process.stdout.write(`\x1b]0;opencode: ${project}\x07`)
+      } catch {
+        // Silent failure
       }
     },
 
     /**
-     * Event handler for session lifecycle
+     * Session lifecycle events
      */
     event: async ({ event }) => {
-      // SessionStart equivalent
       if (event.type === "session.created" && !sessionInitialized) {
         sessionInitialized = true
+
         try {
-          await $`bun run ~/.config/claude/hooks/initialize-session.ts`.quiet()
+          // Log session start to unified AI storage
+          const sessionDir = join(AI_BASE, "sessions", monthStr())
+          mkdirSync(sessionDir, { recursive: true })
+
+          const logFile = join(sessionDir, `${todayStr()}_session-log.txt`)
+          const now = new Date().toISOString().replace("Z", "+00:00")
+          const host = getHostname()
+          const entry = `${now} - Session started (opencode) on ${host}\n`
+
+          appendFileSync(logFile, entry)
+
+          // Set terminal title
+          process.stdout.write(`\x1b]0;opencode: ${basename(process.cwd())}\x07`)
         } catch (error) {
-          console.error("[claude-hooks] initialize-session error:", error)
+          console.error("[opencode-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 $`bun run ~/.config/claude/hooks/save-session.ts`.quiet()
-      //   } catch (error) {
-      //     console.error("[claude-hooks] save-session error:", error)
-      //   }
-      // }
     },
   }
 }
 
-// Default export for OpenCode plugin discovery
-export default ClaudeHooksPlugin
+export default plugin
dots/config/opencode/opencode.json.example
@@ -0,0 +1,66 @@
+{
+  "$schema": "https://opencode.ai/config.json",
+  "instructions": [
+    "~/.config/claude/skills/CORE/SKILL.md",
+    "~/.config/claude/skills/CORE/history-system.md"
+  ],
+  "skills": [
+    "~/.config/claude/skills"
+  ],
+  "provider": {
+    "ollama": {
+      "npm": "@ai-sdk/openai-compatible",
+      "name": "Ollama (aomi)",
+      "options": {
+        "baseURL": "http://192.168.1.23:8000/v1"
+      },
+      "models": {
+        "llama3.1:8b": {
+          "name": "Llama 3.1 8B (Best for OpenCode)",
+          "tools": true,
+          "reasoning": true
+        },
+        "mistral-nemo:latest": {
+          "name": "Mistral Nemo (Fast tools)",
+          "tools": true
+        },
+        "qwen-opencode:latest": {
+          "name": "Qwen OpenCode (16k context)",
+          "tools": true,
+          "reasoning": true
+        },
+        "qwen2.5-coder:7b": {
+          "name": "Qwen2.5 Coder 7B",
+          "tools": true,
+          "reasoning": true
+        },
+        "deepseek-r1:7b": {
+          "name": "DeepSeek R1 7B (Reasoning)",
+          "tools": true,
+          "reasoning": true
+        },
+        "qwen-coder-tools:latest": {
+          "name": "Qwen2.5 Coder 7B (Custom)",
+          "tools": true
+        },
+        "codestral:latest": {
+          "name": "Codestral 22B"
+        },
+        "phi4-reasoning:latest": {
+          "name": "Phi-4 14B (Reasoning)",
+          "reasoning": true
+        },
+        "phi3.5:3.8b": {
+          "name": "Phi-3.5 3.8B (Fast)"
+        }
+      }
+    }
+  },
+  "mcp": {
+    "playwright": {
+      "type": "local",
+      "command": ["mcp-server-playwright", "--browser", "google-chrome-stable"],
+      "enabled": true
+    }
+  }
+}