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