Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 78 additions & 53 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string> {
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<string> {
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<string, unknown>
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()
},
})
"""


Expand Down
26 changes: 17 additions & 9 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down