Skip to content

Commit f16853a

Browse files
feat: add Gemini CLI support via rtk init --gemini (#573)
- Add `rtk hook gemini` command: native Rust hook processor for Gemini CLI BeforeTool hooks. Reads JSON from stdin, delegates to `rewrite_command()` (single source of truth), outputs Gemini-format JSON response. - Add `--gemini` flag to `rtk init`: installs hook wrapper script, GEMINI.md, and patches ~/.gemini/settings.json with BeforeTool hook entry. - Add `rtk init -g --gemini --uninstall`: clean removal of all Gemini artifacts. - 6 unit tests covering hook format, rewrite delegation, and exclusions. Replaces PR #174 which had too many conflicts after upstream restructuring. Signed-off-by: Ousama Ben Younes <benyounes.ousama@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 56160d8 commit f16853a

4 files changed

Lines changed: 412 additions & 3 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ rtk gain # Should show token savings stats
102102
# 1. Install hook for Claude Code (recommended)
103103
rtk init --global
104104
# Follow instructions to register in ~/.claude/settings.json
105-
# Claude Code only by default (use --opencode for OpenCode)
105+
# Claude Code only by default (use --opencode for OpenCode, --gemini for Gemini CLI)
106106

107107
# 2. Restart Claude Code, then test
108108
git status # Automatically rewritten to rtk git status
@@ -287,6 +287,27 @@ rtk init --show # Verify installation
287287

288288
After install, **restart Claude Code**.
289289

290+
## Gemini CLI Support (Global)
291+
292+
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.
293+
294+
**Install Gemini hook:**
295+
```bash
296+
rtk init -g --gemini
297+
```
298+
299+
**What it creates:**
300+
- `~/.gemini/hooks/rtk-hook-gemini.sh` (thin wrapper calling `rtk hook gemini`)
301+
- `~/.gemini/GEMINI.md` (RTK awareness instructions)
302+
- Patches `~/.gemini/settings.json` with BeforeTool hook
303+
304+
**Uninstall:**
305+
```bash
306+
rtk init -g --gemini --uninstall
307+
```
308+
309+
**Restart Required**: Restart Gemini CLI, then test with `git status` in a session.
310+
290311
## OpenCode Plugin (Global)
291312

292313
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.

src/hook_cmd.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use anyhow::{Context, Result};
2+
use serde_json::Value;
3+
use std::io::{self, Read};
4+
5+
use crate::discover::registry::rewrite_command;
6+
7+
/// Run the Gemini CLI BeforeTool hook.
8+
/// Reads JSON from stdin, rewrites shell commands to rtk equivalents,
9+
/// outputs JSON to stdout in Gemini CLI format.
10+
pub fn run_gemini() -> Result<()> {
11+
let mut input = String::new();
12+
io::stdin()
13+
.read_to_string(&mut input)
14+
.context("Failed to read hook input from stdin")?;
15+
16+
let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?;
17+
18+
let tool_name = json.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
19+
20+
if tool_name != "run_shell_command" {
21+
print_allow();
22+
return Ok(());
23+
}
24+
25+
let cmd = json
26+
.pointer("/tool_input/command")
27+
.and_then(|v| v.as_str())
28+
.unwrap_or("");
29+
30+
if cmd.is_empty() {
31+
print_allow();
32+
return Ok(());
33+
}
34+
35+
// Delegate to the single source of truth for command rewriting
36+
match rewrite_command(cmd, &[]) {
37+
Some(rewritten) => print_rewrite(&rewritten),
38+
None => print_allow(),
39+
}
40+
41+
Ok(())
42+
}
43+
44+
fn print_allow() {
45+
println!(r#"{{"decision":"allow"}}"#);
46+
}
47+
48+
fn print_rewrite(cmd: &str) {
49+
let output = serde_json::json!({
50+
"decision": "allow",
51+
"hookSpecificOutput": {
52+
"tool_input": {
53+
"command": cmd
54+
}
55+
}
56+
});
57+
println!("{}", output);
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
64+
#[test]
65+
fn test_print_allow_format() {
66+
// Verify the allow JSON format matches Gemini CLI expectations
67+
let expected = r#"{"decision":"allow"}"#;
68+
assert_eq!(expected, r#"{"decision":"allow"}"#);
69+
}
70+
71+
#[test]
72+
fn test_print_rewrite_format() {
73+
let output = serde_json::json!({
74+
"decision": "allow",
75+
"hookSpecificOutput": {
76+
"tool_input": {
77+
"command": "rtk git status"
78+
}
79+
}
80+
});
81+
let json: Value = serde_json::from_str(&output.to_string()).unwrap();
82+
assert_eq!(json["decision"], "allow");
83+
assert_eq!(
84+
json["hookSpecificOutput"]["tool_input"]["command"],
85+
"rtk git status"
86+
);
87+
}
88+
89+
#[test]
90+
fn test_gemini_hook_uses_rewrite_command() {
91+
// Verify that rewrite_command handles the cases we need for Gemini
92+
assert_eq!(
93+
rewrite_command("git status", &[]),
94+
Some("rtk git status".into())
95+
);
96+
assert_eq!(
97+
rewrite_command("cargo test", &[]),
98+
Some("rtk cargo test".into())
99+
);
100+
// Already rtk → returned as-is (idempotent)
101+
assert_eq!(
102+
rewrite_command("rtk git status", &[]),
103+
Some("rtk git status".into())
104+
);
105+
// Heredoc → no rewrite
106+
assert_eq!(rewrite_command("cat <<EOF", &[]), None);
107+
}
108+
109+
#[test]
110+
fn test_gemini_hook_excluded_commands() {
111+
let excluded = vec!["curl".to_string()];
112+
assert_eq!(rewrite_command("curl https://example.com", &excluded), None);
113+
// Non-excluded still rewrites
114+
assert_eq!(
115+
rewrite_command("git status", &excluded),
116+
Some("rtk git status".into())
117+
);
118+
}
119+
120+
#[test]
121+
fn test_gemini_hook_env_prefix_preserved() {
122+
assert_eq!(
123+
rewrite_command("RUST_LOG=debug cargo test", &[]),
124+
Some("RUST_LOG=debug rtk cargo test".into())
125+
);
126+
}
127+
}

0 commit comments

Comments
 (0)