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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ rtk gain # Should show token savings stats
# 1. Install hook for Claude Code (recommended)
rtk init --global
# Follow instructions to register in ~/.claude/settings.json
# Claude Code only by default (use --opencode for OpenCode)
# Claude Code only by default (use --opencode for OpenCode, --gemini for Gemini CLI)

# 2. Restart Claude Code, then test
git status # Automatically rewritten to rtk git status
Expand Down Expand Up @@ -285,6 +285,27 @@ rtk init --show # Verify installation

After install, **restart Claude Code**.

## Gemini CLI Support (Global)

RTK supports Gemini CLI via a native Rust hook processor. The hook intercepts `run_shell_command` tool calls and rewrites them to `rtk` equivalents using the same rewrite engine as Claude Code.

**Install Gemini hook:**
```bash
rtk init -g --gemini
```

**What it creates:**
- `~/.gemini/hooks/rtk-hook-gemini.sh` (thin wrapper calling `rtk hook gemini`)
- `~/.gemini/GEMINI.md` (RTK awareness instructions)
- Patches `~/.gemini/settings.json` with BeforeTool hook

**Uninstall:**
```bash
rtk init -g --gemini --uninstall
```

**Restart Required**: Restart Gemini CLI, then test with `git status` in a session.

## OpenCode Plugin (Global)

OpenCode supports plugins that can intercept tool execution. RTK provides a global plugin that mirrors the Claude auto-rewrite behavior by rewriting Bash tool commands to `rtk ...` before they execute. This plugin is **not** installed by default.
Expand Down
127 changes: 127 additions & 0 deletions src/hook_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use anyhow::{Context, Result};
use serde_json::Value;
use std::io::{self, Read};

use crate::discover::registry::rewrite_command;

/// Run the Gemini CLI BeforeTool hook.
/// Reads JSON from stdin, rewrites shell commands to rtk equivalents,
/// outputs JSON to stdout in Gemini CLI format.
pub fn run_gemini() -> Result<()> {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.context("Failed to read hook input from stdin")?;

let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?;

let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");

if tool_name != "run_shell_command" {
print_allow();
return Ok(());
}

let cmd = json
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.unwrap_or("");

if cmd.is_empty() {
print_allow();
return Ok(());
}

// Delegate to the single source of truth for command rewriting
match rewrite_command(cmd, &[]) {
Some(rewritten) => print_rewrite(&rewritten),
None => print_allow(),
}

Ok(())
}

fn print_allow() {
println!(r#"{{"decision":"allow"}}"#);
}

fn print_rewrite(cmd: &str) {
let output = serde_json::json!({
"decision": "allow",
"hookSpecificOutput": {
"tool_input": {
"command": cmd
}
}
});
println!("{}", output);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_print_allow_format() {
// Verify the allow JSON format matches Gemini CLI expectations
let expected = r#"{"decision":"allow"}"#;
assert_eq!(expected, r#"{"decision":"allow"}"#);
}

#[test]
fn test_print_rewrite_format() {
let output = serde_json::json!({
"decision": "allow",
"hookSpecificOutput": {
"tool_input": {
"command": "rtk git status"
}
}
});
let json: Value = serde_json::from_str(&output.to_string()).unwrap();
assert_eq!(json["decision"], "allow");
assert_eq!(
json["hookSpecificOutput"]["tool_input"]["command"],
"rtk git status"
);
}

#[test]
fn test_gemini_hook_uses_rewrite_command() {
// Verify that rewrite_command handles the cases we need for Gemini
assert_eq!(
rewrite_command("git status", &[]),
Some("rtk git status".into())
);
assert_eq!(
rewrite_command("cargo test", &[]),
Some("rtk cargo test".into())
);
// Already rtk → returned as-is (idempotent)
assert_eq!(
rewrite_command("rtk git status", &[]),
Some("rtk git status".into())
);
// Heredoc → no rewrite
assert_eq!(rewrite_command("cat <<EOF", &[]), None);
}

#[test]
fn test_gemini_hook_excluded_commands() {
let excluded = vec!["curl".to_string()];
assert_eq!(rewrite_command("curl https://example.com", &excluded), None);
// Non-excluded still rewrites
assert_eq!(
rewrite_command("git status", &excluded),
Some("rtk git status".into())
);
}

#[test]
fn test_gemini_hook_env_prefix_preserved() {
assert_eq!(
rewrite_command("RUST_LOG=debug cargo test", &[]),
Some("RUST_LOG=debug rtk cargo test".into())
);
}
}
Loading
Loading