Commit bc1f668bce38
Changed files (2)
dots
config
opencode
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
+ }
+ }
+}