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