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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@

### Fixed

- The OpenCode plugin template is an OpenCode 2.x definition
(`id` + `setup`, hooks via `ctx.event.subscribe` and `ctx.tool.hook`,
subprocesses via `node:child_process`). The plugin package is imported
only for types, so OpenCode 2.0.0–2.0.24 can load it without resolving
`@opencode/plugin` at runtime. This also fixes the V2 loader error
`Plugin must export a default definition with an id and an effect or
setup function` (#1038).
- An explicit embedding refresh (`--embedding-provider` with
`--embedding-model`, or the `embedding_provider` and `embedding_model`
arguments of `build_or_update_graph_tool` and `run_postprocess_tool`) on a
Expand Down
132 changes: 79 additions & 53 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -2879,20 +2879,30 @@ 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 exports an OpenCode 2.x (V2) definition object with an ``id``
and a ``setup`` function because the V2 loader rejects any other
default-export shape with ``SchemaError(Expected object at ["default"])``.
The plugin package is imported only for types so older 2.x hosts do not
need to resolve it at runtime. ``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 type { Plugin } from "@opencode/plugin"
import { execFile } from "node:child_process"
import { promisify } from "node:util"

/**
* code-review-graph plugin for OpenCode.
Expand All @@ -2903,58 +2913,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 {
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()
},
} satisfies Parameters<typeof Plugin.define>[0]
"""


Expand Down
Loading