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 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 match m {
550 Matcher::All => "*".to_string(),
551 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 agent.install(&scope, &local_spec("alpha")).unwrap();
706 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 assert!(v["hooks"]["BeforeTool"].is_array());
715 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}