1use 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#[derive(Debug, Clone, Copy, Default)]
37pub struct AntigravityAgent {
38 _private: (),
39}
40
41impl AntigravityAgent {
42 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 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}