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, ParsedHookInputBuilder, 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        ParsedHookInputBuilder::new(stdin_json)
140            .session_id("session_id")
141            .tool_use_id("tool_use_id")
142            .tool_name("tool_name")
143            .cwd("cwd")
144            .transcript_path("transcript_path")
145            .subagent_type("tool_input.subagent_type")
146            .model("llm_request.model")
147            .session_source("source")
148            .compact_trigger("trigger")
149            .build()
150        // Note: Gemini doesn't provide permission_mode, spawned_agent_id, agent_id,
151        // agent_transcript_path, duration_ms, tokens, or cost via hooks
152    }
153
154    fn map_event_type(&self, framework_event: &str) -> EventType {
155        match framework_event {
156            // Gemini CLI uses different names for tool events
157            "BeforeTool" => EventType::PreToolUse,
158            "AfterTool" => EventType::PostToolUse,
159            // BeforeAgent maps to UserPromptSubmit (after user submits prompt)
160            "BeforeAgent" => EventType::UserPromptSubmit,
161            // AfterAgent maps to Stop (both indicate agent finished processing)
162            "AfterAgent" => EventType::Stop,
163            // PreCompress maps to PreCompact
164            "PreCompress" => EventType::PreCompact,
165            // These are direct mappings
166            "SessionStart" => EventType::SessionStart,
167            "SessionEnd" => EventType::SessionEnd,
168            "Notification" => EventType::Notification,
169            // AfterModel also maps to Stop as a fallback for when AfterAgent doesn't fire.
170            // Gemini CLI may not reliably fire AfterAgent, but AfterModel fires after each
171            // model response. If tools are used, AfterTool (PostToolUse) sets status back
172            // to busy, so the final AfterModel will correctly set status to idle.
173            "AfterModel" => EventType::Stop,
174            // Other model events become Custom
175            "BeforeModel" | "BeforeToolSelection" => EventType::Custom(framework_event.to_string()),
176            // Try canonical parsing, fall back to custom
177            other => other
178                .parse()
179                .unwrap_or_else(|_| EventType::Custom(other.to_string())),
180        }
181    }
182
183    fn supported_events(&self) -> Vec<&'static str> {
184        vec![
185            "SessionStart",
186            "SessionEnd",
187            "BeforeAgent",
188            "AfterAgent",
189            "BeforeModel",
190            "AfterModel",
191            "BeforeToolSelection",
192            "BeforeTool",
193            "AfterTool",
194            "PreCompress",
195            "Notification",
196        ]
197    }
198
199    fn framework_specific_events(&self) -> Vec<&'static str> {
200        // These Gemini events don't have direct canonical mappings via canonical_to_gemini(),
201        // so they must be added separately to ensure hooks are registered.
202        // Note: AfterModel is included here even though it maps to Stop internally,
203        // because we need to register a hook for it separately from AfterAgent.
204        vec!["BeforeModel", "AfterModel", "BeforeToolSelection"]
205    }
206
207    fn detection_env_vars(&self) -> &[&'static str] {
208        &["GEMINI_SESSION_ID", "GEMINI_PROJECT_DIR"]
209    }
210
211    fn is_installed(&self) -> bool {
212        common::is_framework_installed(self.user_config_path(), "gemini")
213    }
214
215    fn otel_support(&self) -> super::OtelSupport {
216        use super::OtelSupport;
217        // Gemini supports OTEL via env vars in settings.json
218        let Some(settings_path) = self.user_config_path() else {
219            return OtelSupport::Disabled;
220        };
221        let Ok(contents) = std::fs::read_to_string(&settings_path) else {
222            return OtelSupport::Disabled;
223        };
224        let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
225            return OtelSupport::Disabled;
226        };
227
228        // Check for OTEL env vars in the "env" section
229        if let Some(env) = json.get("env") {
230            for key in GEMINI_OTEL_KEYS {
231                if env.get(*key).is_some() {
232                    return OtelSupport::Enabled;
233                }
234            }
235        }
236        OtelSupport::Disabled
237    }
238
239    fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
240        common::remove_json_hooks(existing, GEMINI_OTEL_KEYS)
241    }
242
243    fn resume_command(&self, _session_id: &str) -> Option<String> {
244        // Gemini CLI uses index-based resume (`gemini -r <index>`), not session ID.
245        // Launch the resume picker instead.
246        Some("gemini -r".to_string())
247    }
248}
249
250/// Map canonical event types to Gemini CLI event names
251fn canonical_to_gemini(event: &EventType) -> Cow<'static, str> {
252    match event {
253        EventType::SessionStart => Cow::Borrowed("SessionStart"),
254        EventType::SessionEnd => Cow::Borrowed("SessionEnd"),
255        EventType::PreToolUse => Cow::Borrowed("BeforeTool"),
256        EventType::PostToolUse => Cow::Borrowed("AfterTool"),
257        EventType::UserPromptSubmit => Cow::Borrowed("BeforeAgent"),
258        EventType::PreCompact => Cow::Borrowed("PreCompress"),
259        EventType::Notification => Cow::Borrowed("Notification"),
260        // Map Stop to AfterAgent - both indicate agent finished processing
261        EventType::Stop => Cow::Borrowed("AfterAgent"),
262        // Events that don't have a Gemini equivalent use their canonical name
263        EventType::PermissionRequest => Cow::Borrowed("PermissionRequest"),
264        EventType::SubagentStart => Cow::Borrowed("SubagentStart"),
265        EventType::SubagentStop => Cow::Borrowed("SubagentStop"),
266        EventType::ApiRequest => Cow::Borrowed("ApiRequest"),
267        EventType::Custom(s) => Cow::Owned(s.clone()),
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_name() {
277        let adapter = GeminiAdapter;
278        assert_eq!(adapter.name(), "gemini");
279        assert_eq!(adapter.display_name(), "Gemini CLI");
280    }
281
282    #[test]
283    fn test_project_config_path() {
284        let adapter = GeminiAdapter;
285        assert_eq!(
286            adapter.project_config_path(),
287            PathBuf::from(".gemini/settings.json")
288        );
289    }
290
291    #[test]
292    fn test_map_event_type() {
293        let adapter = GeminiAdapter;
294        // Gemini-specific mappings
295        assert_eq!(adapter.map_event_type("BeforeTool"), EventType::PreToolUse);
296        assert_eq!(adapter.map_event_type("AfterTool"), EventType::PostToolUse);
297        assert_eq!(
298            adapter.map_event_type("BeforeAgent"),
299            EventType::UserPromptSubmit
300        );
301        // AfterAgent maps to Stop (both indicate agent finished processing)
302        assert_eq!(adapter.map_event_type("AfterAgent"), EventType::Stop);
303        assert_eq!(adapter.map_event_type("PreCompress"), EventType::PreCompact);
304        // Direct mappings
305        assert_eq!(
306            adapter.map_event_type("SessionStart"),
307            EventType::SessionStart
308        );
309        assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
310        assert_eq!(
311            adapter.map_event_type("Notification"),
312            EventType::Notification
313        );
314        // BeforeModel becomes Custom
315        assert_eq!(
316            adapter.map_event_type("BeforeModel"),
317            EventType::Custom("BeforeModel".to_string())
318        );
319        // AfterModel maps to Stop (fallback for when AfterAgent doesn't fire)
320        assert_eq!(adapter.map_event_type("AfterModel"), EventType::Stop);
321    }
322
323    #[test]
324    fn test_parse_hook_input() {
325        let adapter = GeminiAdapter;
326        let input = serde_json::json!({
327            "session_id": "gemini-session-123",
328            "tool_use_id": "tool-456",
329            "tool_name": "write_file",
330            "cwd": "/projects/test",
331            "transcript_path": "/tmp/transcript.jsonl",
332            "tool_input": {
333                "file_path": "/tmp/test.txt",
334                "content": "hello"
335            }
336        });
337
338        let parsed = adapter.parse_hook_input("BeforeTool", &input);
339
340        assert_eq!(parsed.session_id, Some("gemini-session-123".to_string()));
341        assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
342        assert_eq!(parsed.tool_name, Some("write_file".to_string()));
343        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
344        assert_eq!(
345            parsed.transcript_path,
346            Some("/tmp/transcript.jsonl".to_string())
347        );
348        // Verify that permission_mode and spawned_agent_id are not extracted
349        assert_eq!(parsed.permission_mode, None);
350        assert_eq!(parsed.spawned_agent_id, None);
351    }
352
353    #[test]
354    fn test_parse_hook_input_with_subagent_type() {
355        let adapter = GeminiAdapter;
356        let input = serde_json::json!({
357            "session_id": "gemini-123",
358            "tool_input": {
359                "subagent_type": "Explore"
360            }
361        });
362
363        let parsed = adapter.parse_hook_input("BeforeTool", &input);
364
365        assert_eq!(parsed.session_id, Some("gemini-123".to_string()));
366        assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
367    }
368
369    #[test]
370    fn test_parse_hook_input_session_source() {
371        let adapter = GeminiAdapter;
372        // Gemini CLI uses "source" field for SessionStart events
373        let input = serde_json::json!({
374            "session_id": "gemini-session-456",
375            "source": "startup",
376            "cwd": "/projects/test"
377        });
378
379        let parsed = adapter.parse_hook_input("SessionStart", &input);
380
381        assert_eq!(parsed.session_id, Some("gemini-session-456".to_string()));
382        assert_eq!(parsed.session_source, Some("startup".to_string()));
383        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
384
385        // Test "resume" source
386        let resume_input = serde_json::json!({
387            "session_id": "gemini-session-789",
388            "source": "resume"
389        });
390        let parsed = adapter.parse_hook_input("SessionStart", &resume_input);
391        assert_eq!(parsed.session_source, Some("resume".to_string()));
392    }
393
394    #[test]
395    fn test_parse_hook_input_compact_trigger() {
396        let adapter = GeminiAdapter;
397        // Gemini CLI uses "trigger" field for PreCompress events
398        let input = serde_json::json!({
399            "session_id": "gemini-session-101",
400            "trigger": "auto"
401        });
402
403        let parsed = adapter.parse_hook_input("PreCompress", &input);
404
405        assert_eq!(parsed.session_id, Some("gemini-session-101".to_string()));
406        assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
407
408        // Test "manual" trigger
409        let manual_input = serde_json::json!({
410            "session_id": "gemini-session-102",
411            "trigger": "manual"
412        });
413        let parsed = adapter.parse_hook_input("PreCompress", &manual_input);
414        assert_eq!(parsed.compact_trigger, Some("manual".to_string()));
415    }
416
417    #[test]
418    fn test_generate_hooks_config() -> Result<(), String> {
419        let adapter = GeminiAdapter;
420        let events = vec![EventType::SessionStart, EventType::PreToolUse];
421
422        let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
423
424        let hooks = config
425            .get("hooks")
426            .ok_or("missing hooks")?
427            .as_object()
428            .ok_or("hooks not an object")?;
429        // Should use Gemini event names
430        assert!(hooks.contains_key("SessionStart"));
431        assert!(hooks.contains_key("BeforeTool")); // PreToolUse -> BeforeTool
432
433        let before_tool = &hooks["BeforeTool"][0]["hooks"][0];
434        let command = before_tool["command"].as_str().ok_or("missing command")?;
435        assert!(command.contains("mi6 ingest event BeforeTool"));
436        // Should include explicit --framework flag
437        assert!(command.contains("--framework gemini"));
438        // Type should be "command"
439        assert_eq!(
440            before_tool["type"].as_str().ok_or("missing type")?,
441            "command"
442        );
443        // Timeout should be in milliseconds
444        assert_eq!(
445            before_tool["timeout"].as_i64().ok_or("missing timeout")?,
446            10000
447        );
448
449        // Should also include framework-specific events
450        assert!(
451            hooks.contains_key("BeforeModel"),
452            "Framework-specific BeforeModel should be included"
453        );
454        assert!(
455            hooks.contains_key("AfterModel"),
456            "AfterModel should be included (maps to Stop as fallback)"
457        );
458        assert!(
459            hooks.contains_key("BeforeToolSelection"),
460            "Framework-specific BeforeToolSelection should be included"
461        );
462        Ok(())
463    }
464
465    #[test]
466    fn test_framework_specific_events() {
467        let adapter = GeminiAdapter;
468        let events = adapter.framework_specific_events();
469
470        assert!(events.contains(&"BeforeModel"));
471        // AfterModel is included to ensure a hook is registered, even though
472        // it maps to Stop internally (as a fallback for when AfterAgent doesn't fire)
473        assert!(events.contains(&"AfterModel"));
474        assert!(events.contains(&"BeforeToolSelection"));
475        assert_eq!(events.len(), 3);
476    }
477
478    #[test]
479    fn test_generate_hooks_config_with_otel() -> Result<(), String> {
480        let adapter = GeminiAdapter;
481        let events = vec![EventType::SessionStart];
482
483        let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
484
485        let hooks = config
486            .get("hooks")
487            .ok_or("missing hooks")?
488            .as_object()
489            .ok_or("hooks not an object")?;
490        let session_start = &hooks["SessionStart"][0]["hooks"][0];
491        let command = session_start["command"].as_str().ok_or("missing command")?;
492
493        assert!(command.contains("otel start"));
494        assert!(command.contains("--port 4318"));
495        assert!(command.contains("--framework gemini"));
496        Ok(())
497    }
498
499    #[test]
500    fn test_merge_config_new() {
501        let adapter = GeminiAdapter;
502        let generated = serde_json::json!({
503            "hooks": {
504                "BeforeTool": [{"matcher": "", "hooks": []}]
505            }
506        });
507
508        let merged = adapter.merge_config(generated, None);
509
510        assert!(merged.get("hooks").is_some());
511        assert!(merged["hooks"].get("BeforeTool").is_some());
512    }
513
514    #[test]
515    fn test_merge_config_existing() {
516        let adapter = GeminiAdapter;
517        let generated = serde_json::json!({
518            "hooks": {
519                "BeforeTool": [{"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
520            }
521        });
522        let existing = serde_json::json!({
523            "theme": "dark",
524            "hooks": {
525                "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
526            }
527        });
528
529        let merged = adapter.merge_config(generated, Some(existing));
530
531        // Should preserve existing settings
532        assert_eq!(merged["theme"], "dark");
533        // Should merge hooks
534        assert!(merged["hooks"].get("SessionStart").is_some());
535        assert!(merged["hooks"].get("BeforeTool").is_some());
536    }
537
538    #[test]
539    fn test_supported_events() {
540        let adapter = GeminiAdapter;
541        let events = adapter.supported_events();
542
543        assert!(events.contains(&"SessionStart"));
544        assert!(events.contains(&"BeforeTool"));
545        assert!(events.contains(&"AfterTool"));
546        assert!(events.contains(&"BeforeModel"));
547        assert!(events.contains(&"AfterModel"));
548    }
549
550    #[test]
551    fn test_detection_env_vars() {
552        let adapter = GeminiAdapter;
553        let vars = adapter.detection_env_vars();
554
555        assert!(vars.contains(&"GEMINI_SESSION_ID"));
556        assert!(vars.contains(&"GEMINI_PROJECT_DIR"));
557    }
558
559    #[test]
560    fn test_parse_hook_input_with_model() {
561        let adapter = GeminiAdapter;
562        // Gemini CLI provides model in llm_request.model for BeforeModel/AfterModel events
563        let input = serde_json::json!({
564            "session_id": "gemini-session-abc",
565            "cwd": "/projects/test",
566            "hook_event_name": "BeforeModel",
567            "llm_request": {
568                "model": "gemini-2.5-flash",
569                "config": {
570                    "temperature": 1
571                },
572                "messages": []
573            }
574        });
575
576        let parsed = adapter.parse_hook_input("BeforeModel", &input);
577
578        assert_eq!(parsed.session_id, Some("gemini-session-abc".to_string()));
579        assert_eq!(parsed.model, Some("gemini-2.5-flash".to_string()));
580        assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
581    }
582
583    #[test]
584    fn test_parse_hook_input_without_model() {
585        let adapter = GeminiAdapter;
586        // Events without llm_request should still work (model will be None)
587        let input = serde_json::json!({
588            "session_id": "gemini-session-xyz",
589            "cwd": "/projects/test"
590        });
591
592        let parsed = adapter.parse_hook_input("SessionStart", &input);
593
594        assert_eq!(parsed.session_id, Some("gemini-session-xyz".to_string()));
595        assert_eq!(parsed.model, None);
596    }
597}