Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ The most effective way to use rtk. The hook transparently intercepts Bash comman

```bash
rtk init -g # Install hook + RTK.md (recommended)
rtk init -g --opencode # OpenCode plugin (instead of Claude Code)
rtk init -g --opencode # Claude setup + OpenCode plugin
rtk init -g --auto-patch # Non-interactive (CI/CD)
rtk init -g --hook-only # Hook only, no RTK.md
rtk init --show # Verify installation
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/getting-started/supported-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ rtk init --global --gemini
rtk init --global --opencode
```

Creates `~/.config/opencode/plugins/rtk.ts`. Uses the `tool.execute.before` hook.
Installs the Claude Code setup and creates `~/.config/opencode/plugins/rtk.ts`.
The plugin uses the `tool.execute.before` hook.

### Pi

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/resources/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ rtk gain # should now show token savings stats
```bash
rtk init --global # Claude Code
rtk init --global --cursor # Cursor
rtk init --global --opencode # OpenCode
rtk init --global --opencode # Claude Code + OpenCode plugin
```

3. Restart your AI assistant.
Expand Down
22 changes: 12 additions & 10 deletions src/hooks/init/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,23 @@ fn run_claude_md_mode_with(

let action = write_rtk_block(&path, block, "rtk instructions", recovery_cmd, ctx)?;

// Runs before the Unchanged return so that re-running restores a deleted plugin.
if global && install_opencode {
let opencode_plugin_path = prepare_opencode_plugin_path()?;
ensure_opencode_plugin_installed(&opencode_plugin_path, ctx)?;
if !dry_run {
println!(
"[ok] OpenCode plugin installed: {}",
opencode_plugin_path.display()
);
}
}

if matches!(action, RtkBlockUpsert::Unchanged) {
return Ok(());
}

if global {
if install_opencode {
let opencode_plugin_path = prepare_opencode_plugin_path()?;
ensure_opencode_plugin_installed(&opencode_plugin_path, ctx)?;
if !dry_run {
println!(
"[ok] OpenCode plugin installed: {}",
opencode_plugin_path.display()
);
}
}
if !dry_run {
println!(" Claude Code will now use rtk in all sessions");
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1656,7 +1656,7 @@ fn show_claude_config() -> Result<()> {
println!(" rtk init -g --hook-only # Hook only, no RTK.md");
println!(" rtk init --codex # Configure local AGENTS.md + RTK.md + hooks.json");
println!(" rtk init -g --codex # Configure global AGENTS.md + RTK.md + hooks.json");
println!(" rtk init -g --opencode # OpenCode plugin only");
println!(" rtk init -g --opencode # Claude setup + OpenCode plugin");
println!(" rtk init -g --agent cursor # Install Cursor Agent hooks");

Ok(())
Expand Down
3 changes: 1 addition & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2612,8 +2612,7 @@ fn run_cli() -> Result<i32> {
// shared init path; only the default (no --agent) or an explicit
// Claude target should install Claude Code files. Without this
// guard, `rtk init -g --agent cursor` writes into ~/.claude (#2097).
let install_claude =
!opencode && !install_cursor && !install_windsurf && !install_cline;
let install_claude = !install_cursor && !install_windsurf && !install_cline;

hooks::init::run(
global,
Expand Down
131 changes: 131 additions & 0 deletions tests/init_opencode_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#![cfg(unix)]
//! `rtk init -g --opencode` installs the Claude Code setup and the OpenCode plugin
//! together, from a home that has neither, and re-running it repairs a missing plugin.

use std::path::Path;
use std::process::{Command, Stdio};

mod common;

/// Runs rtk against `home` with every directory it resolves from the
/// environment pinned inside it, so an exported `CLAUDE_CONFIG_DIR` or `XDG_*`
/// can neither change the outcome nor receive writes.
fn rtk(home: &Path) -> Command {
let mut cmd = common::rtk_command();
cmd.env("HOME", home)
.env("RTK_DB_PATH", home.join("rtk.db"))
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_DATA_HOME", home.join(".local").join("share"))
.env_remove("CLAUDE_CONFIG_DIR")
.env("RTK_TELEMETRY_DISABLED", "1")
.env("LC_ALL", "C")
.stdin(Stdio::null());
cmd
}

/// Runs `rtk init <args>`, asserts it succeeded, and returns stdout.
fn init(home: &Path, args: &[&str]) -> String {
let out = rtk(home).arg("init").args(args).output().unwrap();
assert!(
out.status.success(),
"rtk init {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}

fn plugin(home: &Path) -> std::path::PathBuf {
home.join(".config")
.join("opencode")
.join("plugins")
.join("rtk.ts")
}

fn claude_hook_registered(home: &Path) -> bool {
std::fs::read_to_string(home.join(".claude").join("settings.json"))
.is_ok_and(|settings| settings.contains("rtk hook claude"))
}

#[test]
fn opencode_installs_claude_hook_and_plugin() {
let home = tempfile::tempdir().unwrap();
init(home.path(), &["-g", "--opencode", "--auto-patch"]);
assert!(
claude_hook_registered(home.path()),
"--opencode must also register the Claude hook"
);
assert!(
home.path().join(".claude").join("RTK.md").exists(),
"--opencode must also write RTK.md"
);
assert!(plugin(home.path()).exists(), "OpenCode plugin missing");
}

#[test]
fn opencode_hook_only_installs_claude_hook_and_plugin() {
let home = tempfile::tempdir().unwrap();
init(
home.path(),
&["-g", "--opencode", "--hook-only", "--auto-patch"],
);
assert!(
claude_hook_registered(home.path()),
"--opencode --hook-only must also register the Claude hook"
);
assert!(
!home.path().join(".claude").join("RTK.md").exists(),
"--hook-only must not write RTK.md"
);
assert!(plugin(home.path()).exists(), "OpenCode plugin missing");
}

#[test]
fn hook_only_without_patching_leaves_no_claude_dir() {
let home = tempfile::tempdir().unwrap();
init(home.path(), &["-g", "--hook-only", "--no-patch"]);
assert!(
!home.path().join(".claude").exists(),
"nothing is written under ~/.claude, so it must not be created"
);
}

#[test]
fn opencode_claude_md_rerun_restores_missing_plugin() {
let home = tempfile::tempdir().unwrap();
init(home.path(), &["-g", "--opencode", "--claude-md"]);
std::fs::remove_file(plugin(home.path())).unwrap();

// The CLAUDE.md block is unchanged now; the plugin must still come back.
let stdout = init(home.path(), &["-g", "--opencode", "--claude-md"]);
assert!(stdout.contains("already up to date"), "{stdout}");
assert!(
!stdout.contains("will now use rtk"),
"an unchanged block stays silent:\n{stdout}"
);
assert!(
plugin(home.path()).exists(),
"re-running init must restore the OpenCode plugin"
);
}

#[test]
fn opencode_dry_run_names_both_and_writes_nothing() {
let home = tempfile::tempdir().unwrap();
let stdout = init(
home.path(),
&["-g", "--opencode", "--auto-patch", "--dry-run"],
);
assert!(
stdout.contains("/.claude/settings.json"),
"dry-run must name the Claude hook patch:\n{stdout}"
);
assert!(
stdout.contains("/opencode/plugins/rtk.ts"),
"dry-run must name the OpenCode plugin:\n{stdout}"
);
let leftovers: Vec<_> = std::fs::read_dir(home.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert!(leftovers.is_empty(), "dry-run wrote: {leftovers:?}");
}
Loading