Files
github-spec-kit/tests/integrations/test_extra_args.py
Copilot b4e5a1c3be feat: support SPECKIT_INTEGRATION_<KEY>_EXECUTABLE env var (#2743)
* Initial plan

* feat: support SPECKIT_INTEGRATION_<KEY>_EXECUTABLE env var override

Adds `IntegrationBase._resolve_executable()` which reads
`SPECKIT_INTEGRATION_<KEY>_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."

* fix: complete docstring sentence in _resolve_executable

* test: generalize extra-args test comments for override coverage

* Fix stale Codex executable comment

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-29 10:19:31 -05:00

639 lines
22 KiB
Python

"""Tests for the per-integration `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` and
`SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` env-var hooks.
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 override integrations.
These tests cover both the shared mechanisms (via `SkillsIntegration` stubs
near the top of the file) and override integrations end-to-end (further down).
See issues #2595 and #2596."""
import os
import pytest
from specify_cli.integrations.base import (
MarkdownIntegration,
SkillsIntegration,
TomlIntegration,
)
class _ClaudeStub(SkillsIntegration):
"""Minimal Claude-like SkillsIntegration for testing."""
key = "claude"
config = {
"name": "Claude (test stub)",
"folder": ".claude/",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": True,
}
registrar_config = {
"dir": ".claude/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "CLAUDE.md"
class _KiroCliStub(SkillsIntegration):
"""SkillsIntegration with a hyphenated key to exercise key
normalization (`kiro-cli` → `KIRO_CLI`)."""
key = "kiro-cli"
config = {
"name": "Kiro CLI (test stub)",
"folder": ".kiro/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": True,
}
registrar_config = {
"dir": ".kiro/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "KIRO.md"
class _NoCliStub(SkillsIntegration):
"""SkillsIntegration with requires_cli=False — build_exec_args
must return None and the env-var hook must not fire."""
key = "no-cli"
config = {
"name": "No-CLI agent (test stub)",
"folder": ".no-cli/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".no-cli/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "NOCLI.md"
class _MarkdownAgentStub(MarkdownIntegration):
"""Bare MarkdownIntegration subclass — does NOT override
`build_exec_args`. Locks the base implementation in
`MarkdownIntegration.build_exec_args` for the common case
(most concrete integrations: Amp, Auggie, Generic, …)."""
key = "md-agent"
config = {
"name": "Markdown agent (test stub)",
"folder": ".md-agent/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": True,
}
registrar_config = {
"dir": ".md-agent/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "MDAGENT.md"
class _TomlAgentStub(TomlIntegration):
"""Bare TomlIntegration subclass — does NOT override
`build_exec_args`. Locks the base implementation in
`TomlIntegration.build_exec_args` (Gemini, Tabnine)."""
key = "toml-agent"
config = {
"name": "TOML agent (test stub)",
"folder": ".toml-agent/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": True,
}
registrar_config = {
"dir": ".toml-agent/commands",
"format": "toml",
"args": "$ARGUMENTS",
"extension": ".toml",
}
context_file = "TOMLAGENT.md"
@pytest.fixture(autouse=True)
def _clean_extra_args_env(monkeypatch):
"""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") or key.endswith("_EXECUTABLE")
):
monkeypatch.delenv(key, raising=False)
def test_env_var_unset_byte_identical_argv():
"""Default behaviour: env var unset → no extra args inserted.
Locks the backward-compatibility guarantee that existing
operators see no change.
"""
args = _ClaudeStub().build_exec_args("hello prompt")
assert args == ["claude", "-p", "hello prompt", "--output-format", "json"]
def test_env_var_set_flag_inserted_before_model_and_output_format(
monkeypatch,
):
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
)
args = _ClaudeStub().build_exec_args("hello prompt", model="sonnet")
assert args == [
"claude",
"-p",
"hello prompt",
"--dangerously-skip-permissions",
"--model",
"sonnet",
"--output-format",
"json",
]
def test_env_var_multi_token_parsed_via_shlex(monkeypatch):
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
"--dangerously-skip-permissions --max-turns 3",
)
args = _ClaudeStub().build_exec_args("p")
assert args == [
"claude",
"-p",
"p",
"--dangerously-skip-permissions",
"--max-turns",
"3",
"--output-format",
"json",
]
def test_malformed_quoting_raises_actionable_value_error(monkeypatch):
"""An unmatched quote in the env-var value must surface a clear
error naming the offending env var and showing the invalid value,
rather than crashing workflow dispatch with a bare shlex traceback."""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
'--flag "unterminated',
)
with pytest.raises(ValueError) as excinfo:
_ClaudeStub().build_exec_args("p")
msg = str(excinfo.value)
assert "SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS" in msg
assert "--flag \"unterminated" in msg
def test_env_var_empty_or_whitespace_is_noop(monkeypatch):
"""An env var set to '' or ' ' is treated as unset."""
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", " ")
args = _ClaudeStub().build_exec_args("p")
assert args == ["claude", "-p", "p", "--output-format", "json"]
def test_other_integration_env_var_ignored(monkeypatch):
"""`SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS` set must NOT leak into
Claude's argv (per-integration scoping)."""
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS", "--gemini-only-flag")
args = _ClaudeStub().build_exec_args("p")
assert args == ["claude", "-p", "p", "--output-format", "json"]
def test_key_normalization_hyphen_to_underscore_uppercase(monkeypatch):
"""`kiro-cli` key looks up `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`
(hyphens replaced with underscores, then uppercased)."""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--some-kiro-flag"
)
args = _KiroCliStub().build_exec_args("p")
assert args == [
"kiro-cli",
"-p",
"p",
"--some-kiro-flag",
"--output-format",
"json",
]
def test_requires_cli_false_returns_none(monkeypatch):
"""`requires_cli: False` short-circuits to None — the env-var
hook is never reached and no argv is built."""
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXTRA_ARGS", "--should-not-appear")
assert _NoCliStub().build_exec_args("p") is None
# ---------------------------------------------------------------------------
# Base-class coverage
#
# Most integrations inherit `build_exec_args` from `MarkdownIntegration`
# or `TomlIntegration` without overriding it. The tests above use
# `SkillsIntegration` stubs (which share the same hook mechanism) — these
# tests exercise the two other base implementations directly so all three
# concrete bases are covered.
# ---------------------------------------------------------------------------
def test_markdown_integration_base_honours_extra_args(monkeypatch):
"""A bare `MarkdownIntegration` subclass — which does not override
`build_exec_args` — must honour the env var via the base
implementation. Covers the most common integration pattern."""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_MD_AGENT_EXTRA_ARGS", "--debug --max-tokens 100"
)
args = _MarkdownAgentStub().build_exec_args("p")
assert args == [
"md-agent",
"-p",
"p",
"--debug",
"--max-tokens",
"100",
"--output-format",
"json",
]
def test_toml_integration_base_honours_extra_args(monkeypatch):
"""A bare `TomlIntegration` subclass — which does not override
`build_exec_args` — must honour the env var via the base
implementation. Covers Gemini/Tabnine-style integrations."""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_TOML_AGENT_EXTRA_ARGS", "--yolo"
)
args = _TomlAgentStub().build_exec_args("p", model="gemini-pro")
# TomlIntegration uses `-m` for model (vs Markdown's `--model`).
assert args == [
"toml-agent",
"-p",
"p",
"--yolo",
"-m",
"gemini-pro",
"--output-format",
"json",
]
# ---------------------------------------------------------------------------
# Override-integration coverage
#
# CodexIntegration, DevinIntegration, OpencodeIntegration and
# CopilotIntegration each override `build_exec_args` rather than using the
# base implementations. The env-var hook must be wired into every override
# so the documented behaviour ("works for every requires_cli integration")
# is honoured. These tests lock that contract per integration.
# ---------------------------------------------------------------------------
def test_codex_integration_honours_extra_args(monkeypatch):
from specify_cli.integrations.codex import CodexIntegration
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
args = CodexIntegration().build_exec_args("p", model="gpt-5")
assert args == [
"codex",
"exec",
"p",
"--sandbox",
"read-only",
"--model",
"gpt-5",
"--json",
]
def test_devin_integration_honours_extra_args(monkeypatch):
from specify_cli.integrations.devin import DevinIntegration
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXTRA_ARGS", "--no-confirm")
args = DevinIntegration().build_exec_args("p")
assert args == ["devin", "-p", "p", "--no-confirm"]
def test_opencode_integration_honours_extra_args(monkeypatch):
from specify_cli.integrations.opencode import OpencodeIntegration
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--quiet")
args = OpencodeIntegration().build_exec_args("p")
assert args == [
"opencode",
"run",
"--quiet",
"--format",
"json",
"p",
]
def test_opencode_extra_args_cannot_clobber_prompt_derived_command(
monkeypatch,
):
"""Operator-injected extra args must appear BEFORE the prompt-derived
``--command <X>`` so that Spec Kit's command selection wins under
repeated-flag CLI semantics (last value typically takes precedence).
Locks against the regression where an operator setting
``SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS="--command malicious"`` could redirect
a slash-prefixed prompt to a different command.
"""
from specify_cli.integrations.opencode import OpencodeIntegration
monkeypatch.setenv(
"SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--command operator-override"
)
args = OpencodeIntegration().build_exec_args("/speckit body text")
# Prompt-derived "--command speckit" appears AFTER the
# operator-injected one, so a CLI that resolves repeated flags
# last-wins will honour Spec Kit's choice.
assert args == [
"opencode",
"run",
"--command",
"operator-override",
"--command",
"speckit",
"--format",
"json",
"body text",
]
def test_copilot_integration_honours_extra_args(monkeypatch):
from specify_cli.integrations.copilot import (
CopilotIntegration,
_copilot_executable,
)
# Disable --yolo so the argv shape stays deterministic.
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
monkeypatch.setenv(
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
)
args = CopilotIntegration().build_exec_args("p")
# `_copilot_executable()` returns "copilot.cmd" on Windows and
# "copilot" elsewhere; the test must mirror that to stay portable.
assert args == [
_copilot_executable(),
"-p",
"p",
"--allow-tool",
"shell(echo)",
"--output-format",
"json",
]
# ---------------------------------------------------------------------------
# `dispatch_command` end-to-end coverage
#
# Workflow execution calls `impl.dispatch_command(...)`, not
# `build_exec_args` directly. `IntegrationBase.dispatch_command` delegates
# to `build_exec_args` (so the override fixes above flow through), but
# `CopilotIntegration` overrides `dispatch_command` and constructs
# `cli_args` inline — the hook must be invoked there too or the env var
# is silently ignored at workflow runtime. These tests monkeypatch
# `subprocess.run` and assert the env-var args reach the executed argv.
# ---------------------------------------------------------------------------
class _RunCapture:
"""Test double that captures argv passed to subprocess.run."""
def __init__(self):
self.captured_args: list[str] | None = None
def __call__(self, args, **kwargs):
self.captured_args = list(args)
class _Result:
returncode = 0
stdout = ""
stderr = ""
return _Result()
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
"""
import subprocess
from specify_cli.integrations.copilot import CopilotIntegration
capture = _RunCapture()
monkeypatch.setattr(subprocess, "run", capture)
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
monkeypatch.setenv(
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
)
CopilotIntegration().dispatch_command(
"speckit.plan", args="body", stream=False
)
assert capture.captured_args is not None
# Hook inserted between `-p prompt` and the canonical Copilot flags.
p_idx = capture.captured_args.index("-p")
agent_idx = capture.captured_args.index("--agent")
extra_idx = capture.captured_args.index("--allow-tool")
assert p_idx < extra_idx < agent_idx
assert "shell(echo)" in capture.captured_args
def test_codex_dispatch_command_includes_extra_args(monkeypatch):
"""Lock the inherited `IntegrationBase.dispatch_command` path:
Codex (and by transitivity Devin, Opencode) flow through
`build_exec_args`, so the env var must reach argv at workflow
runtime.
"""
import subprocess
from specify_cli.integrations.codex import CodexIntegration
capture = _RunCapture()
monkeypatch.setattr(subprocess, "run", capture)
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
CodexIntegration().dispatch_command(
"speckit.plan", args="body", stream=False
)
assert capture.captured_args is not None
assert "--sandbox" in capture.captured_args
assert "read-only" in capture.captured_args
# ---------------------------------------------------------------------------
# SPECKIT_INTEGRATION_<KEY>_EXECUTABLE tests
#
# The `_resolve_executable()` method on `IntegrationBase` checks
# `SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` and, when set, substitutes that
# value for `self.key` as the first token in argv. The tests below lock
# the behaviour across shared and override integration paths:
# - the shared SkillsIntegration/MarkdownIntegration/TomlIntegration bases,
# - representative override integrations,
# - 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",
]