Skip to main content

coding_agent_hooks/agents/
gemini.rs

1//! Gemini CLI hook protocol implementation.
2//!
3//! Key differences from the default protocol:
4//! - Tool input rewriting uses `hookSpecificOutput.tool_input` merge
5//! - Session start uses `hookSpecificOutput` wrapper
6
7use anyhow::Result;
8use serde_json::Value;
9
10use super::{AgentKind, resolve_tool_name};
11use crate::input::ToolUseHookInput;
12use crate::protocol::{HookProtocol, json_str};
13
14pub struct GeminiProtocol;
15
16impl HookProtocol for GeminiProtocol {
17    fn agent(&self) -> AgentKind {
18        AgentKind::Gemini
19    }
20
21    fn parse_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput> {
22        let tool_name = json_str(raw, "tool_name").to_string();
23        let original = tool_name.clone();
24        let resolved = resolve_tool_name(AgentKind::Gemini, &tool_name).to_string();
25
26        Ok(ToolUseHookInput {
27            session_id: json_str(raw, "session_id").to_string(),
28            transcript_path: json_str(raw, "transcript_path").to_string(),
29            cwd: json_str(raw, "cwd").to_string(),
30            permission_mode: "default".to_string(),
31            hook_event_name: json_str(raw, "hook_event_name").to_string(),
32            tool_name: resolved,
33            tool_input: raw
34                .get("tool_input")
35                .cloned()
36                .unwrap_or(Value::Object(serde_json::Map::new())),
37            tool_use_id: None,
38            tool_response: raw.get("tool_response").cloned(),
39            agent: Some(AgentKind::Gemini),
40            original_tool_name: Some(original),
41        })
42    }
43
44    // Gemini uses hookSpecificOutput for tool input rewrites
45    fn format_allow(
46        &self,
47        reason: Option<&str>,
48        _context: Option<&str>,
49        updated_input: Option<Value>,
50    ) -> Value {
51        let mut output = serde_json::json!({ "decision": "allow" });
52        if let Some(r) = reason {
53            output["reason"] = Value::String(r.to_string());
54        }
55        if let Some(ui) = updated_input {
56            output["hookSpecificOutput"] = serde_json::json!({ "tool_input": ui });
57        }
58        output
59    }
60
61    fn format_session_start(&self, context: Option<&str>) -> Value {
62        let mut output = serde_json::json!({ "decision": "allow" });
63        if let Some(ctx) = context {
64            output["hookSpecificOutput"] = serde_json::json!({
65                "hookEventName": "SessionStart",
66                "additionalContext": ctx
67            });
68        }
69        output
70    }
71
72    // format_deny, format_ask, rewrite_for_sandbox, session_context — use defaults
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn parse_gemini_tool_use() {
81        let raw = serde_json::json!({
82            "session_id": "gem-123",
83            "transcript_path": "/tmp/gemini.jsonl",
84            "cwd": "/home/user",
85            "hook_event_name": "BeforeTool",
86            "tool_name": "run_shell_command",
87            "tool_input": {"command": "git status"},
88            "timestamp": "2026-03-26T00:00:00Z"
89        });
90        let input = GeminiProtocol.parse_tool_use(&raw).unwrap();
91        assert_eq!(input.tool_name, "Bash");
92        assert_eq!(
93            input.original_tool_name.as_deref(),
94            Some("run_shell_command")
95        );
96        assert_eq!(input.agent, Some(AgentKind::Gemini));
97    }
98
99    #[test]
100    fn parse_gemini_read_file() {
101        let raw = serde_json::json!({
102            "session_id": "gem-123",
103            "cwd": "/home/user",
104            "hook_event_name": "BeforeTool",
105            "tool_name": "read_file",
106            "tool_input": {"file_path": "/tmp/foo.txt"}
107        });
108        let input = GeminiProtocol.parse_tool_use(&raw).unwrap();
109        assert_eq!(input.tool_name, "Read");
110    }
111
112    #[test]
113    fn format_allow_with_rewrite() {
114        let updated = serde_json::json!({"command": "sandboxed"});
115        let out = GeminiProtocol.format_allow(Some("ok"), None, Some(updated.clone()));
116        assert_eq!(out["hookSpecificOutput"]["tool_input"], updated);
117    }
118
119    #[test]
120    fn format_deny_uses_default() {
121        let out = GeminiProtocol.format_deny("blocked", None);
122        assert_eq!(out["decision"], "deny");
123        assert_eq!(out["reason"], "blocked");
124    }
125
126    #[test]
127    fn format_ask_uses_default() {
128        let out = GeminiProtocol.format_ask(None, None);
129        assert_eq!(out["continue"], true);
130    }
131}