Skip to main content

agent_config/agents/
antigravity.rs

1//! Google Antigravity integration.
2//!
3//! Four surfaces:
4//!
5//! 1. **Rules** — project-local markdown files at `.agents/rules/<tag>.md`.
6//!    Legacy `.agent/rules/<tag>.md` installs are still detected and removed.
7//!
8//! 2. **Skills** — directory-scoped skills at `.agents/skills/<name>/` (Local)
9//!    or `~/.gemini/antigravity/skills/<name>/` (Global). Each skill is a
10//!    folder with `SKILL.md` plus optional `scripts/`/`references/`/`assets/`.
11//!    Legacy `.agent/skills/<name>/` installs are still detected and removed.
12//!
13//! 3. **MCP servers** — JSON config at `.agents/mcp_config.json` (Local) or
14//!    `~/.gemini/config/mcp_config.json` (Global), keyed by server name
15//!    under `mcpServers`.
16//!
17//! 4. **Hooks** — event hooks inside `hooks.json` at `.agents/hooks.json` (Local)
18//!    or `~/.gemini/config/hooks.json` (Global).
19
20use std::path::PathBuf;
21
22use crate::agents::planning as agent_planning;
23use crate::error::AgentConfigError;
24use crate::integration::{
25    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
26};
27use crate::paths;
28use crate::plan::{InstallPlan, PlanTarget, UninstallPlan};
29use crate::scope::{Scope, ScopeKind};
30use crate::spec::{HookSpec, InstructionSpec, Matcher, McpSpec, SkillSpec};
31use crate::status::StatusReport;
32use crate::util::{
33    hooks_json, instructions_dir, mcp_json_object, ownership, rules_dir, skills_dir,
34};
35
36const RULES_DIR: &str = ".agents/rules";
37const LEGACY_RULES_DIR: &str = ".agent/rules";
38
39/// Google Antigravity integration.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct AntigravityAgent {
42    _private: (),
43}
44
45impl AntigravityAgent {
46    /// Construct an instance. Stateless.
47    pub const fn new() -> Self {
48        Self { _private: () }
49    }
50
51    fn project_root<'a>(&self, scope: &'a Scope) -> Result<&'a std::path::Path, AgentConfigError> {
52        match scope {
53            Scope::Local(p) => Ok(p),
54            Scope::Global => Err(AgentConfigError::UnsupportedScope {
55                id: "antigravity",
56                scope: ScopeKind::Global,
57            }),
58        }
59    }
60
61    /// Skills root: `<root>/.agents/skills/` (Local) or
62    /// `~/.gemini/antigravity/skills/` (Global). Both scopes are supported
63    /// for skills.
64    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
65        Ok(match scope {
66            Scope::Global => paths::gemini_home()?.join("antigravity").join("skills"),
67            Scope::Local(p) => p.join(".agents").join("skills"),
68        })
69    }
70
71    fn legacy_skills_root(scope: &Scope) -> Option<PathBuf> {
72        match scope {
73            Scope::Global => None,
74            Scope::Local(p) => Some(p.join(".agent").join("skills")),
75        }
76    }
77
78    fn existing_skills_root(scope: &Scope, name: &str) -> Result<PathBuf, AgentConfigError> {
79        SkillSpec::validate_name(name)?;
80        let root = Self::skills_root(scope)?;
81        let (dir, _, ledger) = skills_dir::paths_for_status(&root, name);
82        if dir.exists() || ownership::owner_of(&ledger, name)?.is_some() {
83            return Ok(root);
84        }
85
86        if let Some(legacy) = Self::legacy_skills_root(scope) {
87            let (dir, _, ledger) = skills_dir::paths_for_status(&legacy, name);
88            if dir.exists() || ownership::owner_of(&ledger, name)?.is_some() {
89                return Ok(legacy);
90            }
91        }
92
93        Ok(root)
94    }
95
96    fn hooks_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
97        Ok(match scope {
98            Scope::Global => paths::gemini_home()?.join("config").join("hooks.json"),
99            Scope::Local(p) => p.join(".agents").join("hooks.json"),
100        })
101    }
102
103    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
104        Ok(match scope {
105            Scope::Global => paths::antigravity_mcp_global_file()?,
106            Scope::Local(p) => p.join(".agents").join("mcp_config.json"),
107        })
108    }
109
110    fn existing_mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
111        let primary = Self::mcp_path(scope)?;
112        if primary.exists() {
113            return Ok(primary);
114        }
115        if let Scope::Local(p) = scope {
116            let legacy = p.join(".agent").join("mcp_config.json");
117            if legacy.exists() {
118                return Ok(legacy);
119            }
120        }
121        Ok(primary)
122    }
123}
124
125impl Integration for AntigravityAgent {
126    fn id(&self) -> &'static str {
127        "antigravity"
128    }
129
130    fn display_name(&self) -> &'static str {
131        "Google Antigravity"
132    }
133
134    fn supported_scopes(&self) -> &'static [ScopeKind] {
135        &[ScopeKind::Global, ScopeKind::Local]
136    }
137
138    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
139        HookSpec::validate_tag(tag)?;
140
141        // 1. Check rules files (Local scope only)
142        if let Scope::Local(p) = scope {
143            let path = rules_dir::target_path(p, RULES_DIR, tag);
144            if path.exists() {
145                return Ok(StatusReport::for_file_hook(tag, path));
146            }
147            let legacy = rules_dir::target_path(p, LEGACY_RULES_DIR, tag);
148            if legacy.exists() {
149                return Ok(StatusReport::for_file_hook(tag, legacy));
150            }
151        }
152
153        // 2. Check hooks.json
154        let hooks_path = Self::hooks_path(scope)?;
155        let presence = hooks_json::config_presence(&hooks_path, tag)?;
156        if let crate::status::ConfigPresence::Absent = presence {
157            if let Scope::Local(p) = scope {
158                let path = rules_dir::target_path(p, RULES_DIR, tag);
159                Ok(StatusReport::for_file_hook(tag, path))
160            } else {
161                Ok(StatusReport::for_tagged_hook(tag, hooks_path, presence))
162            }
163        } else {
164            Ok(StatusReport::for_tagged_hook(tag, hooks_path, presence))
165        }
166    }
167
168    fn plan_install(
169        &self,
170        scope: &Scope,
171        spec: &HookSpec,
172    ) -> Result<InstallPlan, AgentConfigError> {
173        HookSpec::validate_tag(&spec.tag)?;
174        let target = PlanTarget::Hook {
175            integration_id: Integration::id(self),
176            scope: scope.clone(),
177            tag: spec.tag.clone(),
178        };
179        let mut changes = Vec::new();
180
181        // 1. Plan hook command installation in hooks.json
182        let hooks_path = Self::hooks_path(scope)?;
183        hooks_json::plan_install(&mut changes, &hooks_path, spec, build_hook_value)?;
184
185        // 2. Plan rules installation if spec.rules is Some
186        if let Some(rules) = &spec.rules {
187            let root = self.project_root(scope);
188            let root = match root {
189                Ok(root) => root,
190                Err(AgentConfigError::UnsupportedScope { .. }) => {
191                    return Ok(InstallPlan::refused(
192                        target,
193                        None,
194                        crate::plan::RefusalReason::UnsupportedScope,
195                    ));
196                }
197                Err(e) => return Err(e),
198            };
199            let rule_changes = rules_dir::plan_install(root, RULES_DIR, &spec.tag, &rules.content)?;
200            changes.extend(rule_changes);
201        }
202
203        Ok(InstallPlan::from_changes(target, changes))
204    }
205
206    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
207        HookSpec::validate_tag(tag)?;
208        let target = PlanTarget::Hook {
209            integration_id: Integration::id(self),
210            scope: scope.clone(),
211            tag: tag.to_string(),
212        };
213        let mut changes = Vec::new();
214
215        // 1. Plan hook command removal
216        let hooks_path = Self::hooks_path(scope)?;
217        hooks_json::plan_uninstall(&mut changes, &hooks_path, tag)?;
218
219        // 2. Plan rules removal if in Local scope
220        if let Scope::Local(p) = scope {
221            let current = rules_dir::target_path(p, RULES_DIR, tag);
222            let legacy = rules_dir::target_path(p, LEGACY_RULES_DIR, tag);
223            let rules_dir_name = if !current.exists() && legacy.exists() {
224                LEGACY_RULES_DIR
225            } else {
226                RULES_DIR
227            };
228            let rule_changes = rules_dir::plan_uninstall(p, rules_dir_name, tag)?;
229            changes.extend(rule_changes);
230        }
231
232        Ok(UninstallPlan::from_changes(target, changes))
233    }
234
235    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
236        HookSpec::validate_tag(&spec.tag)?;
237        agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
238        let mut report = InstallReport::default();
239
240        // 1. Install hook command
241        let hooks_path = Self::hooks_path(scope)?;
242        let hook_report = hooks_json::install(scope, &hooks_path, spec, build_hook_value)?;
243        report.created.extend(hook_report.created);
244        report.patched.extend(hook_report.patched);
245        report.backed_up.extend(hook_report.backed_up);
246        report.already_installed = hook_report.already_installed;
247
248        // 2. Install rules if present
249        if let Some(rules) = &spec.rules {
250            let _ = self.project_root(scope)?;
251            let rules_report = rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)?;
252            report.created.extend(rules_report.created);
253            report.patched.extend(rules_report.patched);
254            report.backed_up.extend(rules_report.backed_up);
255            if !rules_report.already_installed {
256                report.already_installed = false;
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        // 1. Uninstall hook command
268        let hooks_path = Self::hooks_path(scope)?;
269        let hook_report = hooks_json::uninstall(scope, &hooks_path, tag)?;
270        report.removed.extend(hook_report.removed);
271        report.patched.extend(hook_report.patched);
272        report.restored.extend(hook_report.restored);
273        report.not_installed = hook_report.not_installed;
274
275        // 2. Uninstall rules if in Local scope
276        if let Scope::Local(p) = scope {
277            let current = rules_dir::target_path(p, RULES_DIR, tag);
278            let legacy = rules_dir::target_path(p, LEGACY_RULES_DIR, tag);
279            let rules_dir_name = if !current.exists() && legacy.exists() {
280                LEGACY_RULES_DIR
281            } else {
282                RULES_DIR
283            };
284            let rules_report = rules_dir::uninstall(scope, rules_dir_name, tag)?;
285            report.removed.extend(rules_report.removed);
286            report.patched.extend(rules_report.patched);
287            report.restored.extend(rules_report.restored);
288            if !rules_report.not_installed {
289                report.not_installed = false;
290            }
291        }
292
293        Ok(report)
294    }
295}
296
297impl McpSurface for AntigravityAgent {
298    fn id(&self) -> &'static str {
299        "antigravity"
300    }
301
302    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
303        &[ScopeKind::Global, ScopeKind::Local]
304    }
305
306    fn mcp_status(
307        &self,
308        scope: &Scope,
309        name: &str,
310        expected_owner: &str,
311    ) -> Result<StatusReport, AgentConfigError> {
312        McpSpec::validate_name(name)?;
313        let cfg = Self::existing_mcp_path(scope)?;
314        let ledger = ownership::mcp_ledger_for(&cfg);
315        let presence = mcp_json_object::config_presence(&cfg, name)?;
316        let recorded = ownership::owner_of(&ledger, name)?;
317        Ok(StatusReport::for_mcp(
318            name,
319            cfg,
320            ledger,
321            presence,
322            expected_owner,
323            recorded,
324        ))
325    }
326
327    fn plan_install_mcp(
328        &self,
329        scope: &Scope,
330        spec: &McpSpec,
331    ) -> Result<InstallPlan, AgentConfigError> {
332        agent_planning::mcp_json_object_install(
333            McpSurface::id(self),
334            scope,
335            spec,
336            Self::existing_mcp_path(scope),
337        )
338    }
339
340    fn plan_uninstall_mcp(
341        &self,
342        scope: &Scope,
343        name: &str,
344        owner_tag: &str,
345    ) -> Result<UninstallPlan, AgentConfigError> {
346        agent_planning::mcp_json_object_uninstall(
347            McpSurface::id(self),
348            scope,
349            name,
350            owner_tag,
351            Self::existing_mcp_path(scope),
352        )
353    }
354
355    fn install_mcp(
356        &self,
357        scope: &Scope,
358        spec: &McpSpec,
359    ) -> Result<InstallReport, AgentConfigError> {
360        spec.validate()?;
361        let cfg = Self::existing_mcp_path(scope)?;
362        spec.validate_local_secret_policy(scope)?;
363        scope.ensure_contained(&cfg)?;
364        let ledger = ownership::mcp_ledger_for(&cfg);
365        mcp_json_object::install(&cfg, &ledger, spec)
366    }
367
368    fn uninstall_mcp(
369        &self,
370        scope: &Scope,
371        name: &str,
372        owner_tag: &str,
373    ) -> Result<UninstallReport, AgentConfigError> {
374        McpSpec::validate_name(name)?;
375        HookSpec::validate_tag(owner_tag)?;
376        let cfg = Self::existing_mcp_path(scope)?;
377        scope.ensure_contained(&cfg)?;
378        let ledger = ownership::mcp_ledger_for(&cfg);
379        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
380    }
381}
382
383impl SkillSurface for AntigravityAgent {
384    fn id(&self) -> &'static str {
385        "antigravity"
386    }
387
388    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
389        &[ScopeKind::Global, ScopeKind::Local]
390    }
391
392    fn skill_status(
393        &self,
394        scope: &Scope,
395        name: &str,
396        expected_owner: &str,
397    ) -> Result<StatusReport, AgentConfigError> {
398        SkillSpec::validate_name(name)?;
399        let root = Self::skills_root(scope)?;
400        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
401        if !dir.exists() && ownership::owner_of(&ledger, name)?.is_none() {
402            if let Some(legacy) = Self::legacy_skills_root(scope) {
403                let (legacy_dir, legacy_manifest, legacy_ledger) =
404                    skills_dir::paths_for_status(&legacy, name);
405                if legacy_dir.exists() || ownership::owner_of(&legacy_ledger, name)?.is_some() {
406                    let recorded = ownership::owner_of(&legacy_ledger, name)?;
407                    return Ok(StatusReport::for_skill(
408                        name,
409                        legacy_dir,
410                        legacy_manifest,
411                        legacy_ledger,
412                        expected_owner,
413                        recorded,
414                    ));
415                }
416            }
417        }
418        let recorded = ownership::owner_of(&ledger, name)?;
419        Ok(StatusReport::for_skill(
420            name,
421            dir,
422            manifest,
423            ledger,
424            expected_owner,
425            recorded,
426        ))
427    }
428
429    fn plan_install_skill(
430        &self,
431        scope: &Scope,
432        spec: &SkillSpec,
433    ) -> Result<InstallPlan, AgentConfigError> {
434        agent_planning::skill_install(
435            SkillSurface::id(self),
436            scope,
437            spec,
438            Self::skills_root(scope),
439        )
440    }
441
442    fn plan_uninstall_skill(
443        &self,
444        scope: &Scope,
445        name: &str,
446        owner_tag: &str,
447    ) -> Result<UninstallPlan, AgentConfigError> {
448        agent_planning::skill_uninstall(
449            SkillSurface::id(self),
450            scope,
451            name,
452            owner_tag,
453            Self::existing_skills_root(scope, name),
454        )
455    }
456
457    fn install_skill(
458        &self,
459        scope: &Scope,
460        spec: &SkillSpec,
461    ) -> Result<InstallReport, AgentConfigError> {
462        spec.validate()?;
463        let root = Self::skills_root(scope)?;
464        scope.ensure_contained(&root)?;
465        skills_dir::install(&root, spec)
466    }
467
468    fn uninstall_skill(
469        &self,
470        scope: &Scope,
471        name: &str,
472        owner_tag: &str,
473    ) -> Result<UninstallReport, AgentConfigError> {
474        SkillSpec::validate_name(name)?;
475        HookSpec::validate_tag(owner_tag)?;
476        let root = Self::existing_skills_root(scope, name)?;
477        scope.ensure_contained(&root)?;
478        skills_dir::uninstall(&root, name, owner_tag)
479    }
480}
481
482impl AntigravityAgent {
483    fn standalone_layout(
484        &self,
485        scope: &Scope,
486    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
487        let root = self.project_root(scope)?;
488        Ok(instructions_dir::StandaloneLayout {
489            config_dir: root.join(".agents"),
490            instruction_dir: root.join(RULES_DIR),
491        })
492    }
493
494    fn legacy_standalone_layout(
495        &self,
496        scope: &Scope,
497    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
498        let root = self.project_root(scope)?;
499        Ok(instructions_dir::StandaloneLayout {
500            config_dir: root.join(".agent"),
501            instruction_dir: root.join(LEGACY_RULES_DIR),
502        })
503    }
504
505    fn existing_standalone_layout(
506        &self,
507        scope: &Scope,
508        name: &str,
509    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
510        InstructionSpec::validate_name(name)?;
511        let primary = self.standalone_layout(scope)?;
512        let primary_file = primary.instruction_dir.join(format!("{name}.md"));
513        let primary_ledger = instructions_dir::ledger_path(&primary.config_dir);
514        if primary_file.exists() || ownership::owner_of(&primary_ledger, name)?.is_some() {
515            return Ok(primary);
516        }
517
518        let legacy = self.legacy_standalone_layout(scope)?;
519        let legacy_file = legacy.instruction_dir.join(format!("{name}.md"));
520        let legacy_ledger = instructions_dir::ledger_path(&legacy.config_dir);
521        if legacy_file.exists() || ownership::owner_of(&legacy_ledger, name)?.is_some() {
522            return Ok(legacy);
523        }
524
525        Ok(primary)
526    }
527}
528
529impl InstructionSurface for AntigravityAgent {
530    fn id(&self) -> &'static str {
531        "antigravity"
532    }
533
534    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
535        &[ScopeKind::Local]
536    }
537
538    fn instruction_status(
539        &self,
540        scope: &Scope,
541        name: &str,
542        expected_owner: &str,
543    ) -> Result<StatusReport, AgentConfigError> {
544        instructions_dir::standalone_status(
545            self.existing_standalone_layout(scope, name)?,
546            name,
547            expected_owner,
548        )
549    }
550
551    fn plan_install_instruction(
552        &self,
553        scope: &Scope,
554        spec: &InstructionSpec,
555    ) -> Result<InstallPlan, AgentConfigError> {
556        instructions_dir::standalone_plan_install(
557            InstructionSurface::id(self),
558            scope,
559            self.standalone_layout(scope),
560            spec,
561        )
562    }
563
564    fn plan_uninstall_instruction(
565        &self,
566        scope: &Scope,
567        name: &str,
568        owner_tag: &str,
569    ) -> Result<UninstallPlan, AgentConfigError> {
570        instructions_dir::standalone_plan_uninstall(
571            InstructionSurface::id(self),
572            scope,
573            self.existing_standalone_layout(scope, name),
574            name,
575            owner_tag,
576        )
577    }
578
579    fn install_instruction(
580        &self,
581        scope: &Scope,
582        spec: &InstructionSpec,
583    ) -> Result<InstallReport, AgentConfigError> {
584        instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
585    }
586
587    fn uninstall_instruction(
588        &self,
589        scope: &Scope,
590        name: &str,
591        owner_tag: &str,
592    ) -> Result<UninstallReport, AgentConfigError> {
593        instructions_dir::standalone_uninstall(
594            scope,
595            self.existing_standalone_layout(scope, name)?,
596            name,
597            owner_tag,
598        )
599    }
600}
601
602fn matcher_to_antigravity(m: &Matcher) -> String {
603    match m {
604        Matcher::All => "*".to_string(),
605        Matcher::Bash => "run_command".to_string(),
606        Matcher::Exact(s) => s.clone(),
607        Matcher::AnyOf(names) => names.join("|"),
608        Matcher::Regex(s) => s.clone(),
609    }
610}
611
612fn build_hook_value(spec: &HookSpec) -> serde_json::Value {
613    let matcher_str = matcher_to_antigravity(&spec.matcher);
614    let command_str = spec.command.render_shell();
615    serde_json::json!([
616        {
617            "matcher": matcher_str,
618            "hooks": [
619                {
620                    "type": "command",
621                    "command": command_str,
622                    "timeout": 10
623                }
624            ]
625        }
626    ])
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::spec::InstructionPlacement;
633    use std::fs;
634    use tempfile::tempdir;
635
636    fn rules_spec(tag: &str, body: &str) -> HookSpec {
637        HookSpec::builder(tag)
638            .command_program("noop", [] as [&str; 0])
639            .rules(body)
640            .build()
641    }
642
643    fn hook_only_spec(tag: &str) -> HookSpec {
644        HookSpec::builder(tag)
645            .command_program("myapp", ["hook"])
646            .matcher(Matcher::Bash)
647            .event(crate::spec::Event::PreToolUse)
648            .build()
649    }
650
651    fn skill(name: &str, owner: &str) -> SkillSpec {
652        SkillSpec::builder(name)
653            .owner(owner)
654            .description("Format Git commits.")
655            .body("## Goal\nFormat them.\n")
656            .build()
657    }
658
659    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
660        McpSpec::builder(name)
661            .owner(owner)
662            .stdio("npx", ["-y", "@example/server"])
663            .build()
664    }
665
666    fn instruction(name: &str, owner: &str) -> InstructionSpec {
667        InstructionSpec::builder(name)
668            .owner(owner)
669            .placement(InstructionPlacement::StandaloneFile)
670            .body("Use Antigravity instructions.\n")
671            .build()
672    }
673
674    #[test]
675    fn install_rules_uses_plural_dot_agents() {
676        let dir = tempdir().unwrap();
677        let agent = AntigravityAgent::new();
678        let scope = Scope::Local(dir.path().to_path_buf());
679        agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
680        assert!(dir.path().join(".agents/rules/alpha.md").exists());
681        assert!(!dir.path().join(".agent/rules/alpha.md").exists());
682    }
683
684    #[test]
685    fn legacy_dot_agent_rules_status_and_uninstall_still_work() {
686        let dir = tempdir().unwrap();
687        let agent = AntigravityAgent::new();
688        let scope = Scope::Local(dir.path().to_path_buf());
689        fs::create_dir_all(dir.path().join(".agent/rules")).unwrap();
690        fs::write(dir.path().join(".agent/rules/alpha.md"), "legacy\n").unwrap();
691
692        assert!(agent.is_installed(&scope, "alpha").unwrap());
693        agent.uninstall(&scope, "alpha").unwrap();
694        assert!(!dir.path().join(".agent/rules/alpha.md").exists());
695    }
696
697    #[test]
698    fn rules_install_idempotent() {
699        let dir = tempdir().unwrap();
700        let agent = AntigravityAgent::new();
701        let scope = Scope::Local(dir.path().to_path_buf());
702        let s = rules_spec("alpha", "body");
703        agent.install(&scope, &s).unwrap();
704        let r = agent.install(&scope, &s).unwrap();
705        assert!(r.already_installed);
706    }
707
708    #[test]
709    fn install_skill_writes_under_dot_agents_skills() {
710        let dir = tempdir().unwrap();
711        let agent = AntigravityAgent::new();
712        let scope = Scope::Local(dir.path().to_path_buf());
713        agent
714            .install_skill(&scope, &skill("alpha", "myapp"))
715            .unwrap();
716        assert!(dir.path().join(".agents/skills/alpha/SKILL.md").exists());
717        assert!(!dir.path().join(".agent/skills/alpha/SKILL.md").exists());
718        let s = fs::read_to_string(dir.path().join(".agents/skills/alpha/SKILL.md")).unwrap();
719        assert!(s.contains("name: alpha"));
720        assert!(s.contains("description: Format Git commits."));
721    }
722
723    #[test]
724    fn legacy_dot_agent_skill_status_and_uninstall_still_work() {
725        let dir = tempdir().unwrap();
726        let agent = AntigravityAgent::new();
727        let scope = Scope::Local(dir.path().to_path_buf());
728        let legacy_root = dir.path().join(".agent/skills");
729        skills_dir::install(&legacy_root, &skill("alpha", "myapp")).unwrap();
730
731        assert!(agent
732            .is_skill_installed(&scope, "alpha")
733            .expect("legacy skill status"));
734        agent.uninstall_skill(&scope, "alpha", "myapp").unwrap();
735        assert!(!dir.path().join(".agent/skills/alpha").exists());
736    }
737
738    #[test]
739    fn skill_install_idempotent() {
740        let dir = tempdir().unwrap();
741        let agent = AntigravityAgent::new();
742        let scope = Scope::Local(dir.path().to_path_buf());
743        let s = skill("alpha", "myapp");
744        agent.install_skill(&scope, &s).unwrap();
745        let r = agent.install_skill(&scope, &s).unwrap();
746        assert!(r.already_installed);
747    }
748
749    #[test]
750    fn skill_uninstall_round_trip() {
751        let dir = tempdir().unwrap();
752        let agent = AntigravityAgent::new();
753        let scope = Scope::Local(dir.path().to_path_buf());
754        agent
755            .install_skill(&scope, &skill("alpha", "myapp"))
756            .unwrap();
757        agent.uninstall_skill(&scope, "alpha", "myapp").unwrap();
758        assert!(!dir.path().join(".agents/skills/alpha").exists());
759    }
760
761    #[test]
762    fn skill_uninstall_owner_mismatch_refused() {
763        let dir = tempdir().unwrap();
764        let agent = AntigravityAgent::new();
765        let scope = Scope::Local(dir.path().to_path_buf());
766        agent
767            .install_skill(&scope, &skill("alpha", "appA"))
768            .unwrap();
769        let err = agent.uninstall_skill(&scope, "alpha", "appB").unwrap_err();
770        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
771    }
772
773    #[test]
774    fn skill_supports_both_scopes() {
775        let agent = AntigravityAgent::new();
776        let scopes = agent.supported_skill_scopes();
777        assert!(scopes.contains(&ScopeKind::Local));
778        assert!(scopes.contains(&ScopeKind::Global));
779    }
780
781    #[test]
782    fn install_instruction_writes_under_dot_agents_rules() {
783        let dir = tempdir().unwrap();
784        let agent = AntigravityAgent::new();
785        let scope = Scope::Local(dir.path().to_path_buf());
786        agent
787            .install_instruction(&scope, &instruction("alpha", "myapp"))
788            .unwrap();
789        assert!(dir.path().join(".agents/rules/alpha.md").exists());
790        assert!(!dir.path().join(".agent/rules/alpha.md").exists());
791    }
792
793    #[test]
794    fn legacy_dot_agent_instruction_status_and_uninstall_still_work() {
795        let dir = tempdir().unwrap();
796        let agent = AntigravityAgent::new();
797        let scope = Scope::Local(dir.path().to_path_buf());
798        let legacy = instructions_dir::StandaloneLayout {
799            config_dir: dir.path().join(".agent"),
800            instruction_dir: dir.path().join(".agent/rules"),
801        };
802        instructions_dir::standalone_install(&scope, legacy, &instruction("alpha", "myapp"))
803            .unwrap();
804
805        assert!(agent
806            .is_instruction_installed(&scope, "alpha")
807            .expect("legacy instruction status"));
808        agent
809            .uninstall_instruction(&scope, "alpha", "myapp")
810            .unwrap();
811        assert!(!dir.path().join(".agent/rules/alpha.md").exists());
812    }
813
814    #[test]
815    fn install_mcp_writes_dot_agents_mcp_config() {
816        let dir = tempdir().unwrap();
817        let agent = AntigravityAgent::new();
818        let scope = Scope::Local(dir.path().to_path_buf());
819        agent
820            .install_mcp(&scope, &mcp_spec("github", "myapp"))
821            .unwrap();
822        let cfg = dir.path().join(".agents/mcp_config.json");
823        let v: serde_json::Value = serde_json::from_slice(&fs::read(cfg).unwrap()).unwrap();
824        assert_eq!(
825            v["mcpServers"]["github"]["command"],
826            serde_json::json!("npx")
827        );
828    }
829
830    #[test]
831    fn uninstall_mcp_owner_mismatch_refused() {
832        let dir = tempdir().unwrap();
833        let agent = AntigravityAgent::new();
834        let scope = Scope::Local(dir.path().to_path_buf());
835        agent
836            .install_mcp(&scope, &mcp_spec("github", "appA"))
837            .unwrap();
838        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
839        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
840    }
841
842    #[test]
843    fn install_hook_writes_hooks_json() {
844        let dir = tempdir().unwrap();
845        let agent = AntigravityAgent::new();
846        let scope = Scope::Local(dir.path().to_path_buf());
847        agent.install(&scope, &hook_only_spec("alpha")).unwrap();
848        let cfg = dir.path().join(".agents/hooks.json");
849        let v: serde_json::Value = serde_json::from_slice(&fs::read(cfg).unwrap()).unwrap();
850        assert_eq!(
851            v["alpha"]["PreToolUse"][0]["matcher"],
852            serde_json::json!("run_command")
853        );
854    }
855}