From c968fea55d0ad1e5ef4fa5ad8b9f615ba1b6cfe5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 19:23:56 +0000 Subject: [PATCH 1/5] Initial plan From f8622c3e1eb4ae565f41481dc3b63dab2656d5ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:47 +0000 Subject: [PATCH 2/5] feat: support SPECKIT_INTEGRATION__EXECUTABLE env var override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `IntegrationBase._resolve_executable()` which reads `SPECKIT_INTEGRATION__EXECUTABLE` (hyphens→underscores, uppercased) and falls back to `self.key` when unset or whitespace-only. All concrete `build_exec_args()` implementations now call `self._resolve_executable()` instead of hard-coding `self.key` or `"agy"` as the first argv token: - MarkdownIntegration, TomlIntegration, SkillsIntegration (base classes) - CodexIntegration, DevinIntegration, OpencodeIntegration, HermesIntegration, AgyIntegration - CopilotIntegration (overrides `_resolve_executable()` to fall back to the platform-specific `_copilot_executable()` default; also updates `dispatch_command()` to use `_resolve_executable()`) Tests added to tests/integrations/test_extra_args.py covering: - default (unset) falls back to key - env var replaces first argv token - whitespace-only env var is a no-op - key hyphen→underscore normalisation - cross-integration scoping (wrong key ignored) - all override integrations (Codex, Devin, Opencode, Copilot) - Copilot dispatch_command path - EXECUTABLE and EXTRA_ARGS can be set simultaneously See issue #2596." --- src/specify_cli/integrations/agy/__init__.py | 2 +- src/specify_cli/integrations/base.py | 28 ++- .../integrations/codex/__init__.py | 2 +- .../integrations/copilot/__init__.py | 16 +- .../integrations/devin/__init__.py | 2 +- .../integrations/hermes/__init__.py | 2 +- .../integrations/opencode/__init__.py | 2 +- tests/integrations/test_extra_args.py | 182 ++++++++++++++++-- 8 files changed, 214 insertions(+), 22 deletions(-) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 244f95e4f0..6ed69e1e0e 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -90,7 +90,7 @@ def build_exec_args( output_json: bool = True, ) -> list[str] | None: # agy does not support --model or JSON output; both params are ignored - return ["agy", "--print", prompt] + return [self._resolve_executable(), "--print", prompt] def setup( self, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7bacef9e78..deea44228f 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -146,6 +146,28 @@ def build_exec_args( """ return None + def _resolve_executable(self) -> str: + """Return the executable for this integration's CLI tool. + + Checks ``SPECKIT_INTEGRATION__EXECUTABLE`` first, allowing + operators to override the binary path without modifying the + integration configuration — useful when the tool is installed in + a non-standard location or a specific version must be pinned. + Hyphens in the integration key are replaced with underscores and + the key is uppercased + (e.g. ``kiro-cli`` → ``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``). + + Falls back to ``self.key`` when the env var is unset or + whitespace-only so existing behaviour is unchanged. + + See issue #2596. + """ + env_name = ( + f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE" + ) + override = os.environ.get(env_name, "").strip() + return override if override else self.key + def _apply_extra_args_env_var(self, args: list[str]) -> None: """Append `SPECKIT_INTEGRATION__EXTRA_ARGS` env-var value to *args*. @@ -895,7 +917,7 @@ def build_exec_args( ) -> list[str] | None: if not self.config or not self.config.get("requires_cli"): return None - args = [self.key, "-p", prompt] + args = [self._resolve_executable(), "-p", prompt] self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) @@ -983,7 +1005,7 @@ def build_exec_args( ) -> list[str] | None: if not self.config or not self.config.get("requires_cli"): return None - args = [self.key, "-p", prompt] + args = [self._resolve_executable(), "-p", prompt] self._apply_extra_args_env_var(args) if model: args.extend(["-m", model]) @@ -1402,7 +1424,7 @@ def build_exec_args( ) -> list[str] | None: if not self.config or not self.config.get("requires_cli"): return None - args = [self.key, "-p", prompt] + args = [self._resolve_executable(), "-p", prompt] self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 3fb81c06f7..fa5ca8bcfb 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -41,7 +41,7 @@ def build_exec_args( # env-var lookup (which also derives from ``self.key``), matching # the pattern in Devin/Opencode and avoiding drift if the key # ever changes. - args: list[str] = [self.key, "exec", prompt] + args: list[str] = [self._resolve_executable(), "exec", prompt] self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0293abc316..0d32edff72 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -134,6 +134,18 @@ def options(cls) -> list[IntegrationOption]: ), ] + def _resolve_executable(self) -> str: + """Return the Copilot CLI executable, respecting the env-var override. + + Checks ``SPECKIT_INTEGRATION_COPILOT_EXECUTABLE`` first. Falls + back to the platform-specific default from ``_copilot_executable()`` + (``copilot.cmd`` on Windows, ``copilot`` elsewhere) so that + existing behaviour is preserved when the env var is unset. + """ + env_name = "SPECKIT_INTEGRATION_COPILOT_EXECUTABLE" + override = os.environ.get(env_name, "").strip() + return override if override else _copilot_executable() + def build_exec_args( self, prompt: str, @@ -148,7 +160,7 @@ def build_exec_args( # Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var # (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS # is also honoured as a fallback. - args = [_copilot_executable(), "-p", prompt] + args = [self._resolve_executable(), "-p", prompt] self._apply_extra_args_env_var(args) if _allow_all(): args.append("--yolo") @@ -217,7 +229,7 @@ def dispatch_command( agent_name = f"speckit.{stem}" prompt = args or "" - cli_args = [_copilot_executable(), "-p", prompt] + cli_args = [self._resolve_executable(), "-p", prompt] # Honour SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS for real workflow # runs. `dispatch_command` builds cli_args inline rather than # going through `build_exec_args`, so the hook must be invoked diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index f8a57d9ceb..b3b21b8526 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -48,7 +48,7 @@ def build_exec_args( stdout instead of structured JSON. ``requires_cli=True`` is kept on the integration for tool detection. """ - args = [self.key, "-p", prompt] + args = [self._resolve_executable(), "-p", prompt] self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index f0ae6a6b76..44556590c9 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -257,7 +257,7 @@ def build_exec_args( mapping slash-command invocations to the appropriate skill-based dispatch. """ - args = [self.key, "chat", "-Q"] + args = [self._resolve_executable(), "chat", "-Q"] if model: args.extend(["-m", model]) diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index c5dfe03e07..abd97ab2ae 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -28,7 +28,7 @@ def build_exec_args( model: str | None = None, output_json: bool = True, ) -> list[str] | None: - args = [self.key, "run"] + args = [self._resolve_executable(), "run"] # Apply operator-injected extra args before the prompt-derived # --command and the canonical --format/-m flags so Spec Kit's # later appends remain authoritative under repeated-flag CLI diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index 0fc9196f5c..68374d846c 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -1,12 +1,13 @@ -"""Tests for the per-integration `SPECKIT_INTEGRATION__EXTRA_ARGS` env-var hook. +"""Tests for the per-integration `SPECKIT_INTEGRATION__EXTRA_ARGS` and +`SPECKIT_INTEGRATION__EXECUTABLE` env-var hooks. -The hook is implemented in `IntegrationBase._apply_extra_args_env_var` -and wired into every concrete `build_exec_args` — -`MarkdownIntegration`, `TomlIntegration`, `SkillsIntegration`, plus the -overrides in Codex, Devin, Opencode and Copilot. These tests cover both -the shared mechanism (via `SkillsIntegration` stubs near the top of the -file) and each override integration end-to-end (further down). See -issue #2595.""" +The hooks are implemented in `IntegrationBase._apply_extra_args_env_var` and +`IntegrationBase._resolve_executable` and wired into every concrete +`build_exec_args` — `MarkdownIntegration`, `TomlIntegration`, +`SkillsIntegration`, plus the overrides in Codex, Devin, Opencode and Copilot. +These tests cover both the shared mechanisms (via `SkillsIntegration` stubs +near the top of the file) and each override integration end-to-end (further +down). See issues #2595 and #2596.""" import os @@ -128,11 +129,12 @@ class _TomlAgentStub(TomlIntegration): @pytest.fixture(autouse=True) def _clean_extra_args_env(monkeypatch): - """Strip any leaked SPECKIT_INTEGRATION_*_EXTRA_ARGS from the test - env so a developer's shell setting doesn't pollute results.""" + """Strip any leaked SPECKIT_INTEGRATION_*_EXTRA_ARGS and + SPECKIT_INTEGRATION_*_EXECUTABLE vars from the test env so a + developer's shell setting doesn't pollute results.""" for key in list(os.environ): - if key.startswith("SPECKIT_INTEGRATION_") and key.endswith( - "_EXTRA_ARGS" + if key.startswith("SPECKIT_INTEGRATION_") and ( + key.endswith("_EXTRA_ARGS") or key.endswith("_EXECUTABLE") ): monkeypatch.delenv(key, raising=False) @@ -478,3 +480,159 @@ def test_codex_dispatch_command_includes_extra_args(monkeypatch): assert capture.captured_args is not None assert "--sandbox" in capture.captured_args assert "read-only" in capture.captured_args + + +# --------------------------------------------------------------------------- +# SPECKIT_INTEGRATION__EXECUTABLE tests +# +# The `_resolve_executable()` method on `IntegrationBase` checks +# `SPECKIT_INTEGRATION__EXECUTABLE` and, when set, substitutes that +# value for `self.key` as the first token in argv. The tests below lock +# the behaviour for all integration paths: +# - the shared SkillsIntegration/MarkdownIntegration/TomlIntegration bases, +# - each override integration (Codex, Devin, Opencode, Copilot), +# - the hyphen→underscore key normalisation, and +# - whitespace/unset no-op guarantee. +# --------------------------------------------------------------------------- + + +def test_executable_env_var_unset_uses_key(): + """Default: no override → executable is the integration key.""" + args = _ClaudeStub().build_exec_args("p") + assert args[0] == "claude" + + +def test_executable_env_var_replaces_first_argv_token(monkeypatch): + """Setting the env var substitutes the executable name in argv.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude/bin/claude") + args = _ClaudeStub().build_exec_args("hello") + assert args[0] == "/opt/claude/bin/claude" + assert args[1:] == ["-p", "hello", "--output-format", "json"] + + +def test_executable_env_var_whitespace_only_falls_back_to_key(monkeypatch): + """Whitespace-only value is treated as unset → falls back to self.key.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", " ") + args = _ClaudeStub().build_exec_args("p") + assert args[0] == "claude" + + +def test_executable_env_var_key_normalization_hyphen_to_underscore(monkeypatch): + """`kiro-cli` key maps to `SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE`.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", "/usr/local/bin/kiro-cli") + args = _KiroCliStub().build_exec_args("p") + assert args[0] == "/usr/local/bin/kiro-cli" + + +def test_executable_env_var_other_integration_ignored(monkeypatch): + """`SPECKIT_INTEGRATION_GEMINI_EXECUTABLE` must NOT affect Claude.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXECUTABLE", "/custom/gemini") + args = _ClaudeStub().build_exec_args("p") + assert args[0] == "claude" + + +def test_executable_env_var_markdown_integration(monkeypatch): + """MarkdownIntegration base honours the executable env var.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_MD_AGENT_EXECUTABLE", "/custom/md-agent") + args = _MarkdownAgentStub().build_exec_args("p") + assert args[0] == "/custom/md-agent" + + +def test_executable_env_var_toml_integration(monkeypatch): + """TomlIntegration base honours the executable env var.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_TOML_AGENT_EXECUTABLE", "/custom/toml-agent") + args = _TomlAgentStub().build_exec_args("p") + assert args[0] == "/custom/toml-agent" + + +def test_executable_env_var_requires_cli_false_returns_none(monkeypatch): + """`requires_cli: False` still returns None even when executable is set.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXECUTABLE", "/custom/no-cli") + assert _NoCliStub().build_exec_args("p") is None + + +def test_executable_env_var_codex_integration(monkeypatch): + """CodexIntegration honours the executable env var.""" + from specify_cli.integrations.codex import CodexIntegration + + monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXECUTABLE", "/opt/codex") + args = CodexIntegration().build_exec_args("p") + assert args[0] == "/opt/codex" + assert args[1] == "exec" + + +def test_executable_env_var_devin_integration(monkeypatch): + """DevinIntegration honours the executable env var.""" + from specify_cli.integrations.devin import DevinIntegration + + monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXECUTABLE", "/opt/devin") + args = DevinIntegration().build_exec_args("p") + assert args[0] == "/opt/devin" + + +def test_executable_env_var_opencode_integration(monkeypatch): + """OpencodeIntegration honours the executable env var.""" + from specify_cli.integrations.opencode import OpencodeIntegration + + monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXECUTABLE", "/opt/opencode") + args = OpencodeIntegration().build_exec_args("p") + assert args[0] == "/opt/opencode" + assert args[1] == "run" + + +def test_executable_env_var_copilot_integration(monkeypatch): + """CopilotIntegration honours the executable env var, overriding the + platform-specific default from `_copilot_executable()`.""" + from specify_cli.integrations.copilot import CopilotIntegration + + monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot") + monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") + args = CopilotIntegration().build_exec_args("p") + assert args[0] == "/opt/copilot" + + +def test_executable_env_var_copilot_unset_uses_platform_default(monkeypatch): + """When `SPECKIT_INTEGRATION_COPILOT_EXECUTABLE` is unset, Copilot + falls back to the platform-specific default from `_copilot_executable()`.""" + from specify_cli.integrations.copilot import CopilotIntegration, _copilot_executable + + monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") + args = CopilotIntegration().build_exec_args("p") + assert args[0] == _copilot_executable() + + +def test_executable_env_var_copilot_dispatch_command(monkeypatch): + """CopilotIntegration.dispatch_command honours the executable env var.""" + import subprocess + + from specify_cli.integrations.copilot import CopilotIntegration + + capture = _RunCapture() + monkeypatch.setattr(subprocess, "run", capture) + monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot") + monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") + + CopilotIntegration().dispatch_command("speckit.plan", args="body", stream=False) + + assert capture.captured_args is not None + assert capture.captured_args[0] == "/opt/copilot" + + +def test_executable_and_extra_args_both_honoured(monkeypatch): + """Both the executable override and extra args env vars can be set + simultaneously — they are independent hooks.""" + monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude") + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions" + ) + args = _ClaudeStub().build_exec_args("hello", model="sonnet") + assert args == [ + "/opt/claude", + "-p", + "hello", + "--dangerously-skip-permissions", + "--model", + "sonnet", + "--output-format", + "json", + ] From b4ff9efb1e8a440caebe94d24a41213ad84cf1c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 19:33:11 +0000 Subject: [PATCH 3/5] fix: complete docstring sentence in _resolve_executable --- src/specify_cli/integrations/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index deea44228f..5889ed08ea 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -154,8 +154,8 @@ def _resolve_executable(self) -> str: integration configuration — useful when the tool is installed in a non-standard location or a specific version must be pinned. Hyphens in the integration key are replaced with underscores and - the key is uppercased - (e.g. ``kiro-cli`` → ``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``). + the key is uppercased so that, for example, ``kiro-cli`` maps to + ``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``. Falls back to ``self.key`` when the env var is unset or whitespace-only so existing behaviour is unchanged. From bca75b465ecc73f76baff20a78744cbfb0df41da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:17:33 +0000 Subject: [PATCH 4/5] test: generalize extra-args test comments for override coverage --- tests/integrations/test_extra_args.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index 68374d846c..d192e140fb 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -4,10 +4,10 @@ The hooks are implemented in `IntegrationBase._apply_extra_args_env_var` and `IntegrationBase._resolve_executable` and wired into every concrete `build_exec_args` — `MarkdownIntegration`, `TomlIntegration`, -`SkillsIntegration`, plus the overrides in Codex, Devin, Opencode and Copilot. +`SkillsIntegration`, plus override integrations. These tests cover both the shared mechanisms (via `SkillsIntegration` stubs -near the top of the file) and each override integration end-to-end (further -down). See issues #2595 and #2596.""" +near the top of the file) and override integrations end-to-end (further down). +See issues #2595 and #2596.""" import os @@ -488,9 +488,9 @@ def test_codex_dispatch_command_includes_extra_args(monkeypatch): # The `_resolve_executable()` method on `IntegrationBase` checks # `SPECKIT_INTEGRATION__EXECUTABLE` and, when set, substitutes that # value for `self.key` as the first token in argv. The tests below lock -# the behaviour for all integration paths: +# the behaviour across shared and override integration paths: # - the shared SkillsIntegration/MarkdownIntegration/TomlIntegration bases, -# - each override integration (Codex, Devin, Opencode, Copilot), +# - representative override integrations, # - the hyphen→underscore key normalisation, and # - whitespace/unset no-op guarantee. # --------------------------------------------------------------------------- From 9b4173979a1edfbf8a72bac156859979afb44078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:33:05 +0000 Subject: [PATCH 5/5] Fix stale Codex executable comment --- src/specify_cli/integrations/codex/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index fa5ca8bcfb..1f7dbc601f 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -37,10 +37,8 @@ def build_exec_args( output_json: bool = True, ) -> list[str] | None: # Codex uses ``codex exec "prompt"`` for non-interactive mode. - # Use ``self.key`` so the executable name stays coupled to the - # env-var lookup (which also derives from ``self.key``), matching - # the pattern in Devin/Opencode and avoiding drift if the key - # ever changes. + # Resolve argv[0] via the shared executable resolver so operators can + # override the binary with SPECKIT_INTEGRATION_CODEX_EXECUTABLE. args: list[str] = [self._resolve_executable(), "exec", prompt] self._apply_extra_args_env_var(args) if model: