diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index d62bafad40..244f95e4f0 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import TYPE_CHECKING, Any @@ -13,6 +14,15 @@ 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 @@ def setup( 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 diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b95caf3bee..78014ce814 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -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.""" @@ -36,10 +49,87 @@ def test_agy_setup_warning(self, tmp_path): 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"]) 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"