diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index a07415856e..93a69db83d 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -87,14 +87,91 @@ def build_exec_args( *, model: str | None = None, output_json: bool = True, + project_root: Path | None = None, ) -> list[str] | None: - # agy does not support --model or JSON output; both params are ignored - args = [self._resolve_executable(), "--print", prompt] - # Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags), - # appended after the positional prompt like the devin integration. + # 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 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 project_root is not None: + args.extend(["--add-dir", str(project_root)]) + # 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. self._apply_extra_args_env_var(args) + args.extend(["--print", prompt]) return args + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch a Spec Kit command through agy. + + Overrides the base implementation solely to thread *project_root* + into :meth:`build_exec_args` as ``--add-dir``. Without this, + ``agy`` is launched without a workspace hint and cannot locate + ``.agents/skills/``, falling back to its own scratch directory + and reporting *"no active workspace"*. + + All other behaviour is identical to + :meth:`~specify_cli.integrations.base.IntegrationBase.dispatch_command`. + """ + import shutil + import subprocess + + prompt = self.build_command_invocation(command_name, args) + exec_args = self.build_exec_args( + prompt, + model=model, + output_json=not stream, + project_root=project_root, + ) + + if exec_args is None: # pragma: no cover — build_exec_args always returns a list + raise NotImplementedError( + f"Integration {self.key!r} does not support CLI dispatch. " + f"Override build_exec_args() to enable it." + ) + + resolved = shutil.which(exec_args[0]) + if resolved: + exec_args = [resolved, *exec_args[1:]] + + cwd = str(project_root) if project_root else None + + if stream: + try: + result = subprocess.run(exec_args, text=True, cwd=cwd) + except KeyboardInterrupt: + return {"exit_code": 130, "stdout": "", "stderr": "Interrupted by user"} + return {"exit_code": result.returncode, "stdout": "", "stderr": ""} + + result = subprocess.run( + exec_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + def setup( self, project_root: Path, diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 7b5580ab5a..0f56bc8bd4 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -67,12 +67,19 @@ def test_build_exec_args_returns_print_command(self): 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.""" + def test_build_exec_args_honors_model(self): + """agy >=1.20 supports --model; it must be prepended 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", "--print", "my prompt"] + 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.""" @@ -81,19 +88,63 @@ def test_build_exec_args_ignores_output_json(self): result = i.build_exec_args("my prompt", output_json=False) assert result == ["agy", "--print", "my prompt"] - def test_build_exec_args_honors_extra_args(self, monkeypatch): - """SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt. + def test_build_exec_args_extra_args_before_print(self, monkeypatch): + """SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be inserted BEFORE --print. + + 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. - agy previously skipped _apply_extra_args_env_var entirely, so the - documented per-integration extra-args hook was silently ignored - (same class as the merged cursor-agent fix #3265). + See issue #4480. """ 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") == [ - "agy", "--print", "my prompt", "--verbose", - ] + 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"] + + def test_build_exec_args_add_dir_for_workspace(self, tmp_path): + """--add-dir must be injected before --print when project_root is given. + + Without --add-dir, agy cannot locate .agents/skills/ and reports + 'no active workspace', ignoring installed Spec Kit skills entirely. + + 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) + assert "--add-dir" in result + 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) + + 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 . + """ + from specify_cli.integrations import get_integration + monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--dangerously-skip-permissions") + i = get_integration("agy") + result = i.build_exec_args("hello", model="claude-3", project_root=tmp_path) + 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"): + 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 @@ -102,6 +153,7 @@ def test_build_exec_args_honors_executable_override(self, monkeypatch): assert i.build_exec_args("my prompt")[0] == "/custom/agy" + class TestAgyHookCommandNote: """Verify dot-to-hyphen normalization note is injected into hook sections."""