diff --git a/CHANGELOG.md b/CHANGELOG.md index 314e50d5..6945f2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,12 @@ ### Fixed +- The OpenCode plugin template is an OpenCode 2.x definition + (`Plugin.define` with `id` + `setup`, hooks via `ctx.event.subscribe` + and `ctx.tool.hook`, subprocesses via `node:child_process`), so the + installed plugin loads on current OpenCode releases instead of failing + with `Plugin must export a default definition with an id and an effect + or setup function` (#1038). - Go and Ruby imports resolve into the repository instead of staying bare strings. Go reads the module path from the nearest `go.mod` (nested modules win over their ancestors, and local `replace` targets are diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index d2443d16..31fbd9fb 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -2828,20 +2828,29 @@ def install_hermes_skills(repo_root: Path) -> Path: def _opencode_plugin_content() -> str: """Return TypeScript source for the OpenCode user-level plugin. - The plugin hooks into three OpenCode events to mirror the Claude Code - hook behaviors: - - 1. ``file.edited`` — runs ``code-review-graph update --skip-flows`` - 2. ``session.created`` — runs ``code-review-graph status`` - 3. ``tool.execute.before`` — when the tool is a shell command starting - with ``git commit``, runs ``code-review-graph detect-changes --brief`` - - All handlers use try/catch so errors never break the editor session. - The plugin uses Bun's ``$`` shell API (provided by OpenCode's plugin - context) for subprocess execution. + The plugin is an OpenCode 2.x (V2) definition — ``Plugin.define`` with + an ``id`` and a ``setup`` function — because the V2 loader rejects any + other default-export shape with ``SchemaError(Expected object at + ["default"])``. ``setup`` mirrors the Claude Code hook behaviors: + + 1. successful ``edit``/``write``/``patch`` tool calls (there is no V2 + ``file.edited`` hook) — runs ``code-review-graph update --skip-flows`` + 2. ``session.created`` events via ``ctx.event.subscribe`` — runs + ``code-review-graph status`` + 3. ``ctx.tool.hook("execute.before")`` — when the tool is a shell + command starting with ``git commit``, runs ``code-review-graph + detect-changes --brief`` + + All handlers use try/catch (or a swallowing helper) so errors never + break the editor session. Subprocesses run via ``node:child_process`` + ``execFile`` with argv arrays — never by interpolating a command string + into Bun's ``$`` template, which would escape it into one executable + name. """ return """\ -import type { Plugin } from "@opencode-ai/plugin" +import { Plugin } from "@opencode/plugin" +import { execFile } from "node:child_process" +import { promisify } from "node:util" /** * code-review-graph plugin for OpenCode. @@ -2852,58 +2861,74 @@ def _opencode_plugin_content() -> str: * Installed by: code-review-graph install --platform opencode */ -// Helper: run a shell command quietly, swallowing errors. -async function run($: any, cmd: string): Promise { +const execFileAsync = promisify(execFile) + +// Run code-review-graph quietly, swallowing errors. Never blocks the session. +async function runQuiet(args: string[], cwd: string, timeoutMs: number): Promise { try { - const result = await $`${cmd}`.quiet() - return result.stdout?.toString().trim() ?? "" + const { stdout } = await execFileAsync("code-review-graph", args, { + cwd, + timeout: timeoutMs, + }) + return String(stdout ?? "").trim() } catch { return "" } } -export default (app: any) => { - // 1. Auto-update graph after file edits - app.on("file.edited", async ({ $ }: { $: any }) => { - try { - await $`code-review-graph update --skip-flows`.quiet() - } catch { - // Swallow — graph may not be built yet for this project. - } - }) - - // 2. Show graph status when a new session starts - app.on("session.created", async ({ $ }: { $: any }) => { - try { - const result = await $`code-review-graph status`.quiet() - const output = result.stdout?.toString().trim() - if (output) { - console.log("[code-review-graph]", output) +export default Plugin.define({ + id: "code-review-graph", + async setup(ctx) { + const baseDir = ctx.location.directory + const controller = new AbortController() + + // Show graph status when a new session starts. + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type !== "session.created") continue + // Only handle sessions for this plugin instance's location. + const sessionDir = event.location?.directory + if (sessionDir !== undefined && sessionDir !== baseDir) continue + const output = await runQuiet(["status"], sessionDir ?? baseDir, 15_000) + if (output) { + console.log("[code-review-graph]", output) + } + } + } catch { + // Aborted on unload — ignore. } - } catch { - // Swallow — not every project has a graph. - } - }) - - // 3. Detect changes before git commit commands - app.on("tool.execute.before", async (ctx: any) => { - try { - const input = ctx?.input ?? ctx?.params ?? {} - const cmd = - input.command ?? input.cmd ?? input.content ?? "" - if (typeof cmd === "string" && /^git\\s+commit/i.test(cmd)) { - const result = - await ctx.$`code-review-graph detect-changes --brief`.quiet() - const output = result.stdout?.toString().trim() + })() + + // Auto-update graph after file edits. V2 has no `file.edited` hook, + // so trigger off successful edit/write/patch tool calls instead. + await ctx.tool.hook("execute.after", async (event) => { + if (event.status !== "completed") return + const tool = String(event.tool ?? "").toLowerCase() + if (tool !== "edit" && tool !== "write" && tool !== "patch") return + void runQuiet(["update", "--skip-flows"], baseDir, 60_000) + }) + + // Detect changes before git commit commands. + await ctx.tool.hook("execute.before", async (event) => { + const tool = String(event.tool ?? "").toLowerCase() + if (tool !== "bash" && tool !== "shell" && tool !== "execute") return + const input = (event.input ?? {}) as Record + const cmd = input.command ?? input.cmd ?? input.content ?? "" + if (typeof cmd !== "string" || !/^git\\s+commit/i.test(cmd)) return + try { + const output = await runQuiet(["detect-changes", "--brief"], baseDir, 30_000) if (output) { console.log("[code-review-graph] Pre-commit analysis:\\n" + output) } + } catch { + // Swallow — never block a commit. } - } catch { - // Swallow — never block a commit. - } - }) -} + }) + + return () => controller.abort() + }, +}) """ diff --git a/tests/test_skills.py b/tests/test_skills.py index 632262d0..5f2a1561 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -2356,27 +2356,35 @@ def test_returns_non_empty_string(self): def test_has_plugin_type_import(self): content = _opencode_plugin_content() - assert "import type" in content - assert "@opencode-ai/plugin" in content + assert "@opencode/plugin" in content + assert "Plugin.define" in content def test_has_default_export(self): content = _opencode_plugin_content() assert "export default" in content - def test_hooks_file_edited_event(self): + def test_uses_v2_definition_shape(self): + """V2 loader requires an object with id + setup, not a function.""" content = _opencode_plugin_content() - assert '"file.edited"' in content - assert "code-review-graph update --skip-flows" in content + assert 'id: "code-review-graph"' in content + assert "async setup(ctx" in content + assert "app.on(" not in content + + def test_hooks_file_edits_via_tool_hook(self): + content = _opencode_plugin_content() + assert 'ctx.tool.hook("execute.after"' in content + assert '"update", "--skip-flows"' in content def test_hooks_session_created_event(self): content = _opencode_plugin_content() assert '"session.created"' in content - assert "code-review-graph status" in content + assert "ctx.event.subscribe" in content + assert '["status"]' in content def test_hooks_tool_execute_before_event(self): content = _opencode_plugin_content() - assert '"tool.execute.before"' in content - assert "code-review-graph detect-changes --brief" in content + assert 'ctx.tool.hook("execute.before"' in content + assert '"detect-changes", "--brief"' in content def test_has_git_commit_detection(self): """Pre-commit hook should match git commit commands.""" @@ -2406,7 +2414,7 @@ def test_plugin_file_has_correct_content(self, tmp_path): result = install_opencode_plugin() content = result.read_text(encoding="utf-8") assert "export default" in content - assert "file.edited" in content + assert "execute.after" in content def test_creates_parent_directories(self, tmp_path): with patch("code_review_graph.skills.Path.home", return_value=tmp_path):