AgentSkillsCN

OpenCodeModule

审计或适配Forge模块,使其兼容OpenCode。适用于“OpenCode模块兼容性”“OpenCode就绪模块”“OpenCode适配器”“生成OpenCode插件”“OpenCode审计”或“编写跨供应商模块行为”等场景。

SKILL.md
--- frontmatter
name: OpenCodeModule
description: Audit or adapt a Forge module for OpenCode compatibility. USE WHEN opencode module compatibility, opencode-ready module, opencode adapter, generate opencode plugin, opencode audit, or writing cross-provider module behavior.

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

WorkflowTriggerSection
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 check FORGE_MODULE_ROOT before CLAUDE_PLUGIN_ROOT. Pattern:
    rust
    let 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.sh or equivalent must use the same chain.
  • Hook scripts must export FORGE_MODULE_ROOT, not CLAUDE_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:
    bash
    MODULE="Modules/<module-name>"
    [ -d "$MODULE" ] || MODULE="."
    

Step 3: Check skill portability

For each skills/*/SKILL.md:

  1. Verify name: and description: frontmatter are present.
  2. Verify description: includes USE WHEN triggers.
  3. Check for Claude-only hard dependencies (e.g., mandatory AskUserQuestion). If found, ensure a fallback exists for runtimes without it.
  4. Directory naming must be PascalCase — the opencode adapter converts to kebab-case automatically via install.sh.

Step 4: Check lint compliance

If the module has Rust code:

bash
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 — use writeln! instead of write!(..., "...\n")
  • unnecessary_map_or — use .is_none_or() instead of .map_or(true, ...)
  • redundant_closure_for_method_calls — use Result::ok instead of |e| e.ok()
  • format_push_string — use writeln! instead of push_str(&format!(...))

Step 5: Check hook coverage

If module.yaml lists events:, verify:

  1. Each event has a corresponding hook script in hooks/.
  2. Hook scripts follow the contract: exit 0 always, JSON on stdout, errors on stderr.
  3. For modules with Rust binaries: binaries exist in src/bin/ for each hook.

Map events to OpenCode equivalents:

Forge EventOpenCode EventBehavior
SessionStartsession.createdOutput shown as TUI toast (cannot inject into system prompt)
Stopsession.idleWarning toast only (cannot block exit)
PreCompactexperimental.session.compactingContext injection via output.context.push()
PreToolUseNo equivalentSkills-only — no event-based hook available

Step 6: Report findings

Provide:

  1. Compatibility verdict: compatible, compatible with caveats, not compatible
  2. Concrete issues with file paths and line numbers
  3. For each issue: whether it blocks compatibility or is cosmetic
  4. 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 (with trigger field)

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
bash
mkdir -p <module-root>/.opencode/plugins

Step 3: Adapter template

typescript
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., insight then reflect), 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

bash
# 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.idle cannot 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.
  • PreToolUse has 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.sh handles skill symlinks. Do not duplicate that logic — the plugin adapter is only for lifecycle hooks.