From 0a10d6617521b4fb84683c0dd9d4b610406564ff Mon Sep 17 00:00:00 2001 From: timoyan Date: Thu, 10 Sep 2026 02:30:30 +0800 Subject: [PATCH 1/4] fix(agy): support --model, --add-dir workspace, and thread project_root through base dispatch_command --- src/specify_cli/integrations/agy/__init__.py | 21 ++++- src/specify_cli/integrations/base.py | 5 + .../integrations/codex/__init__.py | 2 + .../integrations/copilot/__init__.py | 1 + .../integrations/cursor_agent/__init__.py | 2 + .../integrations/devin/__init__.py | 2 + .../integrations/docker_agent/__init__.py | 3 + .../integrations/droid/__init__.py | 2 + src/specify_cli/integrations/dsh/__init__.py | 2 + .../integrations/goose/__init__.py | 2 + src/specify_cli/integrations/grok/__init__.py | 2 + .../integrations/hermes/__init__.py | 1 + src/specify_cli/integrations/muse/__init__.py | 2 + src/specify_cli/integrations/omp/__init__.py | 2 + .../integrations/opencode/__init__.py | 2 + .../integrations/rovodev/__init__.py | 1 + .../workflows/steps/command/__init__.py | 5 +- .../workflows/steps/prompt/__init__.py | 15 ++- tests/integrations/test_base.py | 1 + tests/integrations/test_integration_agy.py | 94 ++++++++++++++++--- tests/test_workflows.py | 36 +++++++ 21 files changed, 181 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 958f922c12..d513740bcf 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -90,13 +90,26 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: - # agy does not support --model or JSON output; both params are ignored self.validate_runtime_config(integration_args, integration_options) - 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 setup( diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 529803922e..6aab9803a7 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -248,6 +248,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive execution. @@ -410,6 +411,7 @@ def dispatch_command( output_json=not stream, integration_args=integration_args, integration_options=integration_options, + project_root=project_root, ) if exec_args is None: @@ -1067,6 +1069,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): @@ -1161,6 +1164,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): @@ -1633,6 +1637,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) if not self.config or not self.config.get("requires_cli"): diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 8d80e0537f..d88628b3ff 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -51,6 +52,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: # Codex uses ``codex exec "prompt"`` for non-interactive mode. # Resolve argv[0] via the shared executable resolver so operators can diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 74c6589bb8..1a8285d7c4 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -309,6 +309,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) # GitHub Copilot CLI uses ``copilot -p "prompt"`` for diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index eb28286a9a..87b00236d8 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -68,6 +69,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``cursor-agent`` execution. diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index 7391aaea05..7c434ce96e 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -63,6 +64,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build non-interactive CLI args for Devin for Terminal. diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py index c823dc96a7..939e35555b 100644 --- a/src/specify_cli/integrations/docker_agent/__init__.py +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from pathlib import Path import os import shlex @@ -85,6 +86,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build a headless Docker Agent invocation with an agent config.""" self.validate_runtime_config(integration_args, integration_options) @@ -152,6 +154,7 @@ def validate_runtime_config( self, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> None: """Validate Docker Agent's per-step agent reference and CLI options.""" runtime_args = list(integration_args or ()) diff --git a/src/specify_cli/integrations/droid/__init__.py b/src/specify_cli/integrations/droid/__init__.py index 7e41d76b02..fb165f5309 100644 --- a/src/specify_cli/integrations/droid/__init__.py +++ b/src/specify_cli/integrations/droid/__init__.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -95,6 +96,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``droid`` execution. diff --git a/src/specify_cli/integrations/dsh/__init__.py b/src/specify_cli/integrations/dsh/__init__.py index 381d4c08ce..c6c16e9ee5 100644 --- a/src/specify_cli/integrations/dsh/__init__.py +++ b/src/specify_cli/integrations/dsh/__init__.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -51,6 +52,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build non-interactive CLI args for DSH. diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index d02537f29a..76df5d6714 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -1,6 +1,7 @@ """Goose integration — open source AI agent (Agentic AI Foundation).""" from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -32,6 +33,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``goose`` execution. diff --git a/src/specify_cli/integrations/grok/__init__.py b/src/specify_cli/integrations/grok/__init__.py index ca078a30d3..8703e1b73a 100644 --- a/src/specify_cli/integrations/grok/__init__.py +++ b/src/specify_cli/integrations/grok/__init__.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -40,6 +41,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build CLI arguments for non-interactive ``grok`` execution. diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1954d968f8..582a7bf66e 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -275,6 +275,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build Hermes CLI invocation for programmatic dispatch. diff --git a/src/specify_cli/integrations/muse/__init__.py b/src/specify_cli/integrations/muse/__init__.py index 14d9d53da0..327dac5072 100644 --- a/src/specify_cli/integrations/muse/__init__.py +++ b/src/specify_cli/integrations/muse/__init__.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -55,6 +56,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: # Muse Code uses ``muse exec ""`` for non-interactive mode. # Resolve argv[0] via the shared executable resolver so operators can diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py index 2cf2f7b03f..b2088593d2 100644 --- a/src/specify_cli/integrations/omp/__init__.py +++ b/src/specify_cli/integrations/omp/__init__.py @@ -1,6 +1,7 @@ """Oh My Pi (omp) coding agent integration.""" from __future__ import annotations +from pathlib import Path from collections.abc import Mapping, Sequence from typing import Any @@ -33,6 +34,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: # Diverges from MarkdownIntegration.build_exec_args because OMP's # CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index cf7041865b..3ef10bf055 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,6 +1,7 @@ """opencode integration.""" from collections.abc import Mapping, Sequence +from pathlib import Path from typing import Any from ..base import MarkdownIntegration @@ -48,6 +49,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable(), "run"] diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index f8ba459100..198347be3d 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -66,6 +66,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: """Build non-interactive ACLI args for RovoDev. diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py index f242c74a7d..439007112f 100644 --- a/src/specify_cli/workflows/steps/command/__init__.py +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -233,11 +233,14 @@ def _try_dispatch( impl.validate_runtime_config(integration_args, integration_options) + project_root = Path(context.project_root) if context.project_root else None + # Build sample args for fallback executable detection when impl.key is not executable. exec_args = impl.build_exec_args( "test", integration_args=integration_args, integration_options=integration_options, + project_root=project_root, ) # Check if the CLI tool is actually installed. @@ -248,8 +251,6 @@ def _try_dispatch( if cli_path is None and fallback_cli_path is None: return None - project_root = Path(context.project_root) if context.project_root else None - try: return impl.dispatch_command( command, diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py index 3bb9a2708c..9c2e683bf4 100644 --- a/src/specify_cli/workflows/steps/prompt/__init__.py +++ b/src/specify_cli/workflows/steps/prompt/__init__.py @@ -200,7 +200,16 @@ def _try_dispatch( if impl is None: return None - exec_args = impl.build_exec_args(prompt, model=model, output_json=False) + project_root = ( + Path(context.project_root) if context.project_root else Path.cwd() + ) + + exec_args = impl.build_exec_args( + prompt, + model=model, + output_json=False, + project_root=project_root, + ) # Check if the CLI tool is actually installed. # Try the integration key first (covers most agents), then fall back @@ -227,10 +236,6 @@ def _try_dispatch( import subprocess - project_root = ( - Path(context.project_root) if context.project_root else Path.cwd() - ) - try: result = subprocess.run( exec_args, diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 30938dd7b7..6383cead59 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -68,6 +68,7 @@ def test_all_builtin_exec_builders_accept_runtime_config(self): parameters = inspect.signature(integration.build_exec_args).parameters assert "integration_args" in parameters, key assert "integration_options" in parameters, key + assert "project_root" in parameters, key def test_unsupported_exec_builders_reject_runtime_config_directly(self): from specify_cli.integrations import INTEGRATION_REGISTRY diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 7b5580ab5a..b39e8e97da 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 @@ -101,6 +152,27 @@ def test_build_exec_args_honors_executable_override(self, monkeypatch): i = get_integration("agy") assert i.build_exec_args("my prompt")[0] == "/custom/agy" + 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 patch, MagicMock + from specify_cli.integrations import get_integration + + i = get_integration("agy") + mock_result = MagicMock() + mock_result.returncode = 0 + 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) + + 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) + + class TestAgyHookCommandNote: """Verify dot-to-hyphen normalization note is injected into hook sections.""" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 2c7141e954..bd7ab2340d 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2087,6 +2087,42 @@ def fail_if_called(*args, **kwargs): assert result.status is StepStatus.FAILED, bad assert "'timeout' must be a positive number" in (result.error or ""), bad + def test_try_dispatch_threads_project_root(self): + """PromptStep._try_dispatch must pass context.project_root to build_exec_args.""" + from pathlib import Path + from unittest.mock import MagicMock, patch + + from specify_cli.workflows.base import StepContext + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + ctx = StepContext(project_root="/fake/project/root", default_integration="dummy") + + mock_impl = MagicMock() + mock_impl.key = "dummy" + mock_impl.build_exec_args.return_value = ["dummy", "args"] + mock_get_integration = MagicMock(return_value=mock_impl) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch("specify_cli.integrations.get_integration", mock_get_integration), \ + patch("specify_cli.workflows.steps.prompt.shutil.which", return_value="/opt/dummy"), \ + patch("subprocess.run", return_value=mock_result): + step.execute( + {"id": "p", "type": "prompt", "prompt": "hi", "integration": "dummy"}, + ctx, + ) + + mock_impl.build_exec_args.assert_called_once_with( + "hi", + model=None, + output_json=False, + project_root=Path("/fake/project/root"), + ) + class TestShellStep: """Test the shell step type.""" From e49845b0cec4bcd5ffc01a05b3a5fc49a4ccdb6b Mon Sep 17 00:00:00 2001 From: timoyan Date: Thu, 10 Sep 2026 11:29:19 +0800 Subject: [PATCH 2/4] fix(powershell,tests): enforce UTF-8 in common.ps1 and isolate integration env vars in tests - scripts/powershell/common.ps1: Enforce [Console]::OutputEncoding and $OutputEncoding as UTF-8. On Windows hosts with non-UTF-8/OEM code pages (such as CP950/Big5), PowerShell stdout would otherwise output non-ASCII characters (e.g. arrows in templates) using the system code page, causing UnicodeDecodeError when captured by external runners. - tests/conftest.py: Drop ambient SPECKIT_INTEGRATION_*_EXTRA_ARGS and SPECKIT_INTEGRATION_*_EXECUTABLE environment variables in the autouse _strip_specify_env fixture to prevent host configuration leakage into integration argument-building tests. Assisted-by: Antigravity (model: Gemini 3.8 Flash, supervised) --- scripts/powershell/common.ps1 | 3 +++ tests/conftest.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index bb61f623bb..ee9c0aec0c 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -1,6 +1,9 @@ #!/usr/bin/env pwsh # Common PowerShell functions analogous to common.sh +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + # Find repository root by searching upward for .specify directory # This is the primary marker for spec-kit projects function Find-SpecifyRoot { diff --git a/tests/conftest.py b/tests/conftest.py index 94fb8c31b0..06c038a952 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,11 @@ def _strip_specify_env(monkeypatch): that wants an override sets it explicitly via monkeypatch afterwards.""" for key in [k for k in os.environ if k.startswith("SPECIFY_")]: monkeypatch.delenv(key, raising=False) + for key in list(os.environ): + if key.startswith("SPECKIT_INTEGRATION_") and ( + key.endswith("_EXTRA_ARGS") or key.endswith("_EXECUTABLE") + ): + monkeypatch.delenv(key, raising=False) @pytest.fixture From 2cd15edf78bf1d795e2d9cf471bc1363b0355b92 Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 10 Sep 2026 22:47:35 +0800 Subject: [PATCH 3/4] Remove UTF8 output encoding settings Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/powershell/common.ps1 | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index ee9c0aec0c..d0489fdf70 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -1,8 +1,6 @@ #!/usr/bin/env pwsh # Common PowerShell functions analogous to common.sh -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$OutputEncoding = [System.Text.Encoding]::UTF8 # Find repository root by searching upward for .specify directory # This is the primary marker for spec-kit projects From 721c02a969b10b464fe667434cfa0e7d77e98838 Mon Sep 17 00:00:00 2001 From: timoyan Date: Thu, 10 Sep 2026 23:01:05 +0800 Subject: [PATCH 4/4] fix(agy): resolve relative project_root to absolute path for --add-dir --- src/specify_cli/integrations/agy/__init__.py | 2 +- tests/integrations/test_integration_agy.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index d513740bcf..bbbbfdefe8 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -104,7 +104,7 @@ def build_exec_args( # 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)]) + 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. diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b39e8e97da..e1c08fd56f 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -122,6 +122,21 @@ def test_build_exec_args_add_dir_for_workspace(self, tmp_path): 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_relative_project_root(self): + """Relative project_root must be resolved to an absolute path. + + 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 + from pathlib import Path + i = get_integration("agy") + rel_path = Path("my_relative_dir") + result = i.build_exec_args("my prompt", project_root=rel_path) + assert "--add-dir" in result + 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