Skip to content

Commit 8665f56

Browse files
committed
feat(agy): enhance Google Antigravity CLI integration
- Set requires_cli=True and install_url for CLI tool detection - Implement build_exec_args() for non-interactive execution via agy --print - Add dot-to-hyphen hook command note injection in generated SKILL.md files
1 parent 3a57481 commit 8665f56

2 files changed

Lines changed: 170 additions & 5 deletions

File tree

src/specify_cli/integrations/agy/__init__.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import re
89
from pathlib import Path
910
from typing import TYPE_CHECKING, Any
1011

@@ -13,6 +14,15 @@
1314
if TYPE_CHECKING:
1415
from ..manifest import IntegrationManifest
1516

17+
# Note injected into hook sections so agy maps dot-notation command
18+
# names (from extensions.yml) to the hyphenated skill names it uses.
19+
# Without this, agy emits ``/speckit.git.commit`` (which does not
20+
# resolve) instead of ``/speckit-git-commit``.
21+
_HOOK_COMMAND_NOTE = (
22+
"- When constructing slash commands from hook command names, "
23+
"replace dots (`.`) with hyphens (`-`). "
24+
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
25+
)
1626

1727

1828
class AgyIntegration(SkillsIntegration):
@@ -23,8 +33,8 @@ class AgyIntegration(SkillsIntegration):
2333
"name": "Antigravity",
2434
"folder": ".agents/",
2535
"commands_subdir": "skills",
26-
"install_url": None,
27-
"requires_cli": False,
36+
"install_url": "https://antigravity.google/",
37+
"requires_cli": True,
2838
}
2939
registrar_config = {
3040
"dir": ".agents/skills",
@@ -34,6 +44,54 @@ class AgyIntegration(SkillsIntegration):
3444
}
3545
context_file = "AGENTS.md"
3646

47+
@staticmethod
48+
def _inject_hook_command_note(content: str) -> str:
49+
"""Insert a dot-to-hyphen note before each hook output instruction.
50+
51+
Targets the line ``- For each executable hook, output the following``
52+
and inserts the note on the line before it, matching its indentation.
53+
Skips if the note is already present.
54+
"""
55+
if "replace dots" in content:
56+
return content
57+
58+
def repl(m: re.Match[str]) -> str:
59+
indent = m.group(1)
60+
instruction = m.group(2)
61+
# ``eol`` is empty when the regex matched via ``$`` because the
62+
# instruction was the final line of a file with no trailing
63+
# newline. Default to ``\n`` so the note never collapses onto
64+
# the same line as the instruction.
65+
eol = m.group(3) or "\n"
66+
return (
67+
indent
68+
+ _HOOK_COMMAND_NOTE.rstrip("\n")
69+
+ eol
70+
+ indent
71+
+ instruction
72+
+ eol
73+
)
74+
75+
return re.sub(
76+
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
77+
repl,
78+
content,
79+
)
80+
81+
def post_process_skill_content(self, content: str) -> str:
82+
"""Inject the dot-to-hyphen hook command note."""
83+
return self._inject_hook_command_note(content)
84+
85+
def build_exec_args(
86+
self,
87+
prompt: str,
88+
*,
89+
model: str | None = None,
90+
output_json: bool = True,
91+
) -> list[str] | None:
92+
# agy does not support --model or JSON output; both params are ignored
93+
return ["agy", "--print", prompt]
94+
3795
def setup(
3896
self,
3997
project_root: Path,
@@ -49,4 +107,21 @@ def setup(
49107
fg="yellow",
50108
err=True,
51109
)
52-
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
110+
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
111+
112+
skills_dir = self.skills_dest(project_root).resolve()
113+
for path in created:
114+
try:
115+
path.resolve().relative_to(skills_dir)
116+
except ValueError:
117+
continue
118+
if path.name != "SKILL.md":
119+
continue
120+
121+
content = path.read_bytes().decode("utf-8")
122+
updated = self.post_process_skill_content(content)
123+
if updated != content:
124+
path.write_bytes(updated.encode("utf-8"))
125+
self.record_file_in_manifest(path, project_root, manifest)
126+
127+
return created

tests/integrations/test_integration_agy.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Tests for AgyIntegration (Antigravity)."""
22

3+
from specify_cli.integrations import get_integration
4+
35
from .test_integration_base_skills import SkillsIntegrationTests
46

57

@@ -12,10 +14,21 @@ class TestAgyIntegration(SkillsIntegrationTests):
1214

1315
def test_options_include_skills_flag(self):
1416
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
15-
from specify_cli.integrations import get_integration
1617
i = get_integration(self.KEY)
1718
skills_opts = [o for o in i.options() if o.name == "--skills"]
1819
assert len(skills_opts) == 0
20+
21+
def test_requires_cli_is_true(self):
22+
"""agy is a CLI tool; requires_cli must be True."""
23+
i = get_integration(self.KEY)
24+
assert i.config["requires_cli"] is True
25+
26+
def test_install_url_is_set(self):
27+
"""install_url must point to the official installation page."""
28+
i = get_integration(self.KEY)
29+
assert i.config["install_url"] == "https://antigravity.google/"
30+
31+
1932
class TestAgyAutoPromote:
2033
"""--ai agy auto-promotes to integration path."""
2134

@@ -36,10 +49,87 @@ def test_agy_setup_warning(self, tmp_path):
3649
from typer.testing import CliRunner
3750
from specify_cli import app
3851

39-
# Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed
52+
# Click >= 8.2 separates stdout and stderr natively
4053
runner = CliRunner()
4154
target = tmp_path / "test-proj2"
4255
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
4356

4457
assert result.exit_code == 0
4558
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
59+
60+
61+
class TestAgyBuildExecArgs:
62+
"""agy non-interactive execution argument building."""
63+
64+
def test_build_exec_args_returns_print_command(self):
65+
"""build_exec_args should return ['agy', '--print', prompt]."""
66+
from specify_cli.integrations import get_integration
67+
i = get_integration("agy")
68+
result = i.build_exec_args("describe my feature")
69+
assert result == ["agy", "--print", "describe my feature"]
70+
71+
def test_build_exec_args_ignores_model(self):
72+
"""agy does not support --model; model param must be ignored."""
73+
from specify_cli.integrations import get_integration
74+
i = get_integration("agy")
75+
result = i.build_exec_args("my prompt", model="gemini-pro")
76+
assert result == ["agy", "--print", "my prompt"]
77+
78+
def test_build_exec_args_ignores_output_json(self):
79+
"""agy does not support JSON output; output_json param must be ignored."""
80+
from specify_cli.integrations import get_integration
81+
i = get_integration("agy")
82+
result = i.build_exec_args("my prompt", output_json=False)
83+
assert result == ["agy", "--print", "my prompt"]
84+
85+
86+
class TestAgyHookCommandNote:
87+
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
88+
89+
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
90+
"""Skills with hook sections should contain the normalization note."""
91+
from specify_cli.integrations import get_integration
92+
from specify_cli.integrations.manifest import IntegrationManifest
93+
94+
i = get_integration("agy")
95+
m = IntegrationManifest("agy", tmp_path)
96+
i.setup(tmp_path, m, script_type="sh")
97+
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
98+
assert specify_skill.exists()
99+
content = specify_skill.read_text(encoding="utf-8")
100+
assert "replace dots" in content, (
101+
"speckit-specify should have dot-to-hyphen hook note"
102+
)
103+
104+
def test_hook_note_not_in_skills_without_hooks(self):
105+
"""Skills without hook sections should not get the note."""
106+
from specify_cli.integrations.agy import AgyIntegration
107+
108+
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
109+
result = AgyIntegration._inject_hook_command_note(content)
110+
assert "replace dots" not in result
111+
112+
def test_hook_note_idempotent(self):
113+
"""Injecting the note twice must not duplicate it."""
114+
from specify_cli.integrations.agy import AgyIntegration
115+
116+
content = (
117+
"---\nname: test\n---\n\n"
118+
"- For each executable hook, output the following based on its flag:\n"
119+
)
120+
once = AgyIntegration._inject_hook_command_note(content)
121+
twice = AgyIntegration._inject_hook_command_note(once)
122+
assert once == twice, "Hook note injection should be idempotent"
123+
124+
def test_hook_note_preserves_indentation(self):
125+
"""The injected note must match the indentation of the target line."""
126+
from specify_cli.integrations.agy import AgyIntegration
127+
128+
content = (
129+
"---\nname: test\n---\n\n"
130+
" - For each executable hook, output the following\n"
131+
)
132+
result = AgyIntegration._inject_hook_command_note(content)
133+
lines = result.splitlines()
134+
note_line = [l for l in lines if "replace dots" in l][0]
135+
assert note_line.startswith(" "), "Note should preserve indentation"

0 commit comments

Comments
 (0)