mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(agy): enhance Google Antigravity CLI integration (#2689)
* 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 * fix(agy): add --ignore-agent-tools to TestAgyAutoPromote tests Tests verify file layout and setup warnings, not CLI presence. agy requires_cli=True causes CI failures when agy is not installed.
This commit is contained in:
@@ -5,6 +5,7 @@ Antigravity uses ``.agents/skills/speckit-<name>/SKILL.md`` layout (enforced sin
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -13,6 +14,15 @@ from ..base import SkillsIntegration
|
||||
if TYPE_CHECKING:
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so agy maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
# Without this, agy emits ``/speckit.git.commit`` (which does not
|
||||
# resolve) instead of ``/speckit-git-commit``.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
class AgyIntegration(SkillsIntegration):
|
||||
@@ -23,8 +33,8 @@ class AgyIntegration(SkillsIntegration):
|
||||
"name": "Antigravity",
|
||||
"folder": ".agents/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
"install_url": "https://antigravity.google/",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".agents/skills",
|
||||
@@ -34,6 +44,54 @@ class AgyIntegration(SkillsIntegration):
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject the dot-to-hyphen hook command note."""
|
||||
return self._inject_hook_command_note(content)
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# agy does not support --model or JSON output; both params are ignored
|
||||
return ["agy", "--print", prompt]
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -49,4 +107,21 @@ class AgyIntegration(SkillsIntegration):
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
for path in created:
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content = path.read_bytes().decode("utf-8")
|
||||
updated = self.post_process_skill_content(content)
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for AgyIntegration (Antigravity)."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
@@ -12,10 +14,21 @@ class TestAgyIntegration(SkillsIntegrationTests):
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration(self.KEY)
|
||||
skills_opts = [o for o in i.options() if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0
|
||||
|
||||
def test_requires_cli_is_true(self):
|
||||
"""agy is a CLI tool; requires_cli must be True."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["requires_cli"] is True
|
||||
|
||||
def test_install_url_is_set(self):
|
||||
"""install_url must point to the official installation page."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["install_url"] == "https://antigravity.google/"
|
||||
|
||||
|
||||
class TestAgyAutoPromote:
|
||||
"""--ai agy auto-promotes to integration path."""
|
||||
|
||||
@@ -26,7 +39,7 @@ class TestAgyAutoPromote:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -36,10 +49,87 @@ class TestAgyAutoPromote:
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed
|
||||
# Click >= 8.2 separates stdout and stderr natively
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
|
||||
|
||||
class TestAgyBuildExecArgs:
|
||||
"""agy non-interactive execution argument building."""
|
||||
|
||||
def test_build_exec_args_returns_print_command(self):
|
||||
"""build_exec_args should return ['agy', '--print', prompt]."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("describe my feature")
|
||||
assert result == ["agy", "--print", "describe my feature"]
|
||||
|
||||
def test_build_exec_args_ignores_model(self):
|
||||
"""agy does not support --model; model param must be ignored."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("my prompt", model="gemini-pro")
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
def test_build_exec_args_ignores_output_json(self):
|
||||
"""agy does not support JSON output; output_json param must be ignored."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("my prompt", output_json=False)
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
|
||||
class TestAgyHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills with hook sections should contain the normalization note."""
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
i = get_integration("agy")
|
||||
m = IntegrationManifest("agy", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice must not duplicate it."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = AgyIntegration._inject_hook_command_note(content)
|
||||
twice = AgyIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note must match the indentation of the target line."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
Reference in New Issue
Block a user