From 527ef495a699a3516289736c1c4f6fdde5bc2f68 Mon Sep 17 00:00:00 2001 From: Uli Date: Fri, 7 Aug 2026 07:13:13 +0000 Subject: [PATCH] feat: add opencode v2 plugin support (rtk.rewrite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hooks/opencode/rtk-v2.ts — OpenCode v2 plugin using Plugin.define API with ctx.tool.hook('execute.before', ...) - Add --opencode-v2 flag to rtk init CLI - Embed OPENCODE_V2_PLUGIN via include_str! in installer - Thread install_opencode_v2 through run(), run_default_mode(), run_hook_only_mode(), run_claude_md_mode(), run_opencode_only_mode() - ensure_opencode_plugin_installed() now accepts content parameter - Validate --opencode and --opencode-v2 are mutually exclusive - Update READMEs for hooks/ and hooks/opencode/ --- hooks/README.md | 12 ++++- hooks/opencode/README.md | 13 ++++- hooks/opencode/rtk-v2.ts | 45 +++++++++++++++++ src/hooks/init.rs | 103 +++++++++++++++++++++++++++------------ src/main.rs | 9 +++- 5 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 hooks/opencode/rtk-v2.ts diff --git a/hooks/README.md b/hooks/README.md index 6e9cd01a2a..e26e6c3e61 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -187,7 +187,7 @@ Returns `{}` when no rewrite (Cursor requires JSON for all paths). ### OpenCode (TypeScript Plugin) -Mutates `args.command` in-place via the zx library: +**v1 (default)**: Mutates `args.command` in-place via the zx `$` helper: ```typescript const result = await $`rtk rewrite ${command}`.quiet().nothrow() @@ -197,6 +197,16 @@ if (rewritten && rewritten !== command) { } ``` +**v2**: Uses `Plugin.define` + `ctx.tool.hook("execute.before", ...)` — spawns `rtk rewrite` directly since v2 context does not provide `$`: + +```typescript +const proc = spawn("rtk", ["rewrite", command]) +proc.stdout.on("data", (d) => { out += d }) +proc.on("close", () => resolve(out.trim())) +``` + +Installed with `rtk init -g --opencode` (v1) or `rtk init -g --opencode-v2` (v2). + ### Hermes (Python Plugin) Mutates `args["command"]` in-place via the `pre_tool_call` hook: diff --git a/hooks/opencode/README.md b/hooks/opencode/README.md index 8edc93cc4d..1533f7c6a6 100644 --- a/hooks/opencode/README.md +++ b/hooks/opencode/README.md @@ -4,8 +4,19 @@ ## Specifics -- TypeScript plugin using the zx library (not a shell hook) +### OpenCode v1 (default) + +- TypeScript plugin using the `zx` library (bun-shell `$` helper) - Intercepts `tool.execute.before` events, calls `rtk rewrite` as a subprocess - Uses `.quiet().nothrow()` to silently ignore failures - Mutates `args.command` in-place if rewrite differs from original - Installed to `~/.config/opencode/plugins/rtk.ts` by `rtk init -g --opencode` + +### OpenCode v2 + +- TypeScript plugin using the `Plugin.define` API (`@opencode-ai/plugin` v2) +- Registers `ctx.tool.hook("execute.before", ...)` instead of returning a hooks object +- Calls `rtk rewrite` via `child_process.spawn` (v2 context does not provide `$`) +- Uses `spawn` + stdout capture instead of exit codes (`rtk rewrite` exits non-zero on success) +- Mutates `event.input.command` in-place if rewrite differs from original +- Installed to `~/.config/opencode/plugins/rtk.ts` by `rtk init -g --opencode-v2` \ No newline at end of file diff --git a/hooks/opencode/rtk-v2.ts b/hooks/opencode/rtk-v2.ts new file mode 100644 index 0000000000..cfeba865ed --- /dev/null +++ b/hooks/opencode/rtk-v2.ts @@ -0,0 +1,45 @@ +import { Plugin } from "@opencode-ai/plugin" +import { spawn } from "child_process" + +// RTK OpenCode v2 plugin — rewrites commands to use rtk for token savings. +// Requires: rtk >= 0.23.0 in PATH. +// +// OpenCode 2.0 uses a new plugin API (Plugin.define + ctx.tool.hook) and does +// not provide the v1 bun-shell `$` helper, so this variant spawns `rtk` +// directly. `rtk rewrite` exits non-zero even on success, so we read stdout +// from the child instead of relying on exit codes. +// +// This is a thin delegating plugin: all rewrite logic lives in `rtk rewrite`, +// which is the single source of truth (src/discover/registry.rs). +// To add or change rewrite rules, edit the Rust registry — not this file. + +function rtkRewrite(command: string): Promise { + return new Promise((resolve) => { + const proc = spawn("rtk", ["rewrite", command]) + let out = "" + proc.stdout.on("data", (d) => { + out += d + }) + proc.on("error", () => resolve("")) + proc.on("close", () => resolve(out.trim())) + }) +} + +export default Plugin.define({ + id: "rtk.rewrite", + setup: async (ctx) => { + await ctx.tool.hook("execute.before", async (event) => { + const tool = String(event.tool ?? "").toLowerCase() + if (tool !== "bash" && tool !== "shell") return + if (!event.input || typeof event.input !== "object") return + + const command = (event.input as Record).command + if (typeof command !== "string" || !command) return + + const rewritten = await rtkRewrite(command) + if (rewritten && rewritten !== command) { + ;(event.input as Record).command = rewritten + } + }) + }, +}) \ No newline at end of file diff --git a/src/hooks/init.rs b/src/hooks/init.rs index bc7b443283..d35ca85f25 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -24,8 +24,9 @@ use super::constants::{ use super::integrity; use super::is_claude_hook_command; -// Embedded OpenCode plugin (auto-rewrite) +// Embedded OpenCode plugins (auto-rewrite) const OPENCODE_PLUGIN: &str = include_str!("../../hooks/opencode/rtk.ts"); +const OPENCODE_V2_PLUGIN: &str = include_str!("../../hooks/opencode/rtk-v2.ts"); // Embedded Pi extension (auto-rewrite) const PI_PLUGIN: &str = include_str!("../../hooks/pi/rtk.ts"); @@ -264,6 +265,7 @@ pub fn run( global: bool, install_claude: bool, install_opencode: bool, + install_opencode_v2: bool, install_cursor: bool, install_windsurf: bool, install_cline: bool, @@ -274,10 +276,15 @@ pub fn run( ctx: InitContext, ) -> Result<()> { let InitContext { dry_run, .. } = ctx; + // Validation: conflicting OpenCode flags + if install_opencode && install_opencode_v2 { + anyhow::bail!("--opencode and --opencode-v2 are mutually exclusive"); + } + let install_opencode_any = install_opencode || install_opencode_v2; // Validation: Codex mode conflicts if codex { - if install_opencode { - anyhow::bail!("--codex cannot be combined with --opencode"); + if install_opencode || install_opencode_v2 { + anyhow::bail!("--codex cannot be combined with --opencode or --opencode-v2"); } if claude_md { anyhow::bail!("--codex cannot be combined with --claude-md"); @@ -294,7 +301,7 @@ pub fn run( run_codex_mode(global, ctx)?; } else { // Validation: Global-only features - if install_opencode && !global { + if install_opencode_any && !global { anyhow::bail!("OpenCode plugin is global-only. Use: rtk init -g --opencode"); } @@ -312,14 +319,16 @@ pub fn run( run_cline_mode(ctx)?; } else { // Mode selection (Claude Code / OpenCode) - match (install_claude, install_opencode, claude_md, hook_only) { - (false, true, _, _) => run_opencode_only_mode(ctx)?, - (true, opencode, true, _) => run_claude_md_mode(global, opencode, ctx)?, + match (install_claude, install_opencode_any, claude_md, hook_only) { + (false, true, _, _) => run_opencode_only_mode(ctx, install_opencode_v2)?, + (true, opencode, true, _) => { + run_claude_md_mode(global, opencode, ctx, install_opencode_v2)? + } (true, opencode, false, true) => { - run_hook_only_mode(global, patch_mode, opencode, ctx)? + run_hook_only_mode(global, patch_mode, opencode, install_opencode_v2, ctx)? } (true, opencode, false, false) => { - run_default_mode(global, patch_mode, opencode, ctx)? + run_default_mode(global, patch_mode, opencode, install_opencode_v2, ctx)? } (false, false, _, _) => { if !install_cursor { @@ -1143,12 +1152,13 @@ fn run_default_mode( global: bool, patch_mode: PatchMode, install_opencode: bool, + install_opencode_v2: bool, ctx: InitContext, ) -> Result<()> { let InitContext { dry_run, .. } = ctx; if !global { // Local init: inject CLAUDE.md + generate project-local filters template - run_claude_md_mode(false, install_opencode, ctx)?; + run_claude_md_mode(false, install_opencode, ctx, install_opencode_v2)?; generate_project_filters_template(ctx)?; return Ok(()); } @@ -1165,7 +1175,12 @@ fn run_default_mode( let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, ctx)?; + let content = if install_opencode_v2 { + OPENCODE_V2_PLUGIN + } else { + OPENCODE_PLUGIN + }; + ensure_opencode_plugin_installed(&path, content, ctx)?; Some(path) } else { None @@ -1499,6 +1514,7 @@ fn run_hook_only_mode( global: bool, patch_mode: PatchMode, install_opencode: bool, + install_opencode_v2: bool, ctx: InitContext, ) -> Result<()> { let InitContext { dry_run, .. } = ctx; @@ -1513,7 +1529,12 @@ fn run_hook_only_mode( let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&path, ctx)?; + let content = if install_opencode_v2 { + OPENCODE_V2_PLUGIN + } else { + OPENCODE_PLUGIN + }; + ensure_opencode_plugin_installed(&path, content, ctx)?; Some(path) } else { None @@ -1565,7 +1586,12 @@ fn run_hook_only_mode( } /// Legacy mode: full 137-line injection into CLAUDE.md -fn run_claude_md_mode(global: bool, install_opencode: bool, ctx: InitContext) -> Result<()> { +fn run_claude_md_mode( + global: bool, + install_opencode: bool, + ctx: InitContext, + install_opencode_v2: bool, +) -> Result<()> { let InitContext { verbose, dry_run } = ctx; let path = if global { resolve_claude_dir()?.join(CLAUDE_MD) @@ -1604,7 +1630,12 @@ fn run_claude_md_mode(global: bool, install_opencode: bool, ctx: InitContext) -> if global { if install_opencode { let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, ctx)?; + let content = if install_opencode_v2 { + OPENCODE_V2_PLUGIN + } else { + OPENCODE_PLUGIN + }; + ensure_opencode_plugin_installed(&opencode_plugin_path, content, ctx)?; if !dry_run { println!( "[ok] OpenCode plugin installed: {}", @@ -3517,7 +3548,7 @@ fn prepare_opencode_plugin_path() -> Result { } /// Write OpenCode plugin file if missing or outdated -fn ensure_opencode_plugin_installed(path: &Path, ctx: InitContext) -> Result { +fn ensure_opencode_plugin_installed(path: &Path, content: &str, ctx: InitContext) -> Result { let InitContext { dry_run, .. } = ctx; // Ensure parent dir exists (skip in dry-run) if !dry_run { @@ -3530,7 +3561,7 @@ fn ensure_opencode_plugin_installed(path: &Path, ctx: InitContext) -> Result Result<()> { Ok(()) } -fn run_opencode_only_mode(ctx: InitContext) -> Result<()> { +fn run_opencode_only_mode(ctx: InitContext, install_opencode_v2: bool) -> Result<()> { let InitContext { dry_run, .. } = ctx; let opencode_plugin_path = prepare_opencode_plugin_path()?; - ensure_opencode_plugin_installed(&opencode_plugin_path, ctx)?; + let content = if install_opencode_v2 { + OPENCODE_V2_PLUGIN + } else { + OPENCODE_PLUGIN + }; + ensure_opencode_plugin_installed(&opencode_plugin_path, content, ctx)?; if !dry_run { println!("\nOpenCode plugin installed (global).\n"); println!(" OpenCode: {}", opencode_plugin_path.display()); @@ -5164,14 +5200,16 @@ mod tests { assert!(!plugin_path.exists()); let changed = - ensure_opencode_plugin_installed(&plugin_path, InitContext::default()).unwrap(); + ensure_opencode_plugin_installed(&plugin_path, OPENCODE_PLUGIN, InitContext::default()) + .unwrap(); assert!(changed); let content = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content, OPENCODE_PLUGIN); fs::write(&plugin_path, "// old").unwrap(); let changed_again = - ensure_opencode_plugin_installed(&plugin_path, InitContext::default()).unwrap(); + ensure_opencode_plugin_installed(&plugin_path, OPENCODE_PLUGIN, InitContext::default()) + .unwrap(); assert!(changed_again); let content_updated = fs::read_to_string(&plugin_path).unwrap(); assert_eq!(content_updated, OPENCODE_PLUGIN); @@ -5303,6 +5341,7 @@ mod tests { false, false, false, + false, true, PatchMode::Auto, InitContext::default(), @@ -5325,6 +5364,7 @@ mod tests { false, false, false, + false, true, PatchMode::Skip, InitContext::default(), @@ -7198,7 +7238,7 @@ mod tests { fn test_global_default_mode_creates_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, InitContext::default()).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); assert!( @@ -7220,7 +7260,7 @@ mod tests { fn test_global_uninstall_removes_artifacts() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, InitContext::default()).unwrap(); uninstall(true, false, false, false, false, InitContext::default()).unwrap(); assert!(!claude_dir.join(RTK_MD).exists(), "RTK.md must be removed"); @@ -7237,8 +7277,8 @@ mod tests { fn test_global_default_mode_idempotent() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); - run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, InitContext::default()).unwrap(); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); let count = settings.matches(CLAUDE_HOOK_COMMAND).count(); @@ -7250,14 +7290,14 @@ mod tests { fn test_upgrade_from_claude_md_to_hook_mode() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_claude_md_mode(true, false, InitContext::default()).unwrap(); + run_claude_md_mode(true, false, InitContext::default(), false).unwrap(); let claude_md_content = fs::read_to_string(claude_dir.join(CLAUDE_MD)).unwrap(); assert!( claude_md_content.contains(RTK_BLOCK_START), "pre-condition: old block must exist" ); - run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, InitContext::default()).unwrap(); assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created"); let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap(); @@ -7275,7 +7315,7 @@ mod tests { let cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(tmp.path()).unwrap(); - let result = run_default_mode(false, PatchMode::Auto, false, InitContext::default()); + let result = run_default_mode(false, PatchMode::Auto, false, false, InitContext::default()); std::env::set_current_dir(&cwd).unwrap(); result.unwrap(); @@ -7293,7 +7333,8 @@ mod tests { fn test_global_hook_only_mode_creates_settings() { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { - run_hook_only_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_hook_only_mode(true, PatchMode::Auto, false, false, InitContext::default()) + .unwrap(); assert!( !claude_dir.join(RTK_MD).exists(), @@ -7315,7 +7356,7 @@ mod tests { dry_run: true, ..Default::default() }; - run_default_mode(true, PatchMode::Auto, false, dry).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, dry).unwrap(); assert!( !claude_dir.join(RTK_MD).exists(), @@ -7337,7 +7378,7 @@ mod tests { let tmp = TempDir::new().unwrap(); with_claude_dir_override(&tmp, |claude_dir| { // Stage a real install first - run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap(); + run_default_mode(true, PatchMode::Auto, false, false, InitContext::default()).unwrap(); assert!(claude_dir.join(RTK_MD).exists()); assert!(claude_dir.join(SETTINGS_JSON).exists()); @@ -7478,7 +7519,7 @@ mod tests { ); fs::write(&claude_md, &malformed).unwrap(); - let result = run_claude_md_mode(true, false, InitContext::default()); + let result = run_claude_md_mode(true, false, InitContext::default(), false); assert!( result.is_err(), diff --git a/src/main.rs b/src/main.rs index b29cf0769b..657f7e854d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -347,6 +347,10 @@ enum Commands { #[arg(long)] opencode: bool, + /// Install OpenCode v2 plugin (in addition to Claude Code) + #[arg(long)] + opencode_v2: bool, + /// Initialize for Gemini CLI instead of Claude Code #[arg(long)] gemini: bool, @@ -2000,6 +2004,7 @@ fn run_cli() -> Result { Commands::Init { global, opencode, + opencode_v2, gemini, agent, show, @@ -2085,7 +2090,8 @@ fn run_cli() -> Result { hooks::init::run_vibe_mode(global, hook_only, patch_mode, ctx)?; } else { let install_opencode = opencode; - let install_claude = !opencode; + let install_opencode_v2 = opencode_v2; + let install_claude = !opencode && !opencode_v2; let install_cursor = agent == Some(AgentTarget::Cursor); let install_windsurf = agent == Some(AgentTarget::Windsurf); let install_cline = agent == Some(AgentTarget::Cline); @@ -2101,6 +2107,7 @@ fn run_cli() -> Result { global, install_claude, install_opencode, + install_opencode_v2, install_cursor, install_windsurf, install_cline,