OpenCodeModule
Audit a Forge module for OpenCode compatibility and generate a TypeScript plugin adapter for modules with lifecycle hook binaries.
Reference: Core/docs/OpenCodeModuleCompatibility.md
Workflow Routing
| Workflow | Trigger | Section |
|---|---|---|
| Audit | "audit opencode", "opencode compatible", "check opencode module" | Audit Workflow |
| Generate | "generate opencode adapter", "create opencode plugin", "make opencode compatible" | Generate Workflow |
Audit Workflow
Step 1: Identify the module
Read module.yaml in the target module directory. Extract:
- •
name:— module name (e.g.,forge-reflect) - •
events:— lifecycle events the module handles - •
version:
If module.yaml is missing, the directory is not a Forge module.
Step 2: Check env var portability
Search for hardcoded CLAUDE_PLUGIN_ROOT without FORGE_MODULE_ROOT fallback.
Rust source (src/):
- •
Config::load()or equivalent config loading must checkFORGE_MODULE_ROOTbeforeCLAUDE_PLUGIN_ROOT. Pattern:rustlet module_root = std::env::var("FORGE_MODULE_ROOT") .or_else(|_| std::env::var("CLAUDE_PLUGIN_ROOT"));
Shell scripts (bin/, hooks/):
- •Must use the fallback chain:
bash
MODULE_ROOT="${FORGE_MODULE_ROOT:-${CLAUDE_PLUGIN_ROOT:-$(builtin cd "$(dirname "$0")/.." && pwd)}}" - •
_build.shor equivalent must use the same chain. - •Hook scripts must export
FORGE_MODULE_ROOT, notCLAUDE_PLUGIN_ROOT.
Skills (skills/*/SKILL.md):
- •DCI lines must use:
dispatch skill-load <module> - •Inline bash snippets must NOT use
${}variable expansion (blocked by Claude Code's permission system). Use hardcoded relative paths with fallback:bashMODULE="Modules/<module-name>" [ -d "$MODULE" ] || MODULE="."
Step 3: Check skill portability
For each skills/*/SKILL.md:
- •Verify
name:anddescription:frontmatter are present. - •Verify
description:includesUSE WHENtriggers. - •Check for Claude-only hard dependencies (e.g., mandatory
AskUserQuestion). If found, ensure a fallback exists for runtimes without it. - •Directory naming must be PascalCase — the opencode adapter converts
to
kebab-caseautomatically viainstall.sh.
Step 4: Check lint compliance
If the module has Rust code:
cargo clippy --manifest-path Modules/<module>/Cargo.toml -- -D warnings cargo fmt --manifest-path Modules/<module>/Cargo.toml --check cargo test --manifest-path Modules/<module>/Cargo.toml
All three must pass clean. Common drift issues with clippy pedantic:
- •
write_with_newline— usewriteln!instead ofwrite!(..., "...\n") - •
unnecessary_map_or— use.is_none_or()instead of.map_or(true, ...) - •
redundant_closure_for_method_calls— useResult::okinstead of|e| e.ok() - •
format_push_string— usewriteln!instead ofpush_str(&format!(...))
Step 5: Check hook coverage
If module.yaml lists events:, verify:
- •Each event has a corresponding hook script in
hooks/. - •Hook scripts follow the contract: exit 0 always, JSON on stdout, errors on stderr.
- •For modules with Rust binaries: binaries exist in
src/bin/for each hook.
Map events to OpenCode equivalents:
| Forge Event | OpenCode Event | Behavior |
|---|---|---|
SessionStart | session.created | Output shown as TUI toast (cannot inject into system prompt) |
Stop | session.idle | Warning toast only (cannot block exit) |
PreCompact | experimental.session.compacting | Context injection via output.context.push() |
PreToolUse | No equivalent | Skills-only — no event-based hook available |
Step 6: Report findings
Provide:
- •Compatibility verdict:
compatible,compatible with caveats,not compatible - •Concrete issues with file paths and line numbers
- •For each issue: whether it blocks compatibility or is cosmetic
- •If the module has lifecycle hooks: recommend running the Generate workflow
Generate Workflow
Only applies to modules with lifecycle events (SessionStart, Stop, PreCompact).
Modules that are skill-only (e.g., forge-obsidian, forge-steering) do not need
an adapter — the project-level install.sh symlink mechanism handles their skills.
Step 1: Read module.yaml
Extract name: and events: list. Determine which binaries correspond to each event.
Discover binaries by checking src/bin/*.rs files. Map by convention:
- •
surface.rs— SessionStart - •
insight.rs+reflect.rs— Stop (chained) - •
reflect.rs— PreCompact (withtriggerfield)
If the mapping is ambiguous, read the hook scripts in hooks/ to understand the chain.
Step 2: Generate .opencode/plugins/<module>.ts
Create <module-root>/.opencode/plugins/<module-name>.ts using the adapter template below.
Populate the template with:
- •Module name (for the exported plugin constant name, PascalCase)
- •Binary paths (relative to module root)
- •Event handlers based on which events the module declares
mkdir -p <module-root>/.opencode/plugins
Step 3: Adapter template
import type { Plugin } from "@opencode-ai/plugin"
import path from "path"
/**
* <module-name> opencode plugin adapter.
*
* Bridges Rust binaries into opencode's event-based plugin system.
* Auto-generated by the OpenCodeModule skill.
*/
export const <ModuleName>: Plugin = async ({ $, directory, worktree }) => {
const moduleRoot = worktree || directory
const binDir = path.join(moduleRoot, "target", "release")
// List all binaries the module provides:
// const binaryName = path.join(binDir, "binary-name")
async function ensureBuilt(): Promise<boolean> {
try {
// Test all required binaries exist
await $`test -x ${path.join(binDir, "FIRST_BINARY")}`.quiet()
return true
} catch {
try {
await $`cargo build --release --manifest-path ${path.join(moduleRoot, "Cargo.toml")}`.quiet()
return true
} catch {
return false
}
}
}
async function runBinary(bin: string, stdin?: string): Promise<string> {
try {
if (stdin) {
return await $`echo ${stdin} | FORGE_MODULE_ROOT=${moduleRoot} ${bin}`.text()
}
return await $`FORGE_MODULE_ROOT=${moduleRoot} ${bin}`.text()
} catch {
return ""
}
}
const ready = await ensureBuilt()
return {
event: async ({ event }) => {
if (!ready) return
// SESSION.CREATED — SessionStart equivalent
// Uncomment if module handles SessionStart:
//
// if (event.type === "session.created") {
// const output = await runBinary(sessionStartBinary)
// if (output.trim()) {
// return {
// type: "tui.toast.show" as const,
// toast: { message: output.trim(), level: "info" as const },
// }
// }
// }
// SESSION.IDLE — Stop equivalent (non-blocking)
// Uncomment if module handles Stop:
//
// if (event.type === "session.idle") {
// const input = JSON.stringify({ cwd: directory, transcript_path: "" })
// const result = await runBinary(stopBinary, input)
// if (result.trim()) {
// try {
// const parsed = JSON.parse(result.trim())
// if (parsed.decision === "block") {
// return {
// type: "tui.toast.show" as const,
// toast: {
// message: `<module-name>: ${parsed.reason}`,
// level: "warn" as const,
// },
// }
// }
// } catch { /* not JSON */ }
// }
// }
},
// COMPACTING — PreCompact equivalent
// Uncomment if module handles PreCompact:
//
// "experimental.session.compacting": async (input, output) => {
// if (!ready) return
// const payload = JSON.stringify({ cwd: directory, trigger: "auto" })
// const result = await runBinary(preCompactBinary, payload)
// if (result.trim()) {
// try {
// const parsed = JSON.parse(result.trim())
// if (parsed.additionalContext) {
// output.context.push(parsed.additionalContext)
// }
// } catch { /* not JSON */ }
// }
// },
}
}
Customize the template:
- •Uncomment event handlers matching the module's
events:list. - •Replace placeholder names with actual binary names.
- •For Stop hooks with chained binaries (e.g.,
insightthenreflect), invoke them sequentially — first result with stdout JSON wins. - •For SessionStart hooks that produce plain text (not JSON), emit the raw text as the toast message.
Step 4: Verify
# Check the adapter file exists ls <module-root>/.opencode/plugins/<module-name>.ts # Build binaries cargo build --release --manifest-path <module-root>/Cargo.toml # Run clippy and tests cargo clippy --manifest-path <module-root>/Cargo.toml -- -D warnings cargo test --manifest-path <module-root>/Cargo.toml
Then start opencode in the module directory and verify event hooks fire.
Constraints
- •Do not assume opencode supports Claude hook schema or config keys.
- •
session.idlecannot block exit — always degrade Stop hooks to warning toasts. - •Transcript files are not available in opencode — transcript-dependent behavior (insight counting, substantiality checks) will skip gracefully when the path is empty.
- •
PreToolUsehas no opencode equivalent — modules using this event are skill-only in opencode. - •Keep changes narrow: fix compatibility defects without unrelated refactors.
- •The opencode adapter at
Adapters/opencode/install.shhandles skill symlinks. Do not duplicate that logic — the plugin adapter is only for lifecycle hooks.