Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

from __future__ import annotations

import os
import re
import shlex
import shutil
from abc import ABC
from dataclasses import dataclass
Expand Down Expand Up @@ -138,6 +140,31 @@ def build_exec_args(
"""
return None

def _apply_extra_args_env_var(self, args: list[str]) -> None:
"""Append `SPECIFY_<KEY>_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))
Comment thread
doquanghuy marked this conversation as resolved.

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native slash-command invocation for a Spec Kit command.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
163 changes: 163 additions & 0 deletions tests/integrations/test_extra_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Tests for the per-integration `SPECIFY_<KEY>_EXTRA_ARGS` env-var hook
in `SkillsIntegration.build_exec_args`. See issue #2595."""
Comment thread
doquanghuy marked this conversation as resolved.
Outdated

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):
Comment thread
doquanghuy marked this conversation as resolved.
Outdated
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