mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
* docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
634 lines
22 KiB
Python
634 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",
|
|
}
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
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",
|
|
}
|
|
|
|
|
|
@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",
|
|
]
|