Skip to main content

agent_config/agents/
antigravity.rs

1//! Google Antigravity integration.
2//!
3//! Two surfaces:
4//!
5//! 1. **Rules** — project-local markdown files at `.agent/rules/<tag>.md`.
6//!    Note the directory is singular `.agent/`, not `.agents/`.
7//!
8//! 2. **Skills** — directory-scoped skills at `.agent/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//!
12//! 3. **MCP servers** — JSON config at `.agent/mcp_config.json` (Local) or
13//!    `~/.gemini/antigravity/mcp_config.json` (Global), keyed by server name
14//!    under `mcpServers`.
15//!
16//! Antigravity does not yet expose a hooks surface in the way other harnesses
17//! do.
18
19use std::path::PathBuf;
20
21use crate::agents::planning as agent_planning;
22use crate::error::AgentConfigError;
23use crate::integration::{
24    InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
25};
26use crate::paths;
27use crate::plan::{InstallPlan, UninstallPlan};
28use crate::scope::{Scope, ScopeKind};
29use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
30use crate::status::StatusReport;
31use crate::util::{instructions_dir, mcp_json_object, ownership, rules_dir, skills_dir};
32
33const RULES_DIR: &str = ".agent/rules";
34
35/// Google Antigravity integration.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct AntigravityAgent {
38    _private: (),
39}
40
41impl AntigravityAgent {
42    /// Construct an instance. Stateless.
43    pub const fn new() -> Self {
44        Self { _private: () }
45    }
46
47    fn project_root<'a>(&self, scope: &'a Scope) -> Result<&'a std::path::Path, AgentConfigError> {
48        match scope {
49            Scope::Local(p) => Ok(p),
50            Scope::Global => Err(AgentConfigError::UnsupportedScope {
51                id: "antigravity",
52                scope: ScopeKind::Global,
53            }),
54        }
55    }
56
57    /// Skills root: `<root>/.agent/skills/` (Local) or
58    /// `~/.gemini/antigravity/skills/` (Global). Both scopes are supported
59    /// for skills.
60    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
61        Ok(match scope {
62            Scope::Global => paths::gemini_home()?.join("antigravity").join("skills"),
63            Scope::Local(p) => p.join(".agent").join("skills"),
64        })
65    }
66
67    fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
68        Ok(match scope {
69            Scope::Global => paths::antigravity_mcp_global_file()?,
70            Scope::Local(p) => p.join(".agent").join("mcp_config.json"),
71        })
72    }
73}
74
75impl Integration for AntigravityAgent {
76    fn id(&self) -> &'static str {
77        "antigravity"
78    }
79
80    fn display_name(&self) -> &'static str {
81        "Google Antigravity"
82    }
83
84    fn supported_scopes(&self) -> &'static [ScopeKind] {
85        &[ScopeKind::Local]
86    }
87
88    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
89        HookSpec::validate_tag(tag)?;
90        let root = self.project_root(scope)?;
91        let path = rules_dir::target_path(root, RULES_DIR, tag);
92        Ok(StatusReport::for_file_hook(tag, path))
93    }
94
95    fn plan_install(
96        &self,
97        scope: &Scope,
98        spec: &HookSpec,
99    ) -> Result<InstallPlan, AgentConfigError> {
100        agent_planning::rules_install(
101            Integration::id(self),
102            scope,
103            spec,
104            self.project_root(scope),
105            RULES_DIR,
106        )
107    }
108
109    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
110        agent_planning::rules_uninstall(
111            Integration::id(self),
112            scope,
113            tag,
114            self.project_root(scope),
115            RULES_DIR,
116        )
117    }
118
119    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
120        HookSpec::validate_tag(&spec.tag)?;
121        let _ = self.project_root(scope)?;
122        let rules = spec
123            .rules
124            .as_ref()
125            .ok_or(AgentConfigError::MissingSpecField {
126                id: "antigravity",
127                field: "rules",
128            })?;
129        rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
130    }
131
132    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
133        HookSpec::validate_tag(tag)?;
134        let _ = self.project_root(scope)?;
135        rules_dir::uninstall(scope, RULES_DIR, tag)
136    }
137}
138
139impl McpSurface for AntigravityAgent {
140    fn id(&self) -> &'static str {
141        "antigravity"
142    }
143
144    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
145        &[ScopeKind::Global, ScopeKind::Local]
146    }
147
148    fn mcp_status(
149        &self,
150        scope: &Scope,
151        name: &str,
152        expected_owner: &str,
153    ) -> Result<StatusReport, AgentConfigError> {
154        McpSpec::validate_name(name)?;
155        let cfg = Self::mcp_path(scope)?;
156        let ledger = ownership::mcp_ledger_for(&cfg);
157        let presence = mcp_json_object::config_presence(&cfg, name)?;
158        let recorded = ownership::owner_of(&ledger, name)?;
159        Ok(StatusReport::for_mcp(
160            name,
161            cfg,
162            ledger,
163            presence,
164            expected_owner,
165            recorded,
166        ))
167    }
168
169    fn plan_install_mcp(
170        &self,
171        scope: &Scope,
172        spec: &McpSpec,
173    ) -> Result<InstallPlan, AgentConfigError> {
174        agent_planning::mcp_json_object_install(
175            McpSurface::id(self),
176            scope,
177            spec,
178            Self::mcp_path(scope),
179        )
180    }
181
182    fn plan_uninstall_mcp(
183        &self,
184        scope: &Scope,
185        name: &str,
186        owner_tag: &str,
187    ) -> Result<UninstallPlan, AgentConfigError> {
188        agent_planning::mcp_json_object_uninstall(
189            McpSurface::id(self),
190            scope,
191            name,
192            owner_tag,
193            Self::mcp_path(scope),
194        )
195    }
196
197    fn install_mcp(
198        &self,
199        scope: &Scope,
200        spec: &McpSpec,
201    ) -> Result<InstallReport, AgentConfigError> {
202        spec.validate()?;
203        let cfg = Self::mcp_path(scope)?;
204        spec.validate_local_secret_policy(scope)?;
205        scope.ensure_contained(&cfg)?;
206        let ledger = ownership::mcp_ledger_for(&cfg);
207        mcp_json_object::install(&cfg, &ledger, spec)
208    }
209
210    fn uninstall_mcp(
211        &self,
212        scope: &Scope,
213        name: &str,
214        owner_tag: &str,
215    ) -> Result<UninstallReport, AgentConfigError> {
216        McpSpec::validate_name(name)?;
217        HookSpec::validate_tag(owner_tag)?;
218        let cfg = Self::mcp_path(scope)?;
219        scope.ensure_contained(&cfg)?;
220        let ledger = ownership::mcp_ledger_for(&cfg);
221        mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
222    }
223}
224
225impl SkillSurface for AntigravityAgent {
226    fn id(&self) -> &'static str {
227        "antigravity"
228    }
229
230    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
231        &[ScopeKind::Global, ScopeKind::Local]
232    }
233
234    fn skill_status(
235        &self,
236        scope: &Scope,
237        name: &str,
238        expected_owner: &str,
239    ) -> Result<StatusReport, AgentConfigError> {
240        SkillSpec::validate_name(name)?;
241        let root = Self::skills_root(scope)?;
242        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
243        let recorded = ownership::owner_of(&ledger, name)?;
244        Ok(StatusReport::for_skill(
245            name,
246            dir,
247            manifest,
248            ledger,
249            expected_owner,
250            recorded,
251        ))
252    }
253
254    fn plan_install_skill(
255        &self,
256        scope: &Scope,
257        spec: &SkillSpec,
258    ) -> Result<InstallPlan, AgentConfigError> {
259        agent_planning::skill_install(
260            SkillSurface::id(self),
261            scope,
262            spec,
263            Self::skills_root(scope),
264        )
265    }
266
267    fn plan_uninstall_skill(
268        &self,
269        scope: &Scope,
270        name: &str,
271        owner_tag: &str,
272    ) -> Result<UninstallPlan, AgentConfigError> {
273        agent_planning::skill_uninstall(
274            SkillSurface::id(self),
275            scope,
276            name,
277            owner_tag,
278            Self::skills_root(scope),
279        )
280    }
281
282    fn install_skill(
283        &self,
284        scope: &Scope,
285        spec: &SkillSpec,
286    ) -> Result<InstallReport, AgentConfigError> {
287        spec.validate()?;
288        let root = Self::skills_root(scope)?;
289        scope.ensure_contained(&root)?;
290        skills_dir::install(&root, spec)
291    }
292
293    fn uninstall_skill(
294        &self,
295        scope: &Scope,
296        name: &str,
297        owner_tag: &str,
298    ) -> Result<UninstallReport, AgentConfigError> {
299        SkillSpec::validate_name(name)?;
300        HookSpec::validate_tag(owner_tag)?;
301        let root = Self::skills_root(scope)?;
302        skills_dir::uninstall(&root, name, owner_tag)
303    }
304}
305
306impl AntigravityAgent {
307    fn standalone_layout(
308        &self,
309        scope: &Scope,
310    ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
311        let root = self.project_root(scope)?;
312        Ok(instructions_dir::StandaloneLayout {
313            config_dir: root.join(".agent"),
314            instruction_dir: root.join(RULES_DIR),
315        })
316    }
317}
318
319impl InstructionSurface for AntigravityAgent {
320    fn id(&self) -> &'static str {
321        "antigravity"
322    }
323
324    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
325        &[ScopeKind::Local]
326    }
327
328    fn instruction_status(
329        &self,
330        scope: &Scope,
331        name: &str,
332        expected_owner: &str,
333    ) -> Result<StatusReport, AgentConfigError> {
334        instructions_dir::standalone_status(self.standalone_layout(scope)?, name, expected_owner)
335    }
336
337    fn plan_install_instruction(
338        &self,
339        scope: &Scope,
340        spec: &InstructionSpec,
341    ) -> Result<InstallPlan, AgentConfigError> {
342        instructions_dir::standalone_plan_install(
343            InstructionSurface::id(self),
344            scope,
345            self.standalone_layout(scope),
346            spec,
347        )
348    }
349
350    fn plan_uninstall_instruction(
351        &self,
352        scope: &Scope,
353        name: &str,
354        owner_tag: &str,
355    ) -> Result<UninstallPlan, AgentConfigError> {
356        instructions_dir::standalone_plan_uninstall(
357            InstructionSurface::id(self),
358            scope,
359            self.standalone_layout(scope),
360            name,
361            owner_tag,
362        )
363    }
364
365    fn install_instruction(
366        &self,
367        scope: &Scope,
368        spec: &InstructionSpec,
369    ) -> Result<InstallReport, AgentConfigError> {
370        instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
371    }
372
373    fn uninstall_instruction(
374        &self,
375        scope: &Scope,
376        name: &str,
377        owner_tag: &str,
378    ) -> Result<UninstallReport, AgentConfigError> {
379        instructions_dir::standalone_uninstall(
380            scope,
381            self.standalone_layout(scope)?,
382            name,
383            owner_tag,
384        )
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::fs;
392    use tempfile::tempdir;
393
394    fn rules_spec(tag: &str, body: &str) -> HookSpec {
395        HookSpec::builder(tag)
396            .command_program("noop", [] as [&str; 0])
397            .rules(body)
398            .build()
399    }
400
401    fn skill(name: &str, owner: &str) -> SkillSpec {
402        SkillSpec::builder(name)
403            .owner(owner)
404            .description("Format Git commits.")
405            .body("## Goal\nFormat them.\n")
406            .build()
407    }
408
409    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
410        McpSpec::builder(name)
411            .owner(owner)
412            .stdio("npx", ["-y", "@example/server"])
413            .build()
414    }
415
416    #[test]
417    fn install_rules_uses_singular_dot_agent() {
418        let dir = tempdir().unwrap();
419        let agent = AntigravityAgent::new();
420        let scope = Scope::Local(dir.path().to_path_buf());
421        agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
422        assert!(dir.path().join(".agent/rules/alpha.md").exists());
423        assert!(!dir.path().join(".agents").exists());
424    }
425
426    #[test]
427    fn rules_install_idempotent() {
428        let dir = tempdir().unwrap();
429        let agent = AntigravityAgent::new();
430        let scope = Scope::Local(dir.path().to_path_buf());
431        let s = rules_spec("alpha", "body");
432        agent.install(&scope, &s).unwrap();
433        let r = agent.install(&scope, &s).unwrap();
434        assert!(r.already_installed);
435    }
436
437    #[test]
438    fn install_skill_writes_under_dot_agent_skills() {
439        let dir = tempdir().unwrap();
440        let agent = AntigravityAgent::new();
441        let scope = Scope::Local(dir.path().to_path_buf());
442        agent
443            .install_skill(&scope, &skill("alpha", "myapp"))
444            .unwrap();
445        assert!(dir.path().join(".agent/skills/alpha/SKILL.md").exists());
446        let s = fs::read_to_string(dir.path().join(".agent/skills/alpha/SKILL.md")).unwrap();
447        assert!(s.contains("name: alpha"));
448        assert!(s.contains("description: Format Git commits."));
449    }
450
451    #[test]
452    fn skill_install_idempotent() {
453        let dir = tempdir().unwrap();
454        let agent = AntigravityAgent::new();
455        let scope = Scope::Local(dir.path().to_path_buf());
456        let s = skill("alpha", "myapp");
457        agent.install_skill(&scope, &s).unwrap();
458        let r = agent.install_skill(&scope, &s).unwrap();
459        assert!(r.already_installed);
460    }
461
462    #[test]
463    fn skill_uninstall_round_trip() {
464        let dir = tempdir().unwrap();
465        let agent = AntigravityAgent::new();
466        let scope = Scope::Local(dir.path().to_path_buf());
467        agent
468            .install_skill(&scope, &skill("alpha", "myapp"))
469            .unwrap();
470        agent.uninstall_skill(&scope, "alpha", "myapp").unwrap();
471        assert!(!dir.path().join(".agent/skills/alpha").exists());
472    }
473
474    #[test]
475    fn skill_uninstall_owner_mismatch_refused() {
476        let dir = tempdir().unwrap();
477        let agent = AntigravityAgent::new();
478        let scope = Scope::Local(dir.path().to_path_buf());
479        agent
480            .install_skill(&scope, &skill("alpha", "appA"))
481            .unwrap();
482        let err = agent.uninstall_skill(&scope, "alpha", "appB").unwrap_err();
483        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
484    }
485
486    #[test]
487    fn skill_supports_both_scopes() {
488        let agent = AntigravityAgent::new();
489        let scopes = agent.supported_skill_scopes();
490        assert!(scopes.contains(&ScopeKind::Local));
491        assert!(scopes.contains(&ScopeKind::Global));
492    }
493
494    #[test]
495    fn rules_install_requires_rules_field() {
496        let dir = tempdir().unwrap();
497        let agent = AntigravityAgent::new();
498        let scope = Scope::Local(dir.path().to_path_buf());
499        let no_rules = HookSpec::builder("alpha")
500            .command_program("noop", [] as [&str; 0])
501            .build();
502        let err = agent.install(&scope, &no_rules).unwrap_err();
503        assert!(matches!(
504            err,
505            AgentConfigError::MissingSpecField { field: "rules", .. }
506        ));
507    }
508
509    #[test]
510    fn install_mcp_writes_dot_agent_mcp_config() {
511        let dir = tempdir().unwrap();
512        let agent = AntigravityAgent::new();
513        let scope = Scope::Local(dir.path().to_path_buf());
514        agent
515            .install_mcp(&scope, &mcp_spec("github", "myapp"))
516            .unwrap();
517        let cfg = dir.path().join(".agent/mcp_config.json");
518        let v: serde_json::Value = serde_json::from_slice(&fs::read(cfg).unwrap()).unwrap();
519        assert_eq!(
520            v["mcpServers"]["github"]["command"],
521            serde_json::json!("npx")
522        );
523    }
524
525    #[test]
526    fn uninstall_mcp_owner_mismatch_refused() {
527        let dir = tempdir().unwrap();
528        let agent = AntigravityAgent::new();
529        let scope = Scope::Local(dir.path().to_path_buf());
530        agent
531            .install_mcp(&scope, &mcp_spec("github", "appA"))
532            .unwrap();
533        let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
534        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
535    }
536}