From 1ac22ec5fda9d39edcac6ab57a464681ef518fbe Mon Sep 17 00:00:00 2001 From: Stanislav Deviatov Date: Fri, 18 Sep 2026 15:33:52 +0200 Subject: [PATCH 1/2] fix(agy): enhance Antigravity CLI compatibility and execution flags Support --model, --output-format json, and opt-in permissions bypass. Position options before prompt and inherit hook normalization from base. Add CLI catalog tags and comprehensive integration tests. --- docs/reference/integrations.md | 2 +- integrations/catalog.json | 4 +- src/specify_cli/integrations/agy/__init__.py | 121 +++------ tests/integrations/test_integration_agy.py | 247 +++++++++++++++---- 4 files changed, 244 insertions(+), 130 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 32310cf81f..2983f5aff8 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -8,7 +8,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | [Alquimia AI](https://docs.alquimia.ai) | `alquimia` | Skills-based integration; installs skills into `.alquimia/skills` and invokes them as `/speckit-` | | [Amp](https://ampcode.com/) | `amp` | | -| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; installs skills into `.agents/skills/` and invokes them as `/speckit-`. In headless non-interactive runs, automatic tool approval via `--dangerously-skip-permissions` can be enabled by setting `SPECKIT_AGY_ALLOW_ALL_TOOLS=1` or `SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS=1`. | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | | | [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` | | [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent | diff --git a/integrations/catalog.json b/integrations/catalog.json index 55b4ec1fa1..06349fa616 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -304,10 +304,10 @@ "id": "agy", "name": "Antigravity", "version": "1.0.0", - "description": "Antigravity IDE skills-based integration", + "description": "Antigravity CLI and IDE skills-based integration", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", - "tags": ["ide", "skills"] + "tags": ["cli", "ide", "skills"] }, "generic": { "id": "generic", diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index bbbbfdefe8..405e9dcf09 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -1,87 +1,60 @@ """Antigravity (agy) integration — skills-based agent. -Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (enforced since v1.20.5). +Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (supported in Antigravity CLI v1.0.0+ and Antigravity IDE v2.0.0+). """ from __future__ import annotations -import re +import os from collections.abc import Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar 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" -) + +def _allow_all_tools() -> bool: + """Return True if agy should run with auto-approved permissions in headless mode. + + Disabled by default for security. Set SPECKIT_AGY_ALLOW_ALL_TOOLS=1 (or + SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS=1) to enable. + """ + for key in ( + "SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", + "SPECKIT_AGY_ALLOW_ALL_TOOLS", + ): + val = os.environ.get(key) + if val is not None and val.strip(): + return val.strip().lower() in ("1", "true", "yes", "on") + return False class AgyIntegration(SkillsIntegration): - """Integration for Antigravity IDE.""" + """Integration for Antigravity CLI and IDE. + + Inherits hook command normalization and post-processing from + SkillsIntegration, ensuring slash commands constructed from dotted hook + names are automatically converted to hyphenated skill invocations. + """ key = "agy" - config = { + config: ClassVar[dict[str, Any]] = { "name": "Antigravity", "folder": ".agents/", "commands_subdir": "skills", "install_url": "https://antigravity.google/", "requires_cli": True, } - registrar_config = { + registrar_config: ClassVar[dict[str, Any]] = { "dir": ".agents/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.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, @@ -93,21 +66,17 @@ def build_exec_args( project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) - # agy does not support JSON output; output_json is ignored. args = [self._resolve_executable()] - # Pass --model before --print so agy can parse it as a flag. - # agy >=1.20 supports: agy --model --print + if _allow_all_tools(): + args.append("--dangerously-skip-permissions") if model: args.extend(["--model", model]) - # Inject --add-dir so agy discovers the project workspace when invoked - # from an arbitrary working directory (e.g. the workflow engine's cwd). - # Without this agy falls back to its own scratch directory and cannot - # locate .agents/skills/, reporting "no active workspace". + if output_json: + args.extend(["--output-format", "json"]) if project_root is not None: args.extend(["--add-dir", str(project_root.resolve())]) - # Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags). - # These MUST be inserted before --print because agy treats every token - # that follows --print as part of the prompt, not as CLI flags. + # Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags), + # positioned before the positional prompt. self._apply_extra_args_env_var(args) args.extend(["--print", prompt]) return args @@ -122,26 +91,12 @@ def setup( import click click.secho( - "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer. " - "Please ensure your agy installation is up to date.", + "Warning: The .agents/ layout requires Antigravity CLI v1.0.0 or newer " + "(or Antigravity IDE v2.0.0 or newer). " + "Please ensure your installation is up to date.", fg="yellow", err=True, ) - 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 + return super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index e668f0586b..fd18c14ae8 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -1,5 +1,9 @@ """Tests for AgyIntegration (Antigravity).""" +from pathlib import Path + +import pytest + from specify_cli.integrations import get_integration from .test_integration_base_skills import SkillsIntegrationTests @@ -39,13 +43,24 @@ def test_integration_agy_creates_skills(self, tmp_path): runner = CliRunner() target = tmp_path / "test-proj" - result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"]) + result = runner.invoke( + app, + [ + "init", + str(target), + "--integration", + "agy", + "--script", + "sh", + "--ignore-agent-tools", + ], + ) assert result.exit_code == 0, f"init --integration agy failed: {result.output}" assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() def test_agy_setup_warning(self, tmp_path): - """Agy integration should print a warning about v1.20.5 requirement during setup.""" + """Agy integration should print a warning about CLI v1.0.0+ / IDE v2.0.0+ requirement during setup.""" from typer.testing import CliRunner from specify_cli import app @@ -53,59 +68,184 @@ def test_agy_setup_warning(self, tmp_path): # Click >= 8.2 separates stdout and stderr natively runner = CliRunner() target = tmp_path / "test-proj2" - result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"]) + result = runner.invoke( + app, + [ + "init", + str(target), + "--integration", + "agy", + "--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 + assert ( + "Warning: The .agents/ layout requires Antigravity CLI v1.0.0 or newer " + "(or Antigravity IDE v2.0.0 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].""" + @pytest.fixture(autouse=True) + def _isolate_env(self, monkeypatch): + """Isolate tests from ambient environment variables.""" + for var in ( + "SPECKIT_AGY_ALLOW_ALL_TOOLS", + "SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", + "SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", + "SPECKIT_INTEGRATION_AGY_EXECUTABLE", + ): + monkeypatch.delenv(var, raising=False) + + def test_build_exec_args_default(self): + """build_exec_args returns ['agy', '--output-format', 'json', '--print', prompt] by default.""" 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_honors_model(self): - """agy >=1.20 supports --model; it must be prepended before --print.""" + assert result == [ + "agy", + "--output-format", + "json", + "--print", + "describe my feature", + ] + + def test_build_exec_args_supports_model(self): + """agy supports --model; model param must be included before --print.""" from specify_cli.integrations import get_integration + i = get_integration("agy") - result = i.build_exec_args("my prompt", model="gemini-pro") - assert result == ["agy", "--model", "gemini-pro", "--print", "my prompt"] + result = i.build_exec_args("my prompt", model="gemini-pro", output_json=False) + assert result == [ + "agy", + "--model", + "gemini-pro", + "--print", + "my prompt", + ] def test_build_exec_args_no_model_flag_when_model_is_none(self): """When model is None, no --model flag should appear in the args.""" from specify_cli.integrations import get_integration + i = get_integration("agy") result = i.build_exec_args("my prompt", model=None) assert "--model" not in result - def test_build_exec_args_ignores_output_json(self): - """agy does not support JSON output; output_json param must be ignored.""" + def test_build_exec_args_supports_model_and_json(self): + """agy supports both --model and --output-format json simultaneously.""" + from specify_cli.integrations import get_integration + + i = get_integration("agy") + result = i.build_exec_args("my prompt", model="gemini-pro", output_json=True) + assert result == [ + "agy", + "--model", + "gemini-pro", + "--output-format", + "json", + "--print", + "my prompt", + ] + + def test_build_exec_args_honors_output_json_false(self): + """agy supports output_json=False; --output-format json must be omitted.""" + 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", + ] + + @pytest.mark.parametrize( + ("env_var", "value"), + [ + ("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1"), + ("SPECKIT_AGY_ALLOW_ALL_TOOLS", "true"), + ("SPECKIT_AGY_ALLOW_ALL_TOOLS", "yes"), + ("SPECKIT_AGY_ALLOW_ALL_TOOLS", "on"), + ("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", "1"), + ("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", "true"), + ], + ) + def test_build_exec_args_enables_skip_permissions_via_env( + self, monkeypatch, env_var, value + ): + """Setting permissions env var to truthy values enables --dangerously-skip-permissions.""" from specify_cli.integrations import get_integration + + monkeypatch.setenv(env_var, value) i = get_integration("agy") result = i.build_exec_args("my prompt", output_json=False) + assert result == [ + "agy", + "--dangerously-skip-permissions", + "--print", + "my prompt", + ] + + @pytest.mark.parametrize( + "value", + ["0", "false", "no", "off", "", "random"], + ) + def test_build_exec_args_disables_skip_permissions_with_falsy_values( + self, monkeypatch, value + ): + """Falsy or invalid env values must not enable --dangerously-skip-permissions.""" + from specify_cli.integrations import get_integration + + monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", value) + i = get_integration("agy") + result = i.build_exec_args("my prompt", output_json=False) + assert "--dangerously-skip-permissions" not in result assert result == ["agy", "--print", "my prompt"] - def test_build_exec_args_extra_args_before_print(self, monkeypatch): - """SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be inserted BEFORE --print. + def test_build_exec_args_env_empty_fallthrough(self, monkeypatch): + """Empty string in integration-specific var must fall through to generic var.""" + from specify_cli.integrations import get_integration + + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", " ") + monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1") + i = get_integration("agy") + result = i.build_exec_args("my prompt", output_json=False) + assert result == [ + "agy", + "--dangerously-skip-permissions", + "--print", + "my prompt", + ] + + def test_build_exec_args_env_precedence(self, monkeypatch): + """Integration-specific variable takes precedence over generic variable.""" + from specify_cli.integrations import get_integration - agy treats every token after --print as part of the prompt string, - not as CLI flags. Appending flags after --print (the previous - behaviour) caused them to be silently absorbed into the prompt. + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", "0") + monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1") + i = get_integration("agy") + result = i.build_exec_args("my prompt", output_json=False) + assert "--dangerously-skip-permissions" not in result - See issue #4480. - """ + def test_build_exec_args_honors_extra_args(self, monkeypatch): + """SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be positioned before --print.""" from specify_cli.integrations import get_integration + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose") i = get_integration("agy") - result = i.build_exec_args("my prompt") - # --verbose must appear before --print - assert result.index("--verbose") < result.index("--print") - assert result == ["agy", "--verbose", "--print", "my prompt"] + assert i.build_exec_args("my prompt", output_json=False) == [ + "agy", + "--verbose", + "--print", + "my prompt", + ] def test_build_exec_args_add_dir_for_workspace(self, tmp_path): """--add-dir must be injected before --print when project_root is given. @@ -116,8 +256,11 @@ def test_build_exec_args_add_dir_for_workspace(self, tmp_path): See issue #4480. """ from specify_cli.integrations import get_integration + i = get_integration("agy") - result = i.build_exec_args("my prompt", project_root=tmp_path) + result = i.build_exec_args( + "my prompt", project_root=tmp_path, output_json=False + ) assert "--add-dir" in result add_dir_idx = result.index("--add-dir") print_idx = result.index("--print") @@ -130,12 +273,13 @@ def test_build_exec_args_relative_project_root(self): Passing a relative path to --add-dir breaks agy when the subprocess also changes cwd to that same relative path. """ - from pathlib import Path - from specify_cli.integrations import get_integration + i = get_integration("agy") rel_path = Path("my_relative_dir") - result = i.build_exec_args("my prompt", project_root=rel_path) + result = i.build_exec_args( + "my prompt", project_root=rel_path, output_json=False + ) assert "--add-dir" in result add_dir_idx = result.index("--add-dir") assert result[add_dir_idx + 1] == str(rel_path.resolve()) @@ -143,29 +287,37 @@ def test_build_exec_args_relative_project_root(self): def test_build_exec_args_no_add_dir_when_project_root_is_none(self): """When project_root is None, --add-dir must not appear.""" from specify_cli.integrations import get_integration + i = get_integration("agy") result = i.build_exec_args("my prompt", project_root=None) assert "--add-dir" not in result def test_build_exec_args_combined_flag_order(self, monkeypatch, tmp_path): - """When model, project_root, and EXTRA_ARGS are all set, order must be: - agy --model --add-dir --print . - """ + """When permissions, model, project_root, and EXTRA_ARGS are all set, all must appear before --print.""" from specify_cli.integrations import get_integration - monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--dangerously-skip-permissions") + + monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1") + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--custom-flag") i = get_integration("agy") - result = i.build_exec_args("hello", model="claude-3", project_root=tmp_path) + result = i.build_exec_args( + "hello", model="claude-3", project_root=tmp_path, output_json=True + ) assert result[0] == "agy" - assert "--model" in result - assert "--add-dir" in result - assert "--dangerously-skip-permissions" in result print_idx = result.index("--print") - for flag in ("--model", "--add-dir", "--dangerously-skip-permissions"): + for flag in ( + "--dangerously-skip-permissions", + "--model", + "--output-format", + "--add-dir", + "--custom-flag", + ): + assert flag in result assert result.index(flag) < print_idx, f"{flag} must appear before --print" assert result[-1] == "hello" def test_build_exec_args_honors_executable_override(self, monkeypatch): from specify_cli.integrations import get_integration + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy") i = get_integration("agy") assert i.build_exec_args("my prompt")[0] == "/custom/agy" @@ -182,9 +334,13 @@ def test_dispatch_command_forwards_project_root_as_add_dir(self, tmp_path): mock_result.stdout = "" mock_result.stderr = "" - with patch("specify_cli.integrations.base.shutil.which", return_value="agy"), \ - patch("subprocess.run", return_value=mock_result) as mock_run: - result = i.dispatch_command("speckit.plan", stream=False, project_root=tmp_path) + with ( + patch("specify_cli.integrations.base.shutil.which", return_value="agy"), + patch("subprocess.run", return_value=mock_result) as mock_run, + ): + result = i.dispatch_command( + "speckit.plan", stream=False, project_root=tmp_path + ) assert result["exit_code"] == 0 argv = mock_run.call_args[0][0] @@ -192,9 +348,12 @@ def test_dispatch_command_forwards_project_root_as_add_dir(self, tmp_path): assert argv[argv.index("--add-dir") + 1] == str(tmp_path) - class TestAgyHookCommandNote: - """Verify dot-to-hyphen normalization note is injected into hook sections.""" + """Verify dot-to-hyphen normalization note is injected into hook sections. + + Note: AgyIntegration inherits _inject_hook_command_note and + post_process_skill_content directly from SkillsIntegration. + """ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): """Skills with hook sections should contain the normalization note.""" @@ -241,5 +400,5 @@ def test_hook_note_preserves_indentation(self): ) result = AgyIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = [ln for ln in lines if "replace dots" in ln][0] + note_line = next(ln for ln in lines if "replace dots" in ln) assert note_line.startswith(" "), "Note should preserve indentation" From 11ecd28a5dddbe8d4126988a2c52ba07e017c485 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatov Date: Fri, 18 Sep 2026 16:37:37 +0200 Subject: [PATCH 2/2] fix(agy): refine CLI flag ordering, version guidance, and test coverage Clarify CLI and IDE version tracks per review feedback on PR #4612. Harden workspace path resolution and assert manifest hash tracking. Isolate environment state and verify exact argument ordering. --- src/specify_cli/integrations/agy/__init__.py | 16 ++-- tests/integrations/test_integration_agy.py | 89 ++++++++------------ 2 files changed, 46 insertions(+), 59 deletions(-) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 405e9dcf09..f9b10f43a2 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -1,6 +1,7 @@ """Antigravity (agy) integration — skills-based agent. -Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (supported in Antigravity CLI v1.0.0+ and Antigravity IDE v2.0.0+). +Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout +(supported in Antigravity CLI v1.0.0+ and Antigravity IDE v2.0.0+). """ from __future__ import annotations @@ -40,7 +41,7 @@ class AgyIntegration(SkillsIntegration): names are automatically converted to hyphenated skill invocations. """ - key = "agy" + key: ClassVar[str] = "agy" config: ClassVar[dict[str, Any]] = { "name": "Antigravity", "folder": ".agents/", @@ -73,10 +74,13 @@ def build_exec_args( args.extend(["--model", model]) if output_json: args.extend(["--output-format", "json"]) - if project_root is not None: - args.extend(["--add-dir", str(project_root.resolve())]) - # Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags), - # positioned before the positional prompt. + if project_root is not None and str(project_root).strip(): + # agy requires an active workspace directory to discover skills under + # .agents/skills/ when invoked from workflow directories (see issue #4480, PR #4481). + args.extend(["--add-dir", str(Path(project_root).resolve())]) + # Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags). + # Positioned before --print because agy consumes all trailing arguments + # as prompt text (see #4480). self._apply_extra_args_env_var(args) args.extend(["--print", prompt]) return args diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index fd18c14ae8..b3681ee8fc 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -1,5 +1,6 @@ """Tests for AgyIntegration (Antigravity).""" +import hashlib from pathlib import Path import pytest @@ -104,8 +105,6 @@ def _isolate_env(self, monkeypatch): def test_build_exec_args_default(self): """build_exec_args returns ['agy', '--output-format', 'json', '--print', prompt] by default.""" - from specify_cli.integrations import get_integration - i = get_integration("agy") result = i.build_exec_args("describe my feature") assert result == [ @@ -118,8 +117,6 @@ def test_build_exec_args_default(self): def test_build_exec_args_supports_model(self): """agy supports --model; model param must be included before --print.""" - from specify_cli.integrations import get_integration - i = get_integration("agy") result = i.build_exec_args("my prompt", model="gemini-pro", output_json=False) assert result == [ @@ -132,16 +129,12 @@ def test_build_exec_args_supports_model(self): def test_build_exec_args_no_model_flag_when_model_is_none(self): """When model is None, no --model flag should appear in the args.""" - from specify_cli.integrations import get_integration - i = get_integration("agy") result = i.build_exec_args("my prompt", model=None) assert "--model" not in result def test_build_exec_args_supports_model_and_json(self): """agy supports both --model and --output-format json simultaneously.""" - from specify_cli.integrations import get_integration - i = get_integration("agy") result = i.build_exec_args("my prompt", model="gemini-pro", output_json=True) assert result == [ @@ -156,8 +149,6 @@ def test_build_exec_args_supports_model_and_json(self): def test_build_exec_args_honors_output_json_false(self): """agy supports output_json=False; --output-format json must be omitted.""" - from specify_cli.integrations import get_integration - i = get_integration("agy") result = i.build_exec_args("my prompt", output_json=False) assert result == [ @@ -181,8 +172,6 @@ def test_build_exec_args_enables_skip_permissions_via_env( self, monkeypatch, env_var, value ): """Setting permissions env var to truthy values enables --dangerously-skip-permissions.""" - from specify_cli.integrations import get_integration - monkeypatch.setenv(env_var, value) i = get_integration("agy") result = i.build_exec_args("my prompt", output_json=False) @@ -201,19 +190,16 @@ def test_build_exec_args_disables_skip_permissions_with_falsy_values( self, monkeypatch, value ): """Falsy or invalid env values must not enable --dangerously-skip-permissions.""" - from specify_cli.integrations import get_integration - monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", value) i = get_integration("agy") result = i.build_exec_args("my prompt", output_json=False) assert "--dangerously-skip-permissions" not in result assert result == ["agy", "--print", "my prompt"] - def test_build_exec_args_env_empty_fallthrough(self, monkeypatch): - """Empty string in integration-specific var must fall through to generic var.""" - from specify_cli.integrations import get_integration - - monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", " ") + @pytest.mark.parametrize("empty_val", ["", " "]) + def test_build_exec_args_env_empty_fallthrough(self, monkeypatch, empty_val): + """Empty string or whitespace in integration-specific var must fall through to generic var.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", empty_val) monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1") i = get_integration("agy") result = i.build_exec_args("my prompt", output_json=False) @@ -226,8 +212,6 @@ def test_build_exec_args_env_empty_fallthrough(self, monkeypatch): def test_build_exec_args_env_precedence(self, monkeypatch): """Integration-specific variable takes precedence over generic variable.""" - from specify_cli.integrations import get_integration - monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_ALLOW_ALL_TOOLS", "0") monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1") i = get_integration("agy") @@ -236,8 +220,6 @@ def test_build_exec_args_env_precedence(self, monkeypatch): def test_build_exec_args_honors_extra_args(self, monkeypatch): """SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be positioned before --print.""" - from specify_cli.integrations import get_integration - monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose") i = get_integration("agy") assert i.build_exec_args("my prompt", output_json=False) == [ @@ -253,10 +235,8 @@ def test_build_exec_args_add_dir_for_workspace(self, tmp_path): Without --add-dir, agy cannot locate .agents/skills/ and reports 'no active workspace', ignoring installed Spec Kit skills entirely. - See issue #4480. + See issue #4480 (PR #4481). """ - from specify_cli.integrations import get_integration - i = get_integration("agy") result = i.build_exec_args( "my prompt", project_root=tmp_path, output_json=False @@ -265,7 +245,7 @@ def test_build_exec_args_add_dir_for_workspace(self, tmp_path): add_dir_idx = result.index("--add-dir") print_idx = result.index("--print") assert add_dir_idx < print_idx, "--add-dir must come before --print" - assert result[add_dir_idx + 1] == str(tmp_path) + assert result[add_dir_idx + 1] == str(tmp_path.resolve()) def test_build_exec_args_relative_project_root(self): """Relative project_root must be resolved to an absolute path. @@ -273,8 +253,6 @@ def test_build_exec_args_relative_project_root(self): Passing a relative path to --add-dir breaks agy when the subprocess also changes cwd to that same relative path. """ - from specify_cli.integrations import get_integration - i = get_integration("agy") rel_path = Path("my_relative_dir") result = i.build_exec_args( @@ -284,40 +262,38 @@ def test_build_exec_args_relative_project_root(self): add_dir_idx = result.index("--add-dir") assert result[add_dir_idx + 1] == str(rel_path.resolve()) - def test_build_exec_args_no_add_dir_when_project_root_is_none(self): - """When project_root is None, --add-dir must not appear.""" - from specify_cli.integrations import get_integration - + @pytest.mark.parametrize("empty_root", [None, "", " "]) + def test_build_exec_args_no_add_dir_when_project_root_is_empty(self, empty_root): + """When project_root is None or empty/whitespace, --add-dir must not appear.""" i = get_integration("agy") - result = i.build_exec_args("my prompt", project_root=None) + result = i.build_exec_args("my prompt", project_root=empty_root) assert "--add-dir" not in result def test_build_exec_args_combined_flag_order(self, monkeypatch, tmp_path): - """When permissions, model, project_root, and EXTRA_ARGS are all set, all must appear before --print.""" - from specify_cli.integrations import get_integration - + """When permissions, model, output_json, project_root, and EXTRA_ARGS + are all set, all must appear before --print in expected order. + """ monkeypatch.setenv("SPECKIT_AGY_ALLOW_ALL_TOOLS", "1") monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--custom-flag") i = get_integration("agy") result = i.build_exec_args( "hello", model="claude-3", project_root=tmp_path, output_json=True ) - assert result[0] == "agy" - print_idx = result.index("--print") - for flag in ( + assert result == [ + "agy", "--dangerously-skip-permissions", "--model", + "claude-3", "--output-format", + "json", "--add-dir", + str(tmp_path.resolve()), "--custom-flag", - ): - assert flag in result - assert result.index(flag) < print_idx, f"{flag} must appear before --print" - assert result[-1] == "hello" + "--print", + "hello", + ] def test_build_exec_args_honors_executable_override(self, monkeypatch): - from specify_cli.integrations import get_integration - monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy") i = get_integration("agy") assert i.build_exec_args("my prompt")[0] == "/custom/agy" @@ -326,8 +302,6 @@ def test_dispatch_command_forwards_project_root_as_add_dir(self, tmp_path): """dispatch_command must pass project_root to build_exec_args so --add-dir is included.""" from unittest.mock import MagicMock, patch - from specify_cli.integrations import get_integration - i = get_integration("agy") mock_result = MagicMock() mock_result.returncode = 0 @@ -345,7 +319,9 @@ def test_dispatch_command_forwards_project_root_as_add_dir(self, tmp_path): assert result["exit_code"] == 0 argv = mock_run.call_args[0][0] assert "--add-dir" in argv - assert argv[argv.index("--add-dir") + 1] == str(tmp_path) + assert argv[argv.index("--add-dir") + 1] == str(tmp_path.resolve()) + assert "--output-format" in argv + assert argv[argv.index("--output-format") + 1] == "json" class TestAgyHookCommandNote: @@ -356,19 +332,25 @@ class TestAgyHookCommandNote: """ 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 + """Skills with hook sections should contain the normalization note and valid manifest hashes.""" from specify_cli.integrations.manifest import IntegrationManifest i = get_integration("agy") m = IntegrationManifest("agy", tmp_path) - i.setup(tmp_path, m, script_type="sh") + created = 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" ) + rel_key = ".agents/skills/speckit-specify/SKILL.md" + assert rel_key in m.files + assert ( + m.files[rel_key] == hashlib.sha256(specify_skill.read_bytes()).hexdigest() + ) + assert m.check_modified() == [] + assert len(created) > 0 def test_hook_note_not_in_skills_without_hooks(self): """Skills without hook sections should not get the note.""" @@ -400,5 +382,6 @@ def test_hook_note_preserves_indentation(self): ) result = AgyIntegration._inject_hook_command_note(content) lines = result.splitlines() - note_line = next(ln for ln in lines if "replace dots" in ln) + note_line = next((ln for ln in lines if "replace dots" in ln), None) + assert note_line is not None, "Hook note line should be present" assert note_line.startswith(" "), "Note should preserve indentation"