mi6_core/framework/
gemini.rs

1//! Gemini CLI framework adapter.
2//!
3//! This adapter handles integration with Google's Gemini CLI tool.
4//! Gemini CLI uses a similar hook system to Claude Code with the same
5//! JSON configuration structure in `.gemini/settings.json`.
6
7use super::{FrameworkAdapter, ParsedHookInput, common};
8use crate::model::EventType;
9use std::borrow::Cow;
10use std::path::PathBuf;
11
12/// OTel environment variable keys used by Gemini CLI.
13const GEMINI_OTEL_KEYS: &[&str] = &[
14    "OTEL_LOGS_EXPORTER",
15    "OTEL_EXPORTER_OTLP_PROTOCOL",
16    "OTEL_EXPORTER_OTLP_ENDPOINT",
17];
18
19/// Gemini CLI framework adapter.
20///
21/// Handles hook generation and event parsing for Gemini CLI.
22pub struct GeminiAdapter;
23
24impl FrameworkAdapter for GeminiAdapter {
25    fn name(&self) -> &'static str {
26        "gemini"
27    }
28
29    fn display_name(&self) -> &'static str {
30        "Gemini CLI"
31    }
32
33    fn project_config_path(&self) -> PathBuf {
34        PathBuf::from(".gemini/settings.json")
35    }
36
37    fn user_config_path(&self) -> Option<PathBuf> {
38        dirs::home_dir().map(|h| h.join(".gemini/settings.json"))
39    }
40
41    fn generate_hooks_config(
42        &self,
43        enabled_events: &[EventType],
44        mi6_bin: &str,
45        otel_enabled: bool,
46        otel_port: u16,
47    ) -> serde_json::Value {
48        let mut hooks = serde_json::Map::new();
49
50        // Gemini-supported event names
51        let gemini_events: std::collections::HashSet<&str> =
52            self.supported_events().into_iter().collect();
53
54        // Process canonical events that map to Gemini events
55        for event in enabled_events {
56            // Map canonical event types to Gemini CLI event names
57            let gemini_event = canonical_to_gemini(event);
58
59            // Skip events that Gemini doesn't support
60            if !gemini_events.contains(gemini_event.as_ref()) {
61                continue;
62            }
63
64            // For SessionStart with otel, also ensure the otel server is running
65            // Always include --framework flag for explicit framework identification
66            let command = if otel_enabled && *event == EventType::SessionStart {
67                format!(
68                    "{} otel start --port {} </dev/null >/dev/null 2>&1; {} ingest event {} --framework gemini",
69                    mi6_bin, otel_port, mi6_bin, gemini_event
70                )
71            } else {
72                format!(
73                    "{} ingest event {} --framework gemini",
74                    mi6_bin, gemini_event
75                )
76            };
77
78            let hook_entry = serde_json::json!([{
79                "hooks": [{
80                    "type": "command",
81                    "command": command,
82                    "timeout": 10000
83                }]
84            }]);
85            hooks.insert(gemini_event.into_owned(), hook_entry);
86        }
87
88        // Add framework-specific events that have no canonical equivalent
89        for event_name in self.framework_specific_events() {
90            // Skip if already added (shouldn't happen, but be safe)
91            if hooks.contains_key(event_name) {
92                continue;
93            }
94
95            let command = format!("{} ingest event {} --framework gemini", mi6_bin, event_name);
96
97            let hook_entry = serde_json::json!([{
98                "hooks": [{
99                    "type": "command",
100                    "command": command,
101                    "timeout": 10000
102                }]
103            }]);
104            hooks.insert(event_name.to_string(), hook_entry);
105        }
106
107        serde_json::json!({
108            "tools": {
109                "enableHooks": true
110            },
111            "hooks": hooks
112        })
113    }
114
115    fn merge_config(
116        &self,
117        generated: serde_json::Value,
118        existing: Option<serde_json::Value>,
119    ) -> serde_json::Value {
120        let mut settings = common::merge_json_hooks(generated, existing);
121
122        // Gemini-specific: ensure tools.enableHooks is set
123        if let Some(tools) = settings.get_mut("tools") {
124            if let Some(tools_obj) = tools.as_object_mut() {
125                tools_obj.insert("enableHooks".to_string(), serde_json::json!(true));
126            }
127        } else {
128            settings["tools"] = serde_json::json!({ "enableHooks": true });
129        }
130
131        settings
132    }
133
134    fn parse_hook_input(
135        &self,
136        _event_type: &str,
137        stdin_json: &serde_json::Value,
138    ) -> ParsedHookInput {
139        ParsedHookInput {
140            session_id: stdin_json
141                .get("session_id")
142                .and_then(|v| v.as_str())
143                .map(String::from),
144            tool_use_id: stdin_json
145                .get("tool_use_id")
146                .and_then(|v| v.as_str())
147                .map(String::from),
148            tool_name: stdin_json
149                .get("tool_name")
150                .and_then(|v| v.as_str())
151                .map(String::from),
152            cwd: stdin_json
153                .get("cwd")
154                .and_then(|v| v.as_str())
155                .map(String::from),
156            transcript_path: stdin_json
157                .get("transcript_path")
158                .and_then(|v| v.as_str())
159                .map(String::from),
160            // Gemini CLI provides tool_input directly (not nested)
161            subagent_type: stdin_json
162                .get("tool_input")
163                .and_then(|ti| ti.get("subagent_type"))
164                .and_then(|v| v.as_str())
165                .map(String::from),
166            // Gemini CLI does not provide these fields
167            permission_mode: None,
168            spawned_agent_id: None,
169            session_source: None,
170            agent_id: None,
171            agent_transcript_path: None,
172            compact_trigger: None,
173        }
174    }
175
176    fn map_event_type(&self, framework_event: &str) -> EventType {
177        match framework_event {
178            // Gemini CLI uses different names for tool events
179            "BeforeTool" => EventType::PreToolUse,
180            "AfterTool" => EventType::PostToolUse,
181            // BeforeAgent maps to UserPromptSubmit (after user submits prompt)
182            "BeforeAgent" => EventType::UserPromptSubmit,
183            // AfterAgent maps to Stop (both indicate agent finished processing)
184            "AfterAgent" => EventType::Stop,
185            // PreCompress maps to PreCompact
186            "PreCompress" => EventType::PreCompact,
187            // These are direct mappings
188            "SessionStart" => EventType::SessionStart,
189            "SessionEnd" => EventType::SessionEnd,
190            "Notification" => EventType::Notification,
191            // Model events and other Gemini-specific events become Custom
192            "BeforeModel" | "AfterModel" | "BeforeToolSelection" => {
193                EventType::Custom(framework_event.to_string())
194            }
195            // Try canonical parsing, fall back to custom
196            other => other
197                .parse()
198                .unwrap_or_else(|_| EventType::Custom(other.to_string())),
199        }
200    }
201
202    fn supported_events(&self) -> Vec<&'static str> {
203        vec![
204            "SessionStart",
205            "SessionEnd",
206            "BeforeAgent",
207            "AfterAgent",
208            "BeforeModel",
209            "AfterModel",
210            "BeforeToolSelection",
211            "BeforeTool",
212            "AfterTool",
213            "PreCompress",
214            "Notification",
215        ]
216    }
217
218    fn framework_specific_events(&self) -> Vec<&'static str> {
219        // These Gemini events have no canonical equivalent
220        vec!["BeforeModel", "AfterModel", "BeforeToolSelection"]
221    }
222
223    fn detection_env_vars(&self) -> &[&'static str] {
224        &["GEMINI_SESSION_ID", "GEMINI_PROJECT_DIR"]
225    }
226
227    fn is_installed(&self) -> bool {
228        common::is_framework_installed(self.user_config_path(), "gemini")
229    }
230
231    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
232        common::remove_json_hooks(existing, GEMINI_OTEL_KEYS)
233    }
234}
235
236/// Map canonical event types to Gemini CLI event names
237fn canonical_to_gemini(event: &EventType) -> Cow<'static, str> {
238    match event {
239        EventType::SessionStart => Cow::Borrowed("SessionStart"),
240        EventType::SessionEnd => Cow::Borrowed("SessionEnd"),
241        EventType::PreToolUse => Cow::Borrowed("BeforeTool"),
242        EventType::PostToolUse => Cow::Borrowed("AfterTool"),
243        EventType::UserPromptSubmit => Cow::Borrowed("BeforeAgent"),
244        EventType::PreCompact => Cow::Borrowed("PreCompress"),
245        EventType::Notification => Cow::Borrowed("Notification"),
246        // Map Stop to AfterAgent - both indicate agent finished processing
247        EventType::Stop => Cow::Borrowed("AfterAgent"),
248        // Events that don't have a Gemini equivalent use their canonical name
249        EventType::PermissionRequest => Cow::Borrowed("PermissionRequest"),
250        EventType::SubagentStart => Cow::Borrowed("SubagentStart"),
251        EventType::SubagentStop => Cow::Borrowed("SubagentStop"),
252        EventType::ApiRequest => Cow::Borrowed("ApiRequest"),
253        EventType::Custom(s) => Cow::Owned(s.clone()),
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_name() {
263        let adapter = GeminiAdapter;
264        assert_eq!(adapter.name(), "gemini");
265        assert_eq!(adapter.display_name(), "Gemini CLI");
266    }
267
268    #[test]
269    fn test_project_config_path() {
270        let adapter = GeminiAdapter;
271        assert_eq!(
272            adapter.project_config_path(),
273            PathBuf::from(".gemini/settings.json")
274        );
275    }
276
277    #[test]
278    fn test_map_event_type() {
279        let adapter = GeminiAdapter;
280        // Gemini-specific mappings
281        assert_eq!(adapter.map_event_type("BeforeTool"), EventType::PreToolUse);
282        assert_eq!(adapter.map_event_type("AfterTool"), EventType::PostToolUse);
283        assert_eq!(
284            adapter.map_event_type("BeforeAgent"),
285            EventType::UserPromptSubmit
286        );
287        // AfterAgent maps to Stop (both indicate agent finished processing)
288        assert_eq!(adapter.map_event_type("AfterAgent"), EventType::Stop);
289        assert_eq!(adapter.map_event_type("PreCompress"), EventType::PreCompact);
290        // Direct mappings
291        assert_eq!(
292            adapter.map_event_type("SessionStart"),
293            EventType::SessionStart
294        );
295        assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
296        assert_eq!(
297            adapter.map_event_type("Notification"),
298            EventType::Notification
299        );
300        // Model events become Custom
301        assert_eq!(
302            adapter.map_event_type("BeforeModel"),
303            EventType::Custom("BeforeModel".to_string())
304        );
305        assert_eq!(
306            adapter.map_event_type("AfterModel"),
307            EventType::Custom("AfterModel".to_string())
308        );
309    }
310
311    #[test]
312    fn test_parse_hook_input() {
313        let adapter = GeminiAdapter;
314        let input = serde_json::json!({
315            "session_id": "gemini-session-123",
316            "tool_use_id": "tool-456",
317            "tool_name": "write_file",
318            "cwd": "/projects/test",
319            "transcript_path": "/tmp/transcript.jsonl",
320            "tool_input": {
321                "file_path": "/tmp/test.txt",
322                "content": "hello"
323            }
324        });
325
326        let parsed = adapter.parse_hook_input("BeforeTool", &input);
327
328        assert_eq!(parsed.session_id, Some("gemini-session-123".to_string()));
329        assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
330        assert_eq!(parsed.tool_name, Some("write_file".to_string()));
331        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
332        assert_eq!(
333            parsed.transcript_path,
334            Some("/tmp/transcript.jsonl".to_string())
335        );
336        // Verify that permission_mode and spawned_agent_id are not extracted
337        assert_eq!(parsed.permission_mode, None);
338        assert_eq!(parsed.spawned_agent_id, None);
339    }
340
341    #[test]
342    fn test_parse_hook_input_with_subagent_type() {
343        let adapter = GeminiAdapter;
344        let input = serde_json::json!({
345            "session_id": "gemini-123",
346            "tool_input": {
347                "subagent_type": "Explore"
348            }
349        });
350
351        let parsed = adapter.parse_hook_input("BeforeTool", &input);
352
353        assert_eq!(parsed.session_id, Some("gemini-123".to_string()));
354        assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
355    }
356
357    #[test]
358    fn test_generate_hooks_config() -> Result<(), String> {
359        let adapter = GeminiAdapter;
360        let events = vec![EventType::SessionStart, EventType::PreToolUse];
361
362        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
363
364        let hooks = config
365            .get("hooks")
366            .ok_or("missing hooks")?
367            .as_object()
368            .ok_or("hooks not an object")?;
369        // Should use Gemini event names
370        assert!(hooks.contains_key("SessionStart"));
371        assert!(hooks.contains_key("BeforeTool")); // PreToolUse -> BeforeTool
372
373        let before_tool = &hooks["BeforeTool"][0]["hooks"][0];
374        let command = before_tool["command"].as_str().ok_or("missing command")?;
375        assert!(command.contains("mi6 ingest event BeforeTool"));
376        // Should include explicit --framework flag
377        assert!(command.contains("--framework gemini"));
378        // Type should be "command"
379        assert_eq!(
380            before_tool["type"].as_str().ok_or("missing type")?,
381            "command"
382        );
383        // Timeout should be in milliseconds
384        assert_eq!(
385            before_tool["timeout"].as_i64().ok_or("missing timeout")?,
386            10000
387        );
388
389        // Should also include framework-specific events
390        assert!(
391            hooks.contains_key("BeforeModel"),
392            "Framework-specific BeforeModel should be included"
393        );
394        assert!(
395            hooks.contains_key("AfterModel"),
396            "Framework-specific AfterModel should be included"
397        );
398        assert!(
399            hooks.contains_key("BeforeToolSelection"),
400            "Framework-specific BeforeToolSelection should be included"
401        );
402        Ok(())
403    }
404
405    #[test]
406    fn test_framework_specific_events() {
407        let adapter = GeminiAdapter;
408        let events = adapter.framework_specific_events();
409
410        assert!(events.contains(&"BeforeModel"));
411        assert!(events.contains(&"AfterModel"));
412        assert!(events.contains(&"BeforeToolSelection"));
413        assert_eq!(events.len(), 3);
414    }
415
416    #[test]
417    fn test_generate_hooks_config_with_otel() -> Result<(), String> {
418        let adapter = GeminiAdapter;
419        let events = vec![EventType::SessionStart];
420
421        let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
422
423        let hooks = config
424            .get("hooks")
425            .ok_or("missing hooks")?
426            .as_object()
427            .ok_or("hooks not an object")?;
428        let session_start = &hooks["SessionStart"][0]["hooks"][0];
429        let command = session_start["command"].as_str().ok_or("missing command")?;
430
431        assert!(command.contains("otel start"));
432        assert!(command.contains("--port 4318"));
433        assert!(command.contains("--framework gemini"));
434        Ok(())
435    }
436
437    #[test]
438    fn test_merge_config_new() {
439        let adapter = GeminiAdapter;
440        let generated = serde_json::json!({
441            "hooks": {
442                "BeforeTool": [{"matcher": "", "hooks": []}]
443            }
444        });
445
446        let merged = adapter.merge_config(generated, None);
447
448        assert!(merged.get("hooks").is_some());
449        assert!(merged["hooks"].get("BeforeTool").is_some());
450    }
451
452    #[test]
453    fn test_merge_config_existing() {
454        let adapter = GeminiAdapter;
455        let generated = serde_json::json!({
456            "hooks": {
457                "BeforeTool": [{"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
458            }
459        });
460        let existing = serde_json::json!({
461            "theme": "dark",
462            "hooks": {
463                "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
464            }
465        });
466
467        let merged = adapter.merge_config(generated, Some(existing));
468
469        // Should preserve existing settings
470        assert_eq!(merged["theme"], "dark");
471        // Should merge hooks
472        assert!(merged["hooks"].get("SessionStart").is_some());
473        assert!(merged["hooks"].get("BeforeTool").is_some());
474    }
475
476    #[test]
477    fn test_supported_events() {
478        let adapter = GeminiAdapter;
479        let events = adapter.supported_events();
480
481        assert!(events.contains(&"SessionStart"));
482        assert!(events.contains(&"BeforeTool"));
483        assert!(events.contains(&"AfterTool"));
484        assert!(events.contains(&"BeforeModel"));
485        assert!(events.contains(&"AfterModel"));
486    }
487
488    #[test]
489    fn test_detection_env_vars() {
490        let adapter = GeminiAdapter;
491        let vars = adapter.detection_env_vars();
492
493        assert!(vars.contains(&"GEMINI_SESSION_ID"));
494        assert!(vars.contains(&"GEMINI_PROJECT_DIR"));
495    }
496}