From 87ea00c1905cc979b22477b56f52ba3ef513e3c5 Mon Sep 17 00:00:00 2001 From: doquanghuy Date: Sat, 16 May 2026 12:36:06 +0700 Subject: [PATCH 1/2] feat(integrations): support SPECIFY__EXTRA_ARGS env var for agent subprocess flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read a per-integration env var (SPECIFY__EXTRA_ARGS) inside `SkillsIntegration.build_exec_args`, `MarkdownIntegration.build_exec_args`, and `TomlIntegration.build_exec_args` and append the parsed flags to the spawned agent's argv, gated per integration key. Operators can now opt into extra CLI flags (e.g. `SPECIFY_CLAUDE_EXTRA_ARGS=--dangerously-skip-permissions`) without modifying any SKILL or workflow YAML. Useful in CI / non-interactive contexts where the spawned ` -p ...` would otherwise hang on an internal permission or input prompt invisible to the parent `specify workflow run` process. Key normalization: `kiro-cli` → `SPECIFY_KIRO_CLI_EXTRA_ARGS` (hyphen replaced with underscore, then uppercased). Default (env var unset or whitespace-only) is byte-identical to previous behaviour. Extra args are inserted between `-p prompt` and the model / output-format flags so they cannot clobber canonical Spec Kit args. Implementation: a single helper `IntegrationBase._apply_extra_args_env_var` encapsulates the env-var read + shlex parsing; each of the three concrete `build_exec_args` implementations calls it. Closes #2595 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/specify_cli/integrations/base.py | 30 +++++ tests/integrations/test_extra_args.py | 163 ++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tests/integrations/test_extra_args.py diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7ce107caec..1cc18316bf 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,7 +13,9 @@ from __future__ import annotations +import os import re +import shlex import shutil from abc import ABC from dataclasses import dataclass @@ -138,6 +140,31 @@ def build_exec_args( """ return None + def _apply_extra_args_env_var(self, args: list[str]) -> None: + """Append `SPECIFY__EXTRA_ARGS` env-var value to *args*. + + Operators can inject extra CLI flags into the spawned agent + subprocess by setting an env var named for the integration key, + e.g. `SPECIFY_CLAUDE_EXTRA_ARGS="--dangerously-skip-permissions"`. + Hyphens in the integration key are replaced with underscores + and the key is uppercased + (e.g. `kiro-cli` → `SPECIFY_KIRO_CLI_EXTRA_ARGS`). + + Useful in CI / non-interactive contexts where the spawned agent + needs flags that change its prompt-handling behaviour. + Default behaviour (env var unset or whitespace-only) is a no-op + — *args* is unchanged. Multi-token values are parsed via + `shlex.split`. + + See issue #2595. + """ + env_name = ( + f"SPECIFY_{self.key.upper().replace('-', '_')}_EXTRA_ARGS" + ) + extra = os.environ.get(env_name, "").strip() + if extra: + args.extend(shlex.split(extra)) + def build_command_invocation(self, command_name: str, args: str = "") -> str: """Build the native slash-command invocation for a Spec Kit command. @@ -851,6 +878,7 @@ def build_exec_args( if not self.config or not self.config.get("requires_cli"): return None args = [self.key, "-p", prompt] + self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) if output_json: @@ -938,6 +966,7 @@ def build_exec_args( if not self.config or not self.config.get("requires_cli"): return None args = [self.key, "-p", prompt] + self._apply_extra_args_env_var(args) if model: args.extend(["-m", model]) if output_json: @@ -1356,6 +1385,7 @@ def build_exec_args( if not self.config or not self.config.get("requires_cli"): return None args = [self.key, "-p", prompt] + self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) if output_json: diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py new file mode 100644 index 0000000000..96135e0828 --- /dev/null +++ b/tests/integrations/test_extra_args.py @@ -0,0 +1,163 @@ +"""Tests for the per-integration `SPECIFY__EXTRA_ARGS` env-var hook +in `SkillsIntegration.build_exec_args`. See issue #2595.""" + +import pytest + +from specify_cli.integrations.base import SkillsIntegration + + +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" + + +@pytest.fixture(autouse=True) +def _clean_extra_args_env(monkeypatch): + """Strip any leaked SPECIFY_*_EXTRA_ARGS from the test env so a + developer's shell setting doesn't pollute results.""" + for key in list(__import__("os").environ): + if key.startswith("SPECIFY_") and key.endswith("_EXTRA_ARGS"): + 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( + "SPECIFY_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( + "SPECIFY_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_env_var_empty_or_whitespace_is_noop(monkeypatch): + """An env var set to '' or ' ' is treated as unset.""" + monkeypatch.setenv("SPECIFY_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): + """`SPECIFY_GEMINI_EXTRA_ARGS` set must NOT leak into + Claude's argv (per-integration scoping).""" + monkeypatch.setenv("SPECIFY_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 `SPECIFY_KIRO_CLI_EXTRA_ARGS` + (hyphens replaced with underscores, then uppercased).""" + monkeypatch.setenv( + "SPECIFY_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("SPECIFY_NO_CLI_EXTRA_ARGS", "--should-not-appear") + assert _NoCliStub().build_exec_args("p") is None From 8341c121c23e2eeedd4ce4576d1d0ea4c189a48a Mon Sep 17 00:00:00 2001 From: doquanghuy Date: Mon, 18 May 2026 22:43:33 +0700 Subject: [PATCH 2/2] fix(integrations): wire SPECIFY__EXTRA_ARGS into Codex/Devin/Opencode/Copilot Four integrations override `build_exec_args` and were silently ignoring the env-var hook introduced in the previous commit: - CodexIntegration (`codex exec ...`) - DevinIntegration (`devin -p ...`) - OpencodeIntegration (`opencode run ...`) - CopilotIntegration (`copilot -p ...`) Each now calls `self._apply_extra_args_env_var(args)` between the base argv and the canonical Spec Kit flags (matching the placement in `MarkdownIntegration`, `TomlIntegration`, and `SkillsIntegration`), so operator-injected flags cannot clobber `--model` / `--output-format` / `--json`. Adds 4 parameterized override-integration tests locking the wiring per agent. Also cleans up an inline `__import__("os").environ` in the fixture to a top-of-file `import os`. Drive-by typing fix: guard `self.registrar_config.get(...)` in `CopilotIntegration.add_commands` against the `None` case, matching the pattern already used in `base.py` for the same access. Addresses Copilot review on #2596. --- .../integrations/codex/__init__.py | 1 + .../integrations/copilot/__init__.py | 7 +- .../integrations/devin/__init__.py | 1 + .../integrations/opencode/__init__.py | 1 + tests/integrations/test_extra_args.py | 75 ++++++++++++++++++- 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 1c24a84bd2..8783776979 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -38,6 +38,7 @@ def build_exec_args( ) -> list[str] | None: # Codex uses ``codex exec "prompt"`` for non-interactive mode. args: list[str] = ["codex", "exec", prompt] + self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) if output_json: diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index c7456ce7f0..f2dac6e07b 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -139,6 +139,7 @@ def build_exec_args( # (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS # is also honoured as a fallback. args = ["copilot", "-p", prompt] + self._apply_extra_args_env_var(args) if _allow_all(): args.append("--yolo") if model: @@ -358,7 +359,11 @@ def _setup_default( created: list[Path] = [] script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) # 1. Process and write command files as .agent.md for src_file in templates: diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index f5656e4aef..f8a57d9ceb 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -49,6 +49,7 @@ def build_exec_args( kept on the integration for tool detection. """ args = [self.key, "-p", prompt] + self._apply_extra_args_env_var(args) if model: args.extend(["--model", model]) return args diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 4fa9c724ac..3abc9abc2a 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -37,6 +37,7 @@ def build_exec_args( args.extend(["--command", command]) message = remainder + self._apply_extra_args_env_var(args) if model: args.extend(["-m", model]) if output_json: diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index 96135e0828..b479861b03 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -1,6 +1,8 @@ """Tests for the per-integration `SPECIFY__EXTRA_ARGS` env-var hook in `SkillsIntegration.build_exec_args`. See issue #2595.""" +import os + import pytest from specify_cli.integrations.base import SkillsIntegration @@ -72,7 +74,7 @@ class _NoCliStub(SkillsIntegration): def _clean_extra_args_env(monkeypatch): """Strip any leaked SPECIFY_*_EXTRA_ARGS from the test env so a developer's shell setting doesn't pollute results.""" - for key in list(__import__("os").environ): + for key in list(os.environ): if key.startswith("SPECIFY_") and key.endswith("_EXTRA_ARGS"): monkeypatch.delenv(key, raising=False) @@ -161,3 +163,74 @@ def test_requires_cli_false_returns_none(monkeypatch): hook is never reached and no argv is built.""" monkeypatch.setenv("SPECIFY_NO_CLI_EXTRA_ARGS", "--should-not-appear") assert _NoCliStub().build_exec_args("p") is None + + +# --------------------------------------------------------------------------- +# 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("SPECIFY_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("SPECIFY_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("SPECIFY_OPENCODE_EXTRA_ARGS", "--quiet") + args = OpencodeIntegration().build_exec_args("p") + assert args == [ + "opencode", + "run", + "--quiet", + "--format", + "json", + "p", + ] + + +def test_copilot_integration_honours_extra_args(monkeypatch): + from specify_cli.integrations.copilot import CopilotIntegration + + # Disable --yolo so the argv shape stays deterministic. + monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") + monkeypatch.setenv( + "SPECIFY_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'" + ) + args = CopilotIntegration().build_exec_args("p") + assert args == [ + "copilot", + "-p", + "p", + "--allow-tool", + "shell(echo)", + "--output-format", + "json", + ]