1use 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, UninstallPlan};
29use crate::scope::{Scope, ScopeKind};
30use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
31use crate::status::StatusReport;
32use crate::util::{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#[derive(Debug, Clone, Copy, Default)]
39pub struct AntigravityAgent {
40 _private: (),
41}
42
43impl AntigravityAgent {
44 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 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 mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
95 Ok(match scope {
96 Scope::Global => paths::antigravity_mcp_global_file()?,
97 Scope::Local(p) => p.join(".agent").join("mcp_config.json"),
98 })
99 }
100}
101
102impl Integration for AntigravityAgent {
103 fn id(&self) -> &'static str {
104 "antigravity"
105 }
106
107 fn display_name(&self) -> &'static str {
108 "Google Antigravity"
109 }
110
111 fn supported_scopes(&self) -> &'static [ScopeKind] {
112 &[ScopeKind::Local]
113 }
114
115 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
116 HookSpec::validate_tag(tag)?;
117 let root = self.project_root(scope)?;
118 let path = rules_dir::target_path(root, RULES_DIR, tag);
119 if !path.exists() {
120 let legacy = rules_dir::target_path(root, LEGACY_RULES_DIR, tag);
121 if legacy.exists() {
122 return Ok(StatusReport::for_file_hook(tag, legacy));
123 }
124 }
125 Ok(StatusReport::for_file_hook(tag, path))
126 }
127
128 fn plan_install(
129 &self,
130 scope: &Scope,
131 spec: &HookSpec,
132 ) -> Result<InstallPlan, AgentConfigError> {
133 agent_planning::rules_install(
134 Integration::id(self),
135 scope,
136 spec,
137 self.project_root(scope),
138 RULES_DIR,
139 )
140 }
141
142 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
143 HookSpec::validate_tag(tag)?;
144 let root = self.project_root(scope);
145 let rules_dir = if let Ok(root) = root {
146 let current = rules_dir::target_path(root, RULES_DIR, tag);
147 let legacy = rules_dir::target_path(root, LEGACY_RULES_DIR, tag);
148 if !current.exists() && legacy.exists() {
149 LEGACY_RULES_DIR
150 } else {
151 RULES_DIR
152 }
153 } else {
154 RULES_DIR
155 };
156 agent_planning::rules_uninstall(
157 Integration::id(self),
158 scope,
159 tag,
160 self.project_root(scope),
161 rules_dir,
162 )
163 }
164
165 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
166 HookSpec::validate_tag(&spec.tag)?;
167 let _ = self.project_root(scope)?;
168 let rules = spec
169 .rules
170 .as_ref()
171 .ok_or(AgentConfigError::MissingSpecField {
172 id: "antigravity",
173 field: "rules",
174 })?;
175 rules_dir::install(scope, RULES_DIR, &spec.tag, &rules.content)
176 }
177
178 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
179 HookSpec::validate_tag(tag)?;
180 let root = self.project_root(scope)?;
181 let current = rules_dir::target_path(root, RULES_DIR, tag);
182 let legacy = rules_dir::target_path(root, LEGACY_RULES_DIR, tag);
183 let rules_dir = if !current.exists() && legacy.exists() {
184 LEGACY_RULES_DIR
185 } else {
186 RULES_DIR
187 };
188 rules_dir::uninstall(scope, rules_dir, tag)
189 }
190}
191
192impl McpSurface for AntigravityAgent {
193 fn id(&self) -> &'static str {
194 "antigravity"
195 }
196
197 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
198 &[ScopeKind::Global, ScopeKind::Local]
199 }
200
201 fn mcp_status(
202 &self,
203 scope: &Scope,
204 name: &str,
205 expected_owner: &str,
206 ) -> Result<StatusReport, AgentConfigError> {
207 McpSpec::validate_name(name)?;
208 let cfg = Self::mcp_path(scope)?;
209 let ledger = ownership::mcp_ledger_for(&cfg);
210 let presence = mcp_json_object::config_presence(&cfg, name)?;
211 let recorded = ownership::owner_of(&ledger, name)?;
212 Ok(StatusReport::for_mcp(
213 name,
214 cfg,
215 ledger,
216 presence,
217 expected_owner,
218 recorded,
219 ))
220 }
221
222 fn plan_install_mcp(
223 &self,
224 scope: &Scope,
225 spec: &McpSpec,
226 ) -> Result<InstallPlan, AgentConfigError> {
227 agent_planning::mcp_json_object_install(
228 McpSurface::id(self),
229 scope,
230 spec,
231 Self::mcp_path(scope),
232 )
233 }
234
235 fn plan_uninstall_mcp(
236 &self,
237 scope: &Scope,
238 name: &str,
239 owner_tag: &str,
240 ) -> Result<UninstallPlan, AgentConfigError> {
241 agent_planning::mcp_json_object_uninstall(
242 McpSurface::id(self),
243 scope,
244 name,
245 owner_tag,
246 Self::mcp_path(scope),
247 )
248 }
249
250 fn install_mcp(
251 &self,
252 scope: &Scope,
253 spec: &McpSpec,
254 ) -> Result<InstallReport, AgentConfigError> {
255 spec.validate()?;
256 let cfg = Self::mcp_path(scope)?;
257 spec.validate_local_secret_policy(scope)?;
258 scope.ensure_contained(&cfg)?;
259 let ledger = ownership::mcp_ledger_for(&cfg);
260 mcp_json_object::install(&cfg, &ledger, spec)
261 }
262
263 fn uninstall_mcp(
264 &self,
265 scope: &Scope,
266 name: &str,
267 owner_tag: &str,
268 ) -> Result<UninstallReport, AgentConfigError> {
269 McpSpec::validate_name(name)?;
270 HookSpec::validate_tag(owner_tag)?;
271 let cfg = Self::mcp_path(scope)?;
272 scope.ensure_contained(&cfg)?;
273 let ledger = ownership::mcp_ledger_for(&cfg);
274 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
275 }
276}
277
278impl SkillSurface for AntigravityAgent {
279 fn id(&self) -> &'static str {
280 "antigravity"
281 }
282
283 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
284 &[ScopeKind::Global, ScopeKind::Local]
285 }
286
287 fn skill_status(
288 &self,
289 scope: &Scope,
290 name: &str,
291 expected_owner: &str,
292 ) -> Result<StatusReport, AgentConfigError> {
293 SkillSpec::validate_name(name)?;
294 let root = Self::skills_root(scope)?;
295 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
296 if !dir.exists() && ownership::owner_of(&ledger, name)?.is_none() {
297 if let Some(legacy) = Self::legacy_skills_root(scope) {
298 let (legacy_dir, legacy_manifest, legacy_ledger) =
299 skills_dir::paths_for_status(&legacy, name);
300 if legacy_dir.exists() || ownership::owner_of(&legacy_ledger, name)?.is_some() {
301 let recorded = ownership::owner_of(&legacy_ledger, name)?;
302 return Ok(StatusReport::for_skill(
303 name,
304 legacy_dir,
305 legacy_manifest,
306 legacy_ledger,
307 expected_owner,
308 recorded,
309 ));
310 }
311 }
312 }
313 let recorded = ownership::owner_of(&ledger, name)?;
314 Ok(StatusReport::for_skill(
315 name,
316 dir,
317 manifest,
318 ledger,
319 expected_owner,
320 recorded,
321 ))
322 }
323
324 fn plan_install_skill(
325 &self,
326 scope: &Scope,
327 spec: &SkillSpec,
328 ) -> Result<InstallPlan, AgentConfigError> {
329 agent_planning::skill_install(
330 SkillSurface::id(self),
331 scope,
332 spec,
333 Self::skills_root(scope),
334 )
335 }
336
337 fn plan_uninstall_skill(
338 &self,
339 scope: &Scope,
340 name: &str,
341 owner_tag: &str,
342 ) -> Result<UninstallPlan, AgentConfigError> {
343 agent_planning::skill_uninstall(
344 SkillSurface::id(self),
345 scope,
346 name,
347 owner_tag,
348 Self::existing_skills_root(scope, name),
349 )
350 }
351
352 fn install_skill(
353 &self,
354 scope: &Scope,
355 spec: &SkillSpec,
356 ) -> Result<InstallReport, AgentConfigError> {
357 spec.validate()?;
358 let root = Self::skills_root(scope)?;
359 scope.ensure_contained(&root)?;
360 skills_dir::install(&root, spec)
361 }
362
363 fn uninstall_skill(
364 &self,
365 scope: &Scope,
366 name: &str,
367 owner_tag: &str,
368 ) -> Result<UninstallReport, AgentConfigError> {
369 SkillSpec::validate_name(name)?;
370 HookSpec::validate_tag(owner_tag)?;
371 let root = Self::existing_skills_root(scope, name)?;
372 scope.ensure_contained(&root)?;
373 skills_dir::uninstall(&root, name, owner_tag)
374 }
375}
376
377impl AntigravityAgent {
378 fn standalone_layout(
379 &self,
380 scope: &Scope,
381 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
382 let root = self.project_root(scope)?;
383 Ok(instructions_dir::StandaloneLayout {
384 config_dir: root.join(".agents"),
385 instruction_dir: root.join(RULES_DIR),
386 })
387 }
388
389 fn legacy_standalone_layout(
390 &self,
391 scope: &Scope,
392 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
393 let root = self.project_root(scope)?;
394 Ok(instructions_dir::StandaloneLayout {
395 config_dir: root.join(".agent"),
396 instruction_dir: root.join(LEGACY_RULES_DIR),
397 })
398 }
399
400 fn existing_standalone_layout(
401 &self,
402 scope: &Scope,
403 name: &str,
404 ) -> Result<instructions_dir::StandaloneLayout, AgentConfigError> {
405 InstructionSpec::validate_name(name)?;
406 let primary = self.standalone_layout(scope)?;
407 let primary_file = primary.instruction_dir.join(format!("{name}.md"));
408 let primary_ledger = instructions_dir::ledger_path(&primary.config_dir);
409 if primary_file.exists() || ownership::owner_of(&primary_ledger, name)?.is_some() {
410 return Ok(primary);
411 }
412
413 let legacy = self.legacy_standalone_layout(scope)?;
414 let legacy_file = legacy.instruction_dir.join(format!("{name}.md"));
415 let legacy_ledger = instructions_dir::ledger_path(&legacy.config_dir);
416 if legacy_file.exists() || ownership::owner_of(&legacy_ledger, name)?.is_some() {
417 return Ok(legacy);
418 }
419
420 Ok(primary)
421 }
422}
423
424impl InstructionSurface for AntigravityAgent {
425 fn id(&self) -> &'static str {
426 "antigravity"
427 }
428
429 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
430 &[ScopeKind::Local]
431 }
432
433 fn instruction_status(
434 &self,
435 scope: &Scope,
436 name: &str,
437 expected_owner: &str,
438 ) -> Result<StatusReport, AgentConfigError> {
439 instructions_dir::standalone_status(
440 self.existing_standalone_layout(scope, name)?,
441 name,
442 expected_owner,
443 )
444 }
445
446 fn plan_install_instruction(
447 &self,
448 scope: &Scope,
449 spec: &InstructionSpec,
450 ) -> Result<InstallPlan, AgentConfigError> {
451 instructions_dir::standalone_plan_install(
452 InstructionSurface::id(self),
453 scope,
454 self.standalone_layout(scope),
455 spec,
456 )
457 }
458
459 fn plan_uninstall_instruction(
460 &self,
461 scope: &Scope,
462 name: &str,
463 owner_tag: &str,
464 ) -> Result<UninstallPlan, AgentConfigError> {
465 instructions_dir::standalone_plan_uninstall(
466 InstructionSurface::id(self),
467 scope,
468 self.existing_standalone_layout(scope, name),
469 name,
470 owner_tag,
471 )
472 }
473
474 fn install_instruction(
475 &self,
476 scope: &Scope,
477 spec: &InstructionSpec,
478 ) -> Result<InstallReport, AgentConfigError> {
479 instructions_dir::standalone_install(scope, self.standalone_layout(scope)?, spec)
480 }
481
482 fn uninstall_instruction(
483 &self,
484 scope: &Scope,
485 name: &str,
486 owner_tag: &str,
487 ) -> Result<UninstallReport, AgentConfigError> {
488 instructions_dir::standalone_uninstall(
489 scope,
490 self.existing_standalone_layout(scope, name)?,
491 name,
492 owner_tag,
493 )
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::spec::InstructionPlacement;
501 use std::fs;
502 use tempfile::tempdir;
503
504 fn rules_spec(tag: &str, body: &str) -> HookSpec {
505 HookSpec::builder(tag)
506 .command_program("noop", [] as [&str; 0])
507 .rules(body)
508 .build()
509 }
510
511 fn skill(name: &str, owner: &str) -> SkillSpec {
512 SkillSpec::builder(name)
513 .owner(owner)
514 .description("Format Git commits.")
515 .body("## Goal\nFormat them.\n")
516 .build()
517 }
518
519 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
520 McpSpec::builder(name)
521 .owner(owner)
522 .stdio("npx", ["-y", "@example/server"])
523 .build()
524 }
525
526 fn instruction(name: &str, owner: &str) -> InstructionSpec {
527 InstructionSpec::builder(name)
528 .owner(owner)
529 .placement(InstructionPlacement::StandaloneFile)
530 .body("Use Antigravity instructions.\n")
531 .build()
532 }
533
534 #[test]
535 fn install_rules_uses_plural_dot_agents() {
536 let dir = tempdir().unwrap();
537 let agent = AntigravityAgent::new();
538 let scope = Scope::Local(dir.path().to_path_buf());
539 agent.install(&scope, &rules_spec("alpha", "body")).unwrap();
540 assert!(dir.path().join(".agents/rules/alpha.md").exists());
541 assert!(!dir.path().join(".agent/rules/alpha.md").exists());
542 }
543
544 #[test]
545 fn legacy_dot_agent_rules_status_and_uninstall_still_work() {
546 let dir = tempdir().unwrap();
547 let agent = AntigravityAgent::new();
548 let scope = Scope::Local(dir.path().to_path_buf());
549 fs::create_dir_all(dir.path().join(".agent/rules")).unwrap();
550 fs::write(dir.path().join(".agent/rules/alpha.md"), "legacy\n").unwrap();
551
552 assert!(agent.is_installed(&scope, "alpha").unwrap());
553 agent.uninstall(&scope, "alpha").unwrap();
554 assert!(!dir.path().join(".agent/rules/alpha.md").exists());
555 }
556
557 #[test]
558 fn rules_install_idempotent() {
559 let dir = tempdir().unwrap();
560 let agent = AntigravityAgent::new();
561 let scope = Scope::Local(dir.path().to_path_buf());
562 let s = rules_spec("alpha", "body");
563 agent.install(&scope, &s).unwrap();
564 let r = agent.install(&scope, &s).unwrap();
565 assert!(r.already_installed);
566 }
567
568 #[test]
569 fn install_skill_writes_under_dot_agents_skills() {
570 let dir = tempdir().unwrap();
571 let agent = AntigravityAgent::new();
572 let scope = Scope::Local(dir.path().to_path_buf());
573 agent
574 .install_skill(&scope, &skill("alpha", "myapp"))
575 .unwrap();
576 assert!(dir.path().join(".agents/skills/alpha/SKILL.md").exists());
577 assert!(!dir.path().join(".agent/skills/alpha/SKILL.md").exists());
578 let s = fs::read_to_string(dir.path().join(".agents/skills/alpha/SKILL.md")).unwrap();
579 assert!(s.contains("name: alpha"));
580 assert!(s.contains("description: Format Git commits."));
581 }
582
583 #[test]
584 fn legacy_dot_agent_skill_status_and_uninstall_still_work() {
585 let dir = tempdir().unwrap();
586 let agent = AntigravityAgent::new();
587 let scope = Scope::Local(dir.path().to_path_buf());
588 let legacy_root = dir.path().join(".agent/skills");
589 skills_dir::install(&legacy_root, &skill("alpha", "myapp")).unwrap();
590
591 assert!(agent
592 .is_skill_installed(&scope, "alpha")
593 .expect("legacy skill status"));
594 agent.uninstall_skill(&scope, "alpha", "myapp").unwrap();
595 assert!(!dir.path().join(".agent/skills/alpha").exists());
596 }
597
598 #[test]
599 fn skill_install_idempotent() {
600 let dir = tempdir().unwrap();
601 let agent = AntigravityAgent::new();
602 let scope = Scope::Local(dir.path().to_path_buf());
603 let s = skill("alpha", "myapp");
604 agent.install_skill(&scope, &s).unwrap();
605 let r = agent.install_skill(&scope, &s).unwrap();
606 assert!(r.already_installed);
607 }
608
609 #[test]
610 fn skill_uninstall_round_trip() {
611 let dir = tempdir().unwrap();
612 let agent = AntigravityAgent::new();
613 let scope = Scope::Local(dir.path().to_path_buf());
614 agent
615 .install_skill(&scope, &skill("alpha", "myapp"))
616 .unwrap();
617 agent.uninstall_skill(&scope, "alpha", "myapp").unwrap();
618 assert!(!dir.path().join(".agents/skills/alpha").exists());
619 }
620
621 #[test]
622 fn skill_uninstall_owner_mismatch_refused() {
623 let dir = tempdir().unwrap();
624 let agent = AntigravityAgent::new();
625 let scope = Scope::Local(dir.path().to_path_buf());
626 agent
627 .install_skill(&scope, &skill("alpha", "appA"))
628 .unwrap();
629 let err = agent.uninstall_skill(&scope, "alpha", "appB").unwrap_err();
630 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
631 }
632
633 #[test]
634 fn skill_supports_both_scopes() {
635 let agent = AntigravityAgent::new();
636 let scopes = agent.supported_skill_scopes();
637 assert!(scopes.contains(&ScopeKind::Local));
638 assert!(scopes.contains(&ScopeKind::Global));
639 }
640
641 #[test]
642 fn install_instruction_writes_under_dot_agents_rules() {
643 let dir = tempdir().unwrap();
644 let agent = AntigravityAgent::new();
645 let scope = Scope::Local(dir.path().to_path_buf());
646 agent
647 .install_instruction(&scope, &instruction("alpha", "myapp"))
648 .unwrap();
649 assert!(dir.path().join(".agents/rules/alpha.md").exists());
650 assert!(!dir.path().join(".agent/rules/alpha.md").exists());
651 }
652
653 #[test]
654 fn legacy_dot_agent_instruction_status_and_uninstall_still_work() {
655 let dir = tempdir().unwrap();
656 let agent = AntigravityAgent::new();
657 let scope = Scope::Local(dir.path().to_path_buf());
658 let legacy = instructions_dir::StandaloneLayout {
659 config_dir: dir.path().join(".agent"),
660 instruction_dir: dir.path().join(".agent/rules"),
661 };
662 instructions_dir::standalone_install(&scope, legacy, &instruction("alpha", "myapp"))
663 .unwrap();
664
665 assert!(agent
666 .is_instruction_installed(&scope, "alpha")
667 .expect("legacy instruction status"));
668 agent
669 .uninstall_instruction(&scope, "alpha", "myapp")
670 .unwrap();
671 assert!(!dir.path().join(".agent/rules/alpha.md").exists());
672 }
673
674 #[test]
675 fn rules_install_requires_rules_field() {
676 let dir = tempdir().unwrap();
677 let agent = AntigravityAgent::new();
678 let scope = Scope::Local(dir.path().to_path_buf());
679 let no_rules = HookSpec::builder("alpha")
680 .command_program("noop", [] as [&str; 0])
681 .build();
682 let err = agent.install(&scope, &no_rules).unwrap_err();
683 assert!(matches!(
684 err,
685 AgentConfigError::MissingSpecField { field: "rules", .. }
686 ));
687 }
688
689 #[test]
690 fn install_mcp_writes_dot_agent_mcp_config() {
691 let dir = tempdir().unwrap();
692 let agent = AntigravityAgent::new();
693 let scope = Scope::Local(dir.path().to_path_buf());
694 agent
695 .install_mcp(&scope, &mcp_spec("github", "myapp"))
696 .unwrap();
697 let cfg = dir.path().join(".agent/mcp_config.json");
698 let v: serde_json::Value = serde_json::from_slice(&fs::read(cfg).unwrap()).unwrap();
699 assert_eq!(
700 v["mcpServers"]["github"]["command"],
701 serde_json::json!("npx")
702 );
703 }
704
705 #[test]
706 fn uninstall_mcp_owner_mismatch_refused() {
707 let dir = tempdir().unwrap();
708 let agent = AntigravityAgent::new();
709 let scope = Scope::Local(dir.path().to_path_buf());
710 agent
711 .install_mcp(&scope, &mcp_spec("github", "appA"))
712 .unwrap();
713 let err = agent.uninstall_mcp(&scope, "github", "appB").unwrap_err();
714 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
715 }
716}