1use 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#[derive(Debug, Clone, Copy, Default)]
46pub struct GeminiAgent {
47 _private: (),
48}
49
50impl GeminiAgent {
51 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 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 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 match m {
564 Matcher::All => "*".to_string(),
565 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 agent.install(&scope, &local_spec("alpha")).unwrap();
721 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 assert!(v["hooks"]["BeforeTool"].is_array());
730 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 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 let err = agent.install(&scope, &spec).unwrap_err();
800 assert!(matches!(err, AgentConfigError::UnsupportedSpecField { .. }));
801 }
802}