Skip to main content

agent_config/agents/
gemini.rs

1//! Gemini CLI integration.
2//!
3//! Hook surface: `<scope>/.gemini/settings.json` using event name
4//! `BeforeTool` (Gemini's spelling — Claude/Codex use `PreToolUse`).
5//!
6//! ```json
7//! {
8//!   "hooks": {
9//!     "BeforeTool": [
10//!       {
11//!         "matcher": "write_file|replace",
12//!         "hooks": [
13//!           { "type": "command", "command": "...", "timeout": 5000 }
14//!         ],
15//!         "_agent_config_tag": "myapp"
16//!       }
17//!     ]
18//!   }
19//! }
20//! ```
21//!
22//! Optional prompt surface: `<scope>/GEMINI.md` (or `~/.gemini/GEMINI.md`)
23//! with a tagged HTML-comment fence.
24
25use std::path::PathBuf;
26
27use serde_json::json;
28
29use crate::agents::planning as agent_planning;
30use crate::error::AgentConfigError;
31use crate::integration::{
32    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
33};
34use crate::paths;
35use crate::plan::{has_refusal, InstallPlan, PlanTarget, UninstallPlan};
36use crate::scope::{Scope, ScopeKind};
37use crate::spec::{Event, HookSpec, InstructionSpec, Matcher, McpSpec, SkillSpec};
38use crate::status::StatusReport;
39use crate::util::{
40    file_lock, fs_atomic, instructions_dir, json_patch, mcp_json_object, md_block, ownership,
41    planning, safe_fs, skills_dir,
42};
43
44/// Gemini CLI (Google's official Gemini code agent).
45#[derive(Debug, Clone, Copy, Default)]
46pub struct GeminiAgent {
47    _private: (),
48}
49
50impl GeminiAgent {
51    /// Construct an instance. Stateless.
52    pub const fn new() -> Self {
53        Self { _private: () }
54    }
55
56    fn settings_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57        Ok(match scope {
58            Scope::Global => paths::gemini_home()?.join("settings.json"),
59            Scope::Local(p) => p.join(".gemini").join("settings.json"),
60        })
61    }
62
63    fn memory_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
64        Ok(match scope {
65            Scope::Global => paths::gemini_home()?.join("GEMINI.md"),
66            Scope::Local(p) => p.join("GEMINI.md"),
67        })
68    }
69
70    /// MCP servers live under the `mcpServers` key inside the same
71    /// `settings.json` that holds hooks.
72    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
73        Self::settings_path(scope)
74    }
75
76    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
77        Ok(match scope {
78            Scope::Global => paths::gemini_home()?.join("skills"),
79            Scope::Local(p) => p.join(".gemini").join("skills"),
80        })
81    }
82
83    /// Directory holding the instruction ownership ledger.
84    /// Global: `~/.gemini/`. Local: `<root>/.gemini/`.
85    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
86        Ok(match scope {
87            Scope::Global => paths::gemini_home()?,
88            Scope::Local(p) => p.join(".gemini"),
89        })
90    }
91}
92
93impl Integration for GeminiAgent {
94    fn id(&self) -> &'static str {
95        "gemini"
96    }
97
98    fn display_name(&self) -> &'static str {
99        "Gemini CLI"
100    }
101
102    fn supported_scopes(&self) -> &'static [ScopeKind] {
103        &[ScopeKind::Global, ScopeKind::Local]
104    }
105
106    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
107        HookSpec::validate_tag(tag)?;
108        let p = Self::settings_path(scope)?;
109        let presence = json_patch::tagged_hook_presence(&p, &["hooks"], tag)?;
110        Ok(StatusReport::for_tagged_hook(tag, p, presence))
111    }
112
113    fn plan_install(
114        &self,
115        scope: &Scope,
116        spec: &HookSpec,
117    ) -> Result<InstallPlan, AgentConfigError> {
118        HookSpec::validate_tag(&spec.tag)?;
119        let target = PlanTarget::Hook {
120            integration_id: Integration::id(self),
121            scope: scope.clone(),
122            tag: spec.tag.clone(),
123        };
124        let p = Self::settings_path(scope)?;
125        if !matches!(spec.event, Event::PreToolUse | Event::PostToolUse) {
126            return Ok(InstallPlan::refused(
127                target,
128                None,
129                crate::plan::RefusalReason::UnsupportedSpecField,
130            ));
131        }
132        let mut changes = Vec::new();
133
134        let event_key = event_to_string(&spec.event);
135        let matcher_str = matcher_to_gemini(&spec.matcher);
136        let entry = json!({
137            "matcher": matcher_str,
138            "hooks": [
139                { "type": "command", "command": spec.command.render_shell() }
140            ],
141        });
142        planning::plan_tagged_json_upsert(
143            &mut changes,
144            &p,
145            &["hooks", event_key.as_str()],
146            &spec.tag,
147            entry,
148            |_| {},
149        )?;
150        if has_refusal(&changes) {
151            return Ok(InstallPlan::from_changes(target, changes));
152        }
153
154        if let Some(rules) = &spec.rules {
155            let memory = Self::memory_path(scope)?;
156            planning::plan_markdown_upsert(&mut changes, &memory, &spec.tag, &rules.content)?;
157        }
158
159        Ok(InstallPlan::from_changes(target, changes))
160    }
161
162    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
163        HookSpec::validate_tag(tag)?;
164        let target = PlanTarget::Hook {
165            integration_id: Integration::id(self),
166            scope: scope.clone(),
167            tag: tag.to_string(),
168        };
169        let mut changes = Vec::new();
170        let p = Self::settings_path(scope)?;
171        planning::plan_tagged_json_remove_under(
172            &mut changes,
173            &p,
174            &["hooks"],
175            tag,
176            planning::json_object_empty,
177            true,
178        )?;
179        if has_refusal(&changes) {
180            return Ok(UninstallPlan::from_changes(target, changes));
181        }
182
183        let memory = Self::memory_path(scope)?;
184        planning::plan_markdown_remove(&mut changes, &memory, tag)?;
185
186        Ok(UninstallPlan::from_changes(target, changes))
187    }
188
189    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
190        HookSpec::validate_tag(&spec.tag)?;
191        let mut report = InstallReport::default();
192
193        let p = Self::settings_path(scope)?;
194        scope.ensure_contained(&p)?;
195        if !matches!(spec.event, Event::PreToolUse | Event::PostToolUse) {
196            return Err(AgentConfigError::UnsupportedSpecField {
197                id: "gemini",
198                field: "event",
199                value: format!("{:?}", spec.event),
200            });
201        }
202        file_lock::with_lock(&p, || {
203            let mut root = json_patch::read_or_empty(&p)?;
204
205            let event_key = event_to_string(&spec.event);
206            let matcher_str = matcher_to_gemini(&spec.matcher);
207
208            let entry = json!({
209                "matcher": matcher_str,
210                "hooks": [
211                    { "type": "command", "command": spec.command.render_shell() }
212                ],
213            });
214
215            let changed = json_patch::upsert_tagged_array_entry(
216                &mut root,
217                &["hooks", &event_key],
218                &spec.tag,
219                entry,
220            )?;
221
222            if changed {
223                let bytes = json_patch::to_pretty(&root);
224                let outcome = safe_fs::write(scope, &p, &bytes, true)?;
225                if outcome.existed {
226                    report.patched.push(outcome.path.clone());
227                } else {
228                    report.created.push(outcome.path.clone());
229                }
230                if let Some(b) = outcome.backup {
231                    report.backed_up.push(b);
232                }
233            } else {
234                report.already_installed = true;
235            }
236            Ok::<(), AgentConfigError>(())
237        })?;
238
239        if let Some(rules) = &spec.rules {
240            let memory = Self::memory_path(scope)?;
241            scope.ensure_contained(&memory)?;
242            file_lock::with_lock(&memory, || {
243                let host = fs_atomic::read_to_string_or_empty(&memory)?;
244                let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
245                let outcome = safe_fs::write(scope, &memory, new_host.as_bytes(), true)?;
246                if outcome.existed && !outcome.no_change {
247                    report.patched.push(outcome.path.clone());
248                    report.already_installed = false;
249                } else if !outcome.existed {
250                    report.created.push(outcome.path.clone());
251                    report.already_installed = false;
252                }
253                if let Some(b) = outcome.backup {
254                    report.backed_up.push(b);
255                }
256                Ok::<(), AgentConfigError>(())
257            })?;
258        }
259
260        Ok(report)
261    }
262
263    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
264        HookSpec::validate_tag(tag)?;
265        let mut report = UninstallReport::default();
266
267        let p = Self::settings_path(scope)?;
268        scope.ensure_contained(&p)?;
269        if p.exists() {
270            file_lock::with_lock(&p, || {
271                let mut root = json_patch::read_or_empty(&p)?;
272                let changed =
273                    json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
274                if changed {
275                    let empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
276                    let bytes = json_patch::to_pretty(&root);
277                    if empty && safe_fs::restore_backup_if_matches(scope, &p, &bytes)? {
278                        report.restored.push(p.clone());
279                    } else if empty {
280                        safe_fs::remove_file(scope, &p)?;
281                        report.removed.push(p.clone());
282                    } else {
283                        safe_fs::write(scope, &p, &bytes, false)?;
284                        report.patched.push(p.clone());
285                    }
286                }
287                Ok::<(), AgentConfigError>(())
288            })?;
289        }
290
291        let memory = Self::memory_path(scope)?;
292        scope.ensure_contained(&memory)?;
293        file_lock::with_lock(&memory, || {
294            let host = fs_atomic::read_to_string_or_empty(&memory)?;
295            let (stripped, removed) = md_block::remove(&host, tag);
296            if removed {
297                if stripped.trim().is_empty() {
298                    if safe_fs::restore_backup_if_matches(scope, &memory, stripped.as_bytes())? {
299                        report.restored.push(memory.clone());
300                    } else {
301                        safe_fs::remove_file(scope, &memory)?;
302                        report.removed.push(memory.clone());
303                    }
304                } else {
305                    safe_fs::write(scope, &memory, stripped.as_bytes(), false)?;
306                    report.patched.push(memory.clone());
307                }
308            }
309            Ok::<(), AgentConfigError>(())
310        })?;
311
312        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
313            report.not_installed = true;
314        }
315        Ok(report)
316    }
317}
318
319impl McpSurface for GeminiAgent {
320    fn id(&self) -> &'static str {
321        "gemini"
322    }
323
324    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
325        &[ScopeKind::Global, ScopeKind::Local]
326    }
327
328    fn mcp_status(
329        &self,
330        scope: &Scope,
331        name: &str,
332        expected_owner: &str,
333    ) -> Result<StatusReport, AgentConfigError> {
334        McpSpec::validate_name(name)?;
335        let cfg = Self::mcp_path(scope)?;
336        let ledger = ownership::mcp_ledger_for(&cfg);
337        let presence = mcp_json_object::config_presence(&cfg, name)?;
338        let recorded = ownership::owner_of(&ledger, name)?;
339        Ok(StatusReport::for_mcp(
340            name,
341            cfg,
342            ledger,
343            presence,
344            expected_owner,
345            recorded,
346        ))
347    }
348
349    fn plan_install_mcp(
350        &self,
351        scope: &Scope,
352        spec: &McpSpec,
353    ) -> Result<InstallPlan, AgentConfigError> {
354        agent_planning::mcp_json_object_install(
355            McpSurface::id(self),
356            scope,
357            spec,
358            Self::mcp_path(scope),
359        )
360    }
361
362    fn plan_uninstall_mcp(
363        &self,
364        scope: &Scope,
365        name: &str,
366        owner_tag: &str,
367    ) -> Result<UninstallPlan, AgentConfigError> {
368        agent_planning::mcp_json_object_uninstall(
369            McpSurface::id(self),
370            scope,
371            name,
372            owner_tag,
373            Self::mcp_path(scope),
374        )
375    }
376
377    fn install_mcp(
378        &self,
379        scope: &Scope,
380        spec: &McpSpec,
381    ) -> Result<InstallReport, AgentConfigError> {
382        spec.validate()?;
383        let cfg = Self::mcp_path(scope)?;
384        spec.validate_local_secret_policy(scope)?;
385        scope.ensure_contained(&cfg)?;
386        let ledger = ownership::mcp_ledger_for(&cfg);
387        mcp_json_object::install(&cfg, &ledger, spec)
388    }
389
390    fn uninstall_mcp(
391        &self,
392        scope: &Scope,
393        name: &str,
394        owner_tag: &str,
395    ) -> Result<UninstallReport, AgentConfigError> {
396        McpSpec::validate_name(name)?;
397        HookSpec::validate_tag(owner_tag)?;
398        let cfg = Self::mcp_path(scope)?;
399        scope.ensure_contained(&cfg)?;
400        let ledger = ownership::mcp_ledger_for(&cfg);
401        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
402    }
403}
404
405impl SkillSurface for GeminiAgent {
406    fn id(&self) -> &'static str {
407        "gemini"
408    }
409
410    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
411        &[ScopeKind::Global, ScopeKind::Local]
412    }
413
414    fn skill_status(
415        &self,
416        scope: &Scope,
417        name: &str,
418        expected_owner: &str,
419    ) -> Result<StatusReport, AgentConfigError> {
420        SkillSpec::validate_name(name)?;
421        let root = Self::skills_root(scope)?;
422        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
423        let recorded = ownership::owner_of(&ledger, name)?;
424        Ok(StatusReport::for_skill(
425            name,
426            dir,
427            manifest,
428            ledger,
429            expected_owner,
430            recorded,
431        ))
432    }
433
434    fn plan_install_skill(
435        &self,
436        scope: &Scope,
437        spec: &SkillSpec,
438    ) -> Result<InstallPlan, AgentConfigError> {
439        agent_planning::skill_install(
440            SkillSurface::id(self),
441            scope,
442            spec,
443            Self::skills_root(scope),
444        )
445    }
446
447    fn plan_uninstall_skill(
448        &self,
449        scope: &Scope,
450        name: &str,
451        owner_tag: &str,
452    ) -> Result<UninstallPlan, AgentConfigError> {
453        agent_planning::skill_uninstall(
454            SkillSurface::id(self),
455            scope,
456            name,
457            owner_tag,
458            Self::skills_root(scope),
459        )
460    }
461
462    fn install_skill(
463        &self,
464        scope: &Scope,
465        spec: &SkillSpec,
466    ) -> Result<InstallReport, AgentConfigError> {
467        let root = Self::skills_root(scope)?;
468        scope.ensure_contained(&root)?;
469        skills_dir::install(&root, spec)
470    }
471
472    fn uninstall_skill(
473        &self,
474        scope: &Scope,
475        name: &str,
476        owner_tag: &str,
477    ) -> Result<UninstallReport, AgentConfigError> {
478        let root = Self::skills_root(scope)?;
479        scope.ensure_contained(&root)?;
480        skills_dir::uninstall(&root, name, owner_tag)
481    }
482}
483
484impl GeminiAgent {
485    fn inline_layout(
486        &self,
487        scope: &Scope,
488    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
489        Ok(instructions_dir::InlineLayout {
490            config_dir: Self::instruction_config_dir(scope)?,
491            host_file: Self::memory_path(scope)?,
492        })
493    }
494}
495
496impl InstructionSurface for GeminiAgent {
497    fn id(&self) -> &'static str {
498        "gemini"
499    }
500
501    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
502        &[ScopeKind::Global, ScopeKind::Local]
503    }
504
505    fn instruction_status(
506        &self,
507        scope: &Scope,
508        name: &str,
509        expected_owner: &str,
510    ) -> Result<StatusReport, AgentConfigError> {
511        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
512    }
513
514    fn plan_install_instruction(
515        &self,
516        scope: &Scope,
517        spec: &InstructionSpec,
518    ) -> Result<InstallPlan, AgentConfigError> {
519        instructions_dir::inline_plan_install(
520            InstructionSurface::id(self),
521            scope,
522            self.inline_layout(scope),
523            spec,
524        )
525    }
526
527    fn plan_uninstall_instruction(
528        &self,
529        scope: &Scope,
530        name: &str,
531        owner_tag: &str,
532    ) -> Result<UninstallPlan, AgentConfigError> {
533        instructions_dir::inline_plan_uninstall(
534            InstructionSurface::id(self),
535            scope,
536            self.inline_layout(scope),
537            name,
538            owner_tag,
539        )
540    }
541
542    fn install_instruction(
543        &self,
544        scope: &Scope,
545        spec: &InstructionSpec,
546    ) -> Result<InstallReport, AgentConfigError> {
547        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
548    }
549
550    fn uninstall_instruction(
551        &self,
552        scope: &Scope,
553        name: &str,
554        owner_tag: &str,
555    ) -> Result<UninstallReport, AgentConfigError> {
556        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
557    }
558}
559
560fn matcher_to_gemini(m: &Matcher) -> String {
561    // Gemini matchers are regex for tool events. Tool names use snake_case
562    // (e.g., `run_shell_command`, `write_file`, `replace`).
563    match m {
564        Matcher::All => "*".to_string(),
565        // Conventional shell-tool name in Gemini's tool registry.
566        Matcher::Bash => "run_shell_command".to_string(),
567        Matcher::Exact(s) => s.clone(),
568        Matcher::AnyOf(names) => names.join("|"),
569        Matcher::Regex(s) => s.clone(),
570    }
571}
572
573fn event_to_string(e: &Event) -> String {
574    match e {
575        Event::PreToolUse => "BeforeTool".into(),
576        Event::PostToolUse => "AfterTool".into(),
577        Event::Custom(s) => s.clone(),
578        other => other.as_str().into(),
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use serde_json::{json, Value};
586    use tempfile::tempdir;
587
588    fn local_spec(tag: &str) -> HookSpec {
589        HookSpec::builder(tag)
590            .command_program("myapp", ["hook"])
591            .matcher(Matcher::Bash)
592            .event(Event::PreToolUse)
593            .build()
594    }
595
596    fn read_json(p: &std::path::Path) -> Value {
597        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
598    }
599
600    #[test]
601    fn writes_before_tool_event_with_run_shell_command_matcher() {
602        let dir = tempdir().unwrap();
603        let agent = GeminiAgent::new();
604        let scope = Scope::Local(dir.path().to_path_buf());
605        agent.install(&scope, &local_spec("alpha")).unwrap();
606
607        let v = read_json(&dir.path().join(".gemini/settings.json"));
608        assert_eq!(
609            v["hooks"]["BeforeTool"][0]["matcher"],
610            json!("run_shell_command")
611        );
612        assert_eq!(
613            v["hooks"]["BeforeTool"][0]["hooks"][0]["command"],
614            json!("myapp hook")
615        );
616        assert_eq!(
617            v["hooks"]["BeforeTool"][0]["_agent_config_tag"],
618            json!("alpha")
619        );
620    }
621
622    #[test]
623    fn install_uninstall_round_trip() {
624        let dir = tempdir().unwrap();
625        let agent = GeminiAgent::new();
626        let scope = Scope::Local(dir.path().to_path_buf());
627        agent.install(&scope, &local_spec("alpha")).unwrap();
628        agent.uninstall(&scope, "alpha").unwrap();
629        assert!(!dir.path().join(".gemini/settings.json").exists());
630    }
631
632    #[test]
633    fn rules_block_upserts_into_gemini_md() {
634        let dir = tempdir().unwrap();
635        let agent = GeminiAgent::new();
636        let scope = Scope::Local(dir.path().to_path_buf());
637        let spec = HookSpec::builder("alpha")
638            .command_program("noop", [] as [&str; 0])
639            .matcher(Matcher::Bash)
640            .rules("Always prefix shell calls.")
641            .build();
642        agent.install(&scope, &spec).unwrap();
643        let md = std::fs::read_to_string(dir.path().join("GEMINI.md")).unwrap();
644        assert!(md.contains("Always prefix shell calls."));
645        assert!(md.contains("AGENT-CONFIG:alpha"));
646    }
647
648    #[test]
649    fn matcher_mapping() {
650        assert_eq!(matcher_to_gemini(&Matcher::All), "*");
651        assert_eq!(matcher_to_gemini(&Matcher::Bash), "run_shell_command");
652        assert_eq!(
653            matcher_to_gemini(&Matcher::Exact("write_file".into())),
654            "write_file"
655        );
656        assert_eq!(
657            matcher_to_gemini(&Matcher::AnyOf(vec![
658                "read_file".into(),
659                "write_file".into()
660            ])),
661            "read_file|write_file"
662        );
663    }
664
665    #[test]
666    fn post_tool_use_maps_to_after_tool() {
667        let dir = tempdir().unwrap();
668        let agent = GeminiAgent::new();
669        let scope = Scope::Local(dir.path().to_path_buf());
670        let spec = HookSpec::builder("alpha")
671            .command_program("noop", [] as [&str; 0])
672            .event(Event::PostToolUse)
673            .build();
674        agent.install(&scope, &spec).unwrap();
675        let v = read_json(&dir.path().join(".gemini/settings.json"));
676        assert!(v["hooks"]["AfterTool"].is_array());
677    }
678
679    #[test]
680    fn install_preserves_existing_settings() {
681        let dir = tempdir().unwrap();
682        let gemini_dir = dir.path().join(".gemini");
683        std::fs::create_dir_all(&gemini_dir).unwrap();
684        let settings = gemini_dir.join("settings.json");
685        let existing = json!({
686            "hooks": {
687                "BeforeTool": [{
688                    "matcher": "write_file",
689                    "hooks": [{ "type": "command", "command": "user-cmd" }],
690                    "_agent_config_tag": "user-tool"
691                }]
692            }
693        });
694        std::fs::write(&settings, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
695
696        let agent = GeminiAgent::new();
697        let scope = Scope::Local(dir.path().to_path_buf());
698        agent.install(&scope, &local_spec("alpha")).unwrap();
699
700        let v = read_json(&settings);
701        let entries = v["hooks"]["BeforeTool"].as_array().unwrap();
702        assert_eq!(entries.len(), 2);
703        assert_eq!(entries[0]["_agent_config_tag"], json!("user-tool"));
704        assert_eq!(entries[1]["_agent_config_tag"], json!("alpha"));
705    }
706
707    fn local_mcp_spec(name: &str, owner: &str) -> McpSpec {
708        McpSpec::builder(name)
709            .owner(owner)
710            .stdio("npx", ["-y", "@example/server"])
711            .build()
712    }
713
714    #[test]
715    fn install_mcp_writes_into_settings_json_alongside_hooks() {
716        let dir = tempdir().unwrap();
717        let agent = GeminiAgent::new();
718        let scope = Scope::Local(dir.path().to_path_buf());
719        // Install a hook first.
720        agent.install(&scope, &local_spec("alpha")).unwrap();
721        // Then install MCP — must coexist in same file.
722        agent
723            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
724            .unwrap();
725
726        let settings = dir.path().join(".gemini/settings.json");
727        let v = read_json(&settings);
728        // Hook is preserved.
729        assert!(v["hooks"]["BeforeTool"].is_array());
730        // MCP server is added under top-level mcpServers.
731        assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
732    }
733
734    #[test]
735    fn uninstall_mcp_keeps_hooks_in_settings_json() {
736        let dir = tempdir().unwrap();
737        let agent = GeminiAgent::new();
738        let scope = Scope::Local(dir.path().to_path_buf());
739        agent.install(&scope, &local_spec("alpha")).unwrap();
740        agent
741            .install_mcp(&scope, &local_mcp_spec("github", "myapp"))
742            .unwrap();
743        agent.uninstall_mcp(&scope, "github", "myapp").unwrap();
744
745        let settings = dir.path().join(".gemini/settings.json");
746        let v = read_json(&settings);
747        assert!(
748            v["hooks"]["BeforeTool"].is_array(),
749            "hooks must survive MCP uninstall"
750        );
751        assert!(v.get("mcpServers").is_none(), "mcpServers should be pruned");
752    }
753
754    #[test]
755    fn install_mcp_idempotent() {
756        let dir = tempdir().unwrap();
757        let agent = GeminiAgent::new();
758        let scope = Scope::Local(dir.path().to_path_buf());
759        let s = local_mcp_spec("github", "myapp");
760        agent.install_mcp(&scope, &s).unwrap();
761        let r = agent.install_mcp(&scope, &s).unwrap();
762        assert!(r.already_installed);
763    }
764
765    #[test]
766    fn uninstall_mcp_other_owner_refused() {
767        let dir = tempdir().unwrap();
768        let agent = GeminiAgent::new();
769        let scope = Scope::Local(dir.path().to_path_buf());
770        agent
771            .install_mcp(&scope, &local_mcp_spec("github", "appA"))
772            .unwrap();
773        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
774        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
775    }
776
777    #[test]
778    fn event_validation_rejects_unsupported() {
779        let dir = tempdir().unwrap();
780        let agent = GeminiAgent::new();
781        let scope = Scope::Local(dir.path().to_path_buf());
782        let spec = HookSpec::builder("alpha")
783            .command_program("noop", [] as [&str; 0])
784            .event(Event::Stop)
785            .build();
786
787        // 1. plan_install refuses it
788        let plan = agent.plan_install(&scope, &spec).unwrap();
789        assert!(matches!(plan.status, crate::plan::PlanStatus::Refused));
790        assert!(plan.changes.iter().any(|c| matches!(
791            c,
792            crate::plan::PlannedChange::Refuse {
793                reason: crate::plan::RefusalReason::UnsupportedSpecField,
794                ..
795            }
796        )));
797
798        // 2. install returns error
799        let err = agent.install(&scope, &spec).unwrap_err();
800        assert!(matches!(err, AgentConfigError::UnsupportedSpecField { .. }));
801    }
802}