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