Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -43,6 +43,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).
- Freshness metadata follows what was stored. A no-op `update` that
confirms `HEAD` advances the Git anchor, so queries after a commit no
longer carry a stale-graph caveat, and a file that fails to parse no
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
Loading