chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:57:40 +08:00
commit 923a61929d
462 changed files with 139124 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Unit tests for Spec Kit."""
+21
View File
@@ -0,0 +1,21 @@
"""Shared test helpers for authentication config injection."""
from __future__ import annotations
from specify_cli.authentication.config import AuthConfigEntry
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
"""Build a GitHub ``AuthConfigEntry`` for testing."""
return AuthConfigEntry(
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
provider="github",
auth="bearer",
token_env=token_env,
)
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])
+125
View File
@@ -0,0 +1,125 @@
"""Shared helpers and fakes for bundler tests.
Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched.
Import what you need explicitly, e.g.::
from tests.bundler_helpers import FakeInstaller, write_manifest
"""
from __future__ import annotations
import json
from pathlib import Path
import yaml
from specify_cli.bundler.models.manifest import ComponentRef
def valid_manifest_dict(**overrides) -> dict:
"""Return a structurally valid manifest dict; override any top-level key."""
data = {
"schema_version": "1.0",
"bundle": {
"id": "demo-bundle",
"name": "Demo Bundle",
"version": "1.2.0",
"role": "developer",
"description": "A demo bundle for tests.",
"author": "Spec Kit",
"license": "MIT",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"extensions": [{"id": "ext-a", "version": "1.0.0"}],
"presets": [
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
],
"steps": [{"id": "step-a"}],
"workflows": [{"id": "wf-a", "version": "0.3.0"}],
},
"tags": ["demo", "test"],
}
data.update(overrides)
return data
def write_manifest(directory: Path, data: dict | None = None) -> Path:
directory.mkdir(parents=True, exist_ok=True)
manifest_path = directory / "bundle.yml"
manifest_path.write_text(
yaml.safe_dump(data if data is not None else valid_manifest_dict()),
encoding="utf-8",
)
return manifest_path
def make_project(root: Path) -> Path:
"""Create a minimal Spec Kit project skeleton under *root*."""
(root / ".specify").mkdir(parents=True, exist_ok=True)
return root
def catalog_payload(bundles: dict | None = None) -> dict:
return {
"schema_version": "1.0",
"updated_at": "2026-06-19T00:00:00Z",
"catalog_url": "file://test",
"bundles": bundles or {},
}
def catalog_entry_dict(bundle_id: str = "demo-bundle", **overrides) -> dict:
entry = {
"id": bundle_id,
"name": "Demo Bundle",
"version": "1.2.0",
"role": "developer",
"description": "A demo bundle.",
"author": "Spec Kit",
"license": "MIT",
"download_url": "",
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"extensions": 1, "presets": 1, "steps": 1, "workflows": 1},
"verified": True,
}
entry.update(overrides)
return entry
def write_catalog_file(path: Path, bundles: dict) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(catalog_payload(bundles)), encoding="utf-8")
return path
class FakeInstaller:
"""Deterministic in-memory PrimitiveInstaller for offline integration tests."""
def __init__(self, *, fail_on: str | None = None) -> None:
self.installed: set[tuple[str, str]] = set()
self.install_calls: list[tuple[str, str]] = []
self.remove_calls: list[tuple[str, str]] = []
self.refresh_calls: list[tuple[str, str]] = []
self._fail_on = fail_on
def _key(self, component: ComponentRef) -> tuple[str, str]:
return (component.kind, component.id)
def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
return self._key(component) in self.installed
def install(self, project_root: Path, component: ComponentRef) -> None:
from specify_cli.bundler import BundlerError
self.install_calls.append(self._key(component))
if self._fail_on is not None and component.id == self._fail_on:
raise BundlerError(f"Simulated failure installing {component.id}")
self.installed.add(self._key(component))
def remove(self, project_root: Path, component: ComponentRef) -> None:
self.remove_calls.append(self._key(component))
self.installed.discard(self._key(component))
def refresh(self, project_root: Path, component: ComponentRef) -> None:
self.refresh_calls.append(self._key(component))
self.installed.add(self._key(component))
+166
View File
@@ -0,0 +1,166 @@
"""Shared test helpers for the Spec Kit test suite."""
import os
import re
import shutil
import subprocess
import sys
import pytest
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
def _has_working_bash() -> bool:
"""Check whether a functional native bash is available.
On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
which searches System32 *before* PATH — so it may find the WSL
launcher even when Git-for-Windows bash appears first in PATH via
``shutil.which``. We therefore probe with bare ``"bash"`` (the
same way test helpers invoke it) to get an accurate result.
On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
The WSL launcher is rejected because it runs in a separate Linux
filesystem and cannot handle native Windows paths used by the
test fixtures.
Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
"""
if os.environ.get("SPECKIT_TEST_BASH") == "1":
return True
if shutil.which("bash") is None:
return False
# Probe with bare "bash" — same as the test helpers — so that
# Windows CreateProcess resolution order is respected.
try:
r = subprocess.run(
["bash", "-c", "echo ok"],
capture_output=True, text=True, timeout=5,
)
if r.returncode != 0 or "ok" not in r.stdout:
return False
except (OSError, subprocess.TimeoutExpired):
return False
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
# not the WSL launcher which can't handle native paths.
if sys.platform == "win32":
try:
u = subprocess.run(
["bash", "-c", "uname -s"],
capture_output=True, text=True, timeout=5,
)
kernel = u.stdout.strip().upper()
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
return False
except (OSError, subprocess.TimeoutExpired):
return False
return True
requires_bash = pytest.mark.skipif(
not _has_working_bash(), reason="working bash not available"
)
def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from Rich-formatted CLI output."""
return _ANSI_ESCAPE_RE.sub("", text)
# ---------------------------------------------------------------------------
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _isolate_auth_config(monkeypatch):
"""Ensure no test reads the real ~/.specify/auth.json."""
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", [])
# Also clear the per-process cache so tests that unset _config_override
# won't see a previously cached real-file result.
monkeypatch.setattr(_auth_http, "_config_cache", None)
@pytest.fixture(autouse=True)
def _strip_specify_env(monkeypatch):
"""Drop any inherited SPECIFY_* vars for every test.
The Python CLI's project resolver (`_require_specify_project`) now honors
SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a
developer or CI runner with any SPECIFY_* var exported would silently
retarget (or hard-error) the many command/script tests that resolve a
project. Stripping them here keeps resolution tests deterministic; a test
that wants an override sets it explicitly via monkeypatch afterwards."""
for key in [k for k in os.environ if k.startswith("SPECIFY_")]:
monkeypatch.delenv(key, raising=False)
@pytest.fixture
def clean_environ(monkeypatch):
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
monkeypatch.setenv(env_name, str(tmp_path))
fake_dir = tmp_path.joinpath(*path_parts)
fake_dir.mkdir(parents=True)
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
fake_specify.write_text("#!/usr/bin/env python\n")
fake_specify.chmod(0o755)
monkeypatch.setattr("sys.argv", [str(fake_specify)])
return fake_specify
@pytest.fixture
def uv_tool_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
if os.name == "nt":
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
)
return _fake_self_upgrade_argv0(
monkeypatch,
tmp_path,
"HOME",
(".local", "share", "uv", "tools", "specify-cli", "bin"),
)
@pytest.fixture
def pipx_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
if os.name == "nt":
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
)
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
)
@pytest.fixture
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
if os.name == "nt":
return _fake_self_upgrade_argv0(
monkeypatch,
tmp_path,
"LOCALAPPDATA",
("uv", "cache", "archive-v0", "abc123", "bin"),
)
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
)
@pytest.fixture
def unsupported_argv0(monkeypatch, tmp_path):
"""Point sys.argv[0] at a path that does not match any installer prefix."""
return _fake_self_upgrade_argv0(
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
)
+719
View File
@@ -0,0 +1,719 @@
"""Contract test for the `specify bundle` CLI surface (Typer integration).
Exercises the wired commands end-to-end via CliRunner against a temp project,
asserting exit codes and the cross-cutting error guarantees from
contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error).
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
import pytest
import yaml
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.bundler.services.packager import build_bundle
from tests.bundler_helpers import (
catalog_entry_dict,
valid_manifest_dict,
write_catalog_file,
)
runner = CliRunner()
@pytest.fixture()
def project(tmp_path: Path, monkeypatch) -> Path:
(tmp_path / ".specify").mkdir()
monkeypatch.chdir(tmp_path)
return tmp_path
def test_bundle_help_lists_all_commands():
result = runner.invoke(app, ["bundle", "--help"])
assert result.exit_code == 0
for cmd in ("search", "info", "list", "install", "update", "remove",
"validate", "build", "init", "catalog"):
assert cmd in result.output
def test_update_accepts_integration_override():
# Update must expose --integration so integration-pinned bundles can be
# updated in projects where the active integration can't be auto-detected.
# Rich may insert ANSI escapes between the two leading dashes, so match the
# un-split option word rather than the literal "--integration".
result = runner.invoke(app, ["bundle", "update", "--help"])
assert result.exit_code == 0
assert "integration" in result.output
def test_list_empty_project(project: Path):
result = runner.invoke(app, ["bundle", "list"])
assert result.exit_code == 0
assert "No bundles installed" in result.output
def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "list"])
assert result.exit_code == 1
assert "Spec Kit project" in result.output
def test_fail_writes_error_to_stderr_not_stdout(capsys):
"""_fail must write to stderr, not stdout: every bundle command routes errors
through it, and under --json the error would otherwise corrupt the JSON payload
that consumers read from stdout."""
import typer
from specify_cli.commands.bundle import _fail
with pytest.raises(typer.Exit):
_fail("something broke")
captured = capsys.readouterr()
assert "something broke" in captured.err
assert "something broke" not in captured.out
def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
# Discovery commands fall back to the built-in/user catalog stack and must
# not require a Spec Kit project (matches README/quickstart examples).
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
assert result.exit_code == 0, result.output
assert result.output.strip().startswith("[")
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
# Reaches catalog resolution (not the project gate) and reports a clean miss.
assert result.exit_code == 1
assert "Spec Kit project" not in result.output
def test_catalog_list_shows_builtin_defaults(project: Path):
result = runner.invoke(app, ["bundle", "catalog", "list"])
assert result.exit_code == 0
assert "default" in result.output
assert "community" in result.output
assert "built-in default stack" in result.output
def test_catalog_add_and_remove(project: Path):
catalog = project / "local-catalog.json"
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
listed = runner.invoke(app, ["bundle", "catalog", "list"])
assert "local" in listed.output
removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"])
assert removed.exit_code == 0
def test_catalog_remove_builtin_is_refused(project: Path):
result = runner.invoke(app, ["bundle", "catalog", "remove", "default"])
assert result.exit_code == 1
assert "built-in" in result.output
def test_validate_reports_invalid_manifest(project: Path):
data = valid_manifest_dict()
del data["bundle"]["license"]
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
result = runner.invoke(app, ["bundle", "validate"])
assert result.exit_code == 1
assert "license" in result.output
def test_validate_accepts_valid_manifest(project: Path):
(project / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
# Offline mode does not fail on references it cannot verify (synthetic ids
# here); they surface as warnings while structure is confirmed valid.
result = runner.invoke(app, ["bundle", "validate", "--offline"])
assert result.exit_code == 0, result.output
assert "valid" in result.output
def test_validate_rejects_broken_reference(project: Path):
# Synthetic component ids resolve to nothing in any catalog → hard failure.
(project / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
result = runner.invoke(app, ["bundle", "validate"])
assert result.exit_code == 1
assert "preset-a" in result.output or "ext-a" in result.output
def test_validate_accepts_bundled_reference(project: Path):
data = valid_manifest_dict()
data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
result = runner.invoke(app, ["bundle", "validate"])
assert result.exit_code == 0, result.output
assert "valid" in result.output
def test_build_produces_artifact(project: Path):
(project / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
(project / "README.md").write_text("# Demo", encoding="utf-8")
result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")])
assert result.exit_code == 0, result.output
artifacts = list((project / "dist").glob("*.zip"))
assert len(artifacts) == 1
def test_info_expands_full_component_set(project: Path):
bundle_dir = project / "src-bundle"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
catalog = project / "local-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
)
write_catalog_file(catalog, {"demo-bundle": entry})
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
components = {(c["kind"], c["id"]): c for c in payload["components"]}
assert ("extensions", "ext-a") in components
preset = components[("presets", "preset-a")]
assert preset["version"] == "2.0.0"
assert preset["priority"] == 10
assert preset["strategy"] == "append"
assert payload["trust"] == "verified"
text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
assert "preset-a v2.0.0" in text.output
assert "Trust" in text.output
def test_info_expands_discovery_only_bundle(project: Path):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.
bundle_dir = project / "disc-bundle"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
catalog = project / "disc-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
)
write_catalog_file(catalog, {"demo-bundle": entry})
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "disc", "url": str(catalog), "priority": 1,
"install_policy": "discovery-only"}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
components = {(c["kind"], c["id"]) for c in payload["components"]}
assert ("extensions", "ext-a") in components
def test_info_resolves_local_zip_download_url(project: Path):
# A local .zip artifact as download_url is extracted to read bundle.yml.
bundle_dir = project / "zip-src"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
(bundle_dir / "README.md").write_text("# Demo", encoding="utf-8")
artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path
catalog = project / "zip-catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
)
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
)
assert added.exit_code == 0, added.output
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
components = {(c["kind"], c["id"]) for c in payload["components"]}
assert ("extensions", "ext-a") in components
def test_install_refuses_discovery_only_source(project: Path, monkeypatch):
# Point a discovery-only catalog at a local payload containing the bundle.
catalog = project / "disc.json"
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "disc", "url": str(catalog), "priority": 1,
"install_policy": "discovery-only"}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
result = runner.invoke(app, ["bundle", "install", "demo", "--offline"])
assert result.exit_code == 1
assert "discovery-only" in result.output
def test_update_refuses_discovery_only_source(project: Path):
# An installed bundle whose only resolvable source is discovery-only must
# not be updatable from there (FR-025), mirroring the install policy gate.
from specify_cli.bundler.models.manifest import ComponentRef
from specify_cli.bundler.models.records import (
InstalledBundleRecord,
save_records,
)
save_records(
project,
[
InstalledBundleRecord.create(
"demo",
"1.0.0",
[ComponentRef(kind="extensions", id="ext-a", version=None)],
)
],
)
catalog = project / "disc.json"
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "disc", "url": str(catalog), "priority": 1,
"install_policy": "discovery-only"}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
result = runner.invoke(app, ["bundle", "update", "demo", "--offline"])
assert result.exit_code == 1
assert "discovery-only" in result.output
def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path):
# `info` must expand the real component set; if the manifest can't be
# resolved (here: --offline against an https download_url), it should error
# and exit non-zero rather than silently degrading to `provides` counts.
catalog = project / "remote-catalog.json"
entry = catalog_entry_dict(
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
)
write_catalog_file(catalog, {"demo-bundle": entry})
added = runner.invoke(
app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"]
)
assert added.exit_code == 0, added.output
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
assert result.exit_code == 1
assert "Network access disabled" in result.output
def test_search_json_offline(project: Path):
catalog = project / "c.json"
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "c", "url": str(catalog), "priority": 1,
"install_policy": "install-allowed"}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload[0]["id"] == "demo"
# Trust indicator is exposed on the discovery surface (FR-010 / FR-027).
assert payload[0]["verified"] is True
assert payload[0]["trust"] == "verified"
def test_search_text_shows_trust(project: Path):
catalog = project / "c.json"
write_catalog_file(
catalog,
{
"verified-one": catalog_entry_dict("verified-one", verified=True),
"community-one": catalog_entry_dict("community-one", verified=False),
},
)
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "c", "url": str(catalog), "priority": 1,
"install_policy": "install-allowed"}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
result = runner.invoke(app, ["bundle", "search", "--offline"])
assert result.exit_code == 0, result.output
assert "verified" in result.output
assert "community" in result.output
def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
# An initialized project's recorded active integration is authoritative:
# passing --integration must not let a differently-pinned bundle install.
import json
(project / ".specify" / "integration.json").write_text(
json.dumps({"integration": "copilot"}), encoding="utf-8"
)
bundle_dir = project / "claude-bundle"
bundle_dir.mkdir()
data = valid_manifest_dict(integration={"id": "claude"})
(bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
(bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8")
result = runner.invoke(
app,
["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"],
)
assert result.exit_code == 1
assert "claude" in result.output and "copilot" in result.output
# ===== Private GitHub release asset URL resolution =====
class FakeBundleResponse:
"""Minimal context-manager response stub for open_url fakes."""
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
self._data = data
self._url = url
def read(self) -> bytes:
return self._data
def geturl(self) -> str:
return self._url
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""
config = {
"schema_version": "1.0",
"catalogs": [
{
"id": "test",
"url": str(catalog_path),
"priority": 1,
"install_policy": "install-allowed",
}
],
}
(project / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
def test_bundle_info_resolves_github_browser_release_url(project: Path):
"""bundle info resolves a private-repo browser release URL via the GitHub API."""
browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml"
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
captured = []
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured.append((url, extra_headers))
if "releases/tags/" in url:
# GitHub API release-tags lookup — return asset list
return FakeBundleResponse(
json.dumps({
"assets": [{"name": "bundle.yml", "url": api_asset_url}]
}).encode(),
url=url,
)
# Actual asset download
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
catalog = project / "catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
)
_make_catalog_config(catalog, project)
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
assert result.exit_code == 0, result.output
# The browser release URL must have been resolved via the GitHub tags API
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}"
assert "releases/tags/v1.0" in tag_calls[0]
# The actual download must use the resolved API asset URL with octet-stream
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
assert len(asset_calls) == 1
assert asset_calls[0][0] == api_asset_url
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
def test_bundle_info_passes_through_api_asset_url(project: Path):
"""bundle info passes a direct GitHub API asset URL through with octet-stream."""
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"
captured = []
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured.append((url, extra_headers))
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
catalog = project / "catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
)
_make_catalog_config(catalog, project)
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
assert result.exit_code == 0, result.output
# No tags API call — URL was already a REST asset URL
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
assert len(tag_calls) == 0
# Exactly one download call to the asset URL with octet-stream
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
assert len(asset_calls) == 1
assert asset_calls[0][0] == api_asset_url
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
def test_bundle_info_resolves_github_browser_release_url_zip(project: Path):
"""bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml."""
import io
import zipfile
browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip"
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88"
# Build a minimal in-memory ZIP containing bundle.yml
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
zip_bytes = buf.getvalue()
captured = []
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured.append((url, extra_headers))
if "releases/tags/" in url:
return FakeBundleResponse(
json.dumps({
"assets": [{"name": "bundle.zip", "url": api_asset_url}]
}).encode(),
url=url,
)
return FakeBundleResponse(zip_bytes, url=api_asset_url)
catalog = project / "catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
)
_make_catalog_config(catalog, project)
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
assert result.exit_code == 0, result.output
# tags API lookup must have fired
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
assert len(tag_calls) == 1
assert "releases/tags/v2.0" in tag_calls[0]
# Asset download uses the resolved API URL with octet-stream
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
assert len(asset_calls) == 1
assert asset_calls[0][0] == api_asset_url
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
# Manifest was successfully parsed from the ZIP
payload = json.loads(result.output)
assert payload["id"] == "demo-bundle"
def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path):
"""bundle info correctly handles a direct API asset URL that serves ZIP bytes."""
import io
import zipfile
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55"
# Build a minimal in-memory ZIP containing bundle.yml
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
zip_bytes = buf.getvalue()
captured = []
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured.append((url, extra_headers))
return FakeBundleResponse(zip_bytes, url=api_asset_url)
catalog = project / "catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
)
_make_catalog_config(catalog, project)
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
assert result.exit_code == 0, result.output
# No tags API call — URL was already a REST asset URL
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
assert len(tag_calls) == 0
# Download used octet-stream header
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
assert len(asset_calls) == 1
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
# ZIP bytes were detected by magic and bundle.yml extracted correctly
payload = json.loads(result.output)
assert payload["id"] == "demo-bundle"
def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors(project: Path):
"""When the GitHub tags API lookup finds no matching asset, fall back to the
original browser URL and surface a meaningful error (not a raw traceback)."""
browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml"
captured = []
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured.append((url, extra_headers))
if "releases/tags/" in url:
# Tags API responds but the asset list doesn't include our file
return FakeBundleResponse(
json.dumps({"assets": []}).encode(),
url=url,
)
# Fallback download: GitHub serves HTML (SSO redirect) instead of YAML
return FakeBundleResponse(b"<html>SSO login required</html>", url=url)
catalog = project / "catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
)
_make_catalog_config(catalog, project)
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
# Must exit non-zero — the HTML body is not a valid bundle manifest
assert result.exit_code == 1
# The tags API lookup must have fired
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
assert len(tag_calls) == 1
# The fallback download should use the original browser URL (no octet-stream)
fallback_calls = [(url, h) for url, h in captured if url == browser_url]
assert len(fallback_calls) == 1
assert fallback_calls[0][1] is None # no Accept header on the original URL
# Error output must be actionable (not a raw traceback)
assert "Error:" in result.output
def test_bundle_info_resolves_ghes_browser_release_url(project: Path):
"""bundle info resolves a GHES private-repo browser release URL via /api/v3."""
ghes_host = "ghes.example"
browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml"
api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42"
captured = []
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
captured.append((url, extra_headers))
if "/api/v3/repos/" in url and "releases/tags/" in url:
return FakeBundleResponse(
json.dumps({
"assets": [{"name": "bundle.yml", "url": api_asset_url}]
}).encode(),
url=url,
)
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
catalog = project / "catalog.json"
write_catalog_file(
catalog,
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
)
_make_catalog_config(catalog, project)
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
patch("specify_cli.authentication.http.github_provider_hosts", return_value=(ghes_host,)):
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
assert result.exit_code == 0, result.output
# The GHES /api/v3 tags lookup must have fired
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
assert len(tag_calls) == 1
assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0]
# Asset download must use the resolved GHES API URL with octet-stream
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
assert len(asset_calls) == 1
assert asset_calls[0][0] == api_asset_url
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
payload = json.loads(result.output)
assert payload["id"] == "demo-bundle"
+147
View File
@@ -0,0 +1,147 @@
"""Contract tests for the catalog schema and source stack.
Mirrors contracts/bundle-catalog.schema.md: source precedence project > user >
built-in, install policy gating, payload parsing.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from specify_cli.bundler.models.catalog import (
BUILTIN_DEFAULT_STACK,
CatalogSource,
InstallPolicy,
Scope,
load_catalog_payload,
load_source_stack,
)
from specify_cli.bundler import BundlerError
import pytest
from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project
def test_non_integer_source_priority_raises_actionable_error():
with pytest.raises(BundlerError, match="non-integer priority"):
CatalogSource.from_dict(
{"id": "corp", "url": "https://corp/catalog.json", "priority": "high"},
Scope.PROJECT,
)
def test_builtin_default_stack_when_no_config(tmp_path: Path):
make_project(tmp_path)
sources = load_source_stack(tmp_path)
ids = [s.id for s in sources]
assert ids == ["default", "community"]
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
assert all(s.scope is Scope.BUILTIN for s in sources)
def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {
"schema_version": "1.0",
"catalogs": [
{"id": "default", "url": "file://local", "priority": 1,
"install_policy": "install-allowed"},
{"id": "corp", "url": "https://corp/catalog.json", "priority": 0,
"install_policy": "install-allowed"},
],
}
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
yaml.safe_dump(config), encoding="utf-8"
)
sources = load_source_stack(tmp_path)
by_id = {s.id: s for s in sources}
assert by_id["default"].scope is Scope.PROJECT
assert by_id["default"].url == "file://local"
# Highest precedence (lowest priority number) sorts first.
assert sources[0].id == "corp"
def test_user_scope_between_builtin_and_project(tmp_path: Path):
make_project(tmp_path)
user_dir = tmp_path / "userconf"
user_dir.mkdir()
(user_dir / "bundle-catalogs.yml").write_text(
yaml.safe_dump(
{"catalogs": [
{"id": "community", "url": "https://u", "priority": 2,
"install_policy": "install-allowed"}
]}
),
encoding="utf-8",
)
sources = load_source_stack(tmp_path, user_config_dir=user_dir)
by_id = {s.id: s for s in sources}
# User overrode the built-in community policy to install-allowed.
assert by_id["community"].scope is Scope.USER
assert by_id["community"].install_allowed is True
def test_load_payload_parses_entries():
payload = catalog_payload({"demo-bundle": catalog_entry_dict()})
entries = load_catalog_payload(payload)
assert "demo-bundle" in entries
assert entries["demo-bundle"].version == "1.2.0"
assert entries["demo-bundle"].provides["presets"] == 1
def test_builtin_default_stack_constant_shape():
ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
assert ids == {"default", "community"}
def test_catalog_entry_rejects_string_tags():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["tags"] = "not-a-list"
with pytest.raises(BundlerError, match="'tags' must be a list"):
CatalogEntry.from_dict(data)
def test_catalog_entry_rejects_non_boolean_verified():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["verified"] = "false" # truthy string must not mark the entry verified
with pytest.raises(BundlerError, match="'verified' must be a boolean"):
CatalogEntry.from_dict(data)
def test_load_payload_rejects_id_key_mismatch():
# The enclosing key is authoritative; an entry whose own id disagrees with
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")})
with pytest.raises(BundlerError, match="id mismatch"):
load_catalog_payload(payload)
def test_load_payload_rejects_missing_entry_id():
entry = catalog_entry_dict("demo-bundle")
entry["id"] = ""
payload = catalog_payload({"demo-bundle": entry})
with pytest.raises(BundlerError, match="missing its 'id'"):
load_catalog_payload(payload)
def test_catalog_entry_rejects_non_mapping_requires():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["requires"] = "speckit>=0.1"
with pytest.raises(BundlerError, match="'requires' must be a mapping"):
CatalogEntry.from_dict(data)
def test_catalog_entry_rejects_non_mapping_provides():
from specify_cli.bundler.models.catalog import CatalogEntry
data = catalog_entry_dict("demo")
data["provides"] = "extensions"
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
CatalogEntry.from_dict(data)
+126
View File
@@ -0,0 +1,126 @@
"""Contract tests for the bundle manifest schema (bundle.yml).
Mirrors contracts/bundle-manifest.schema.md: required identity/metadata fields,
semver pinning of components, preset priority+strategy, integration optionality.
"""
from __future__ import annotations
import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.manifest import BundleManifest
from tests.bundler_helpers import valid_manifest_dict
def test_valid_manifest_has_no_structural_errors():
manifest = BundleManifest.from_dict(valid_manifest_dict())
assert manifest.structural_errors() == []
assert manifest.bundle.id == "demo-bundle"
assert manifest.is_agnostic() is True
def test_missing_required_field_is_reported_by_name():
data = valid_manifest_dict()
del data["bundle"]["license"]
errors = BundleManifest.from_dict(data).structural_errors()
assert any("bundle.license" in e for e in errors)
def test_unsupported_schema_version_is_rejected():
data = valid_manifest_dict(schema_version="9.9")
errors = BundleManifest.from_dict(data).structural_errors()
assert any("schema_version" in e for e in errors)
def test_non_semver_bundle_version_is_rejected():
data = valid_manifest_dict()
data["bundle"]["version"] = "not-a-version"
errors = BundleManifest.from_dict(data).structural_errors()
assert any("semver" in e for e in errors)
def test_preset_requires_priority_and_strategy():
data = valid_manifest_dict()
data["provides"]["presets"] = [{"id": "p", "version": "1.0.0"}]
errors = BundleManifest.from_dict(data).structural_errors()
assert any("priority" in e for e in errors)
assert any("strategy" in e for e in errors)
def test_invalid_preset_strategy_is_rejected():
data = valid_manifest_dict()
data["provides"]["presets"][0]["strategy"] = "merge"
errors = BundleManifest.from_dict(data).structural_errors()
assert any("strategy" in e for e in errors)
def test_non_integer_priority_raises_actionable_error():
data = valid_manifest_dict()
data["provides"]["presets"][0]["priority"] = "high"
with pytest.raises(BundlerError, match="priority must be an integer"):
BundleManifest.from_dict(data)
def test_non_step_components_must_be_pinned():
data = valid_manifest_dict()
data["provides"]["extensions"] = [{"id": "ext-unpinned"}]
errors = BundleManifest.from_dict(data).structural_errors()
assert any("must be pinned" in e for e in errors)
def test_steps_may_be_unpinned():
data = valid_manifest_dict()
data["provides"]["steps"] = [{"id": "step-x"}]
manifest = BundleManifest.from_dict(data)
assert manifest.structural_errors() == []
def test_integration_makes_bundle_non_agnostic():
data = valid_manifest_dict(integration={"id": "copilot"})
manifest = BundleManifest.from_dict(data)
assert manifest.is_agnostic() is False
assert manifest.integration.id == "copilot"
def test_components_property_orders_by_kind():
manifest = BundleManifest.from_dict(valid_manifest_dict())
kinds = [c.kind for c in manifest.components]
assert kinds == ["extensions", "presets", "steps", "workflows"]
def test_string_tags_rejected_not_split_per_character():
# A bare string would otherwise be iterated character-by-character; the
# schema requires a list of strings.
data = valid_manifest_dict()
data["tags"] = "security"
with pytest.raises(BundlerError, match="'tags' must be a list of strings"):
BundleManifest.from_dict(data)
def test_unsafe_bundle_id_flagged_by_structural_validation():
data = valid_manifest_dict()
data["bundle"]["id"] = "../evil"
manifest = BundleManifest.from_dict(data)
errors = manifest.structural_errors()
assert any("bundle.id" in e and "slug" in e for e in errors)
def test_valid_slug_bundle_id_passes():
data = valid_manifest_dict()
data["bundle"]["id"] = "team-a.bundle_1"
manifest = BundleManifest.from_dict(data)
assert not any("bundle.id" in e for e in manifest.structural_errors())
def test_string_tools_rejected_not_split_per_character():
data = valid_manifest_dict()
data["requires"]["tools"] = "docker"
with pytest.raises(BundlerError, match="'requires.tools' must be a list of strings"):
BundleManifest.from_dict(data)
def test_string_mcp_rejected_not_split_per_character():
data = valid_manifest_dict()
data["requires"]["mcp"] = "github"
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
BundleManifest.from_dict(data)
+1
View File
@@ -0,0 +1 @@
"""Extensions test package."""
View File
+113
View File
@@ -0,0 +1,113 @@
"""Tests for the bundled ``bug`` extension.
Validates:
- Bundled layout (manifest, README, three command files)
- Catalog registration
- Wheel/source-checkout resolution via ``_locate_bundled_extension``
- Install via ``ExtensionManager.install_from_directory`` copies the three
command files and records them in the installed manifest (command
registration with AI agents is exercised separately and not asserted here)
"""
from __future__ import annotations
import json
from pathlib import Path
import yaml
from specify_cli import _locate_bundled_extension
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "bug"
EXPECTED_COMMANDS = {
"speckit.bug.assess",
"speckit.bug.fix",
"speckit.bug.test",
}
# ── Bundled extension layout ─────────────────────────────────────────────────
class TestExtensionLayout:
def test_extension_yml_exists(self):
assert (EXT_DIR / "extension.yml").is_file()
def test_extension_yml_has_required_fields(self):
manifest = yaml.safe_load(
(EXT_DIR / "extension.yml").read_text(encoding="utf-8")
)
assert manifest["extension"]["id"] == "bug"
assert manifest["extension"]["name"] == "Bug Triage Workflow"
assert manifest["extension"]["author"] == "spec-kit-core"
commands = {c["name"] for c in manifest["provides"]["commands"]}
assert commands == EXPECTED_COMMANDS
def test_readme_exists(self):
readme = EXT_DIR / "README.md"
assert readme.is_file()
text = readme.read_text(encoding="utf-8")
assert "Bug Triage Workflow Extension" in text
def test_command_files_exist(self):
for name in EXPECTED_COMMANDS:
cmd = EXT_DIR / "commands" / f"{name}.md"
assert cmd.is_file(), f"Missing command file: {cmd}"
# ── Catalog registration ─────────────────────────────────────────────────────
class TestCatalogEntry:
def test_catalog_lists_bug_as_bundled(self):
catalog = json.loads(
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
)
entry = catalog["extensions"]["bug"]
assert entry["bundled"] is True
assert entry["id"] == "bug"
assert entry["author"] == "spec-kit-core"
# ── Bundle resolution ────────────────────────────────────────────────────────
class TestBundleResolution:
def test_locate_bundled_extension_finds_bug(self):
located = _locate_bundled_extension("bug")
assert located is not None
assert (located / "extension.yml").is_file()
# ── Install ──────────────────────────────────────────────────────────────────
class TestExtensionInstall:
def test_install_from_directory(self, tmp_path: Path):
from specify_cli.extensions import ExtensionManager
(tmp_path / ".specify").mkdir()
manager = ExtensionManager(tmp_path)
manifest = manager.install_from_directory(EXT_DIR, "0.9.0", register_commands=False)
assert manifest.id == "bug"
assert manager.registry.is_installed("bug")
# All three command files are copied into the installed extension dir
installed = tmp_path / ".specify" / "extensions" / "bug"
for name in EXPECTED_COMMANDS:
assert (installed / "commands" / f"{name}.md").is_file()
def test_install_command_names(self, tmp_path: Path):
"""The installed manifest exposes the expected command names."""
from specify_cli.extensions import ExtensionManager
(tmp_path / ".specify").mkdir()
manager = ExtensionManager(tmp_path)
manifest = manager.install_from_directory(EXT_DIR, "0.9.0", register_commands=False)
names = {c["name"] for c in manifest.commands}
assert names == EXPECTED_COMMANDS
+1
View File
@@ -0,0 +1 @@
"""Tests for the bundled git extension."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
"""Static guard: the Specify CLI source must contain no agent-context lifecycle code.
The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The
Python codebase (``src/specify_cli/**``) must therefore not reference any of the
removed context-section management helpers, the extension config helpers, the
context markers, or the obsolete deprecation message.
Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003.
"""
from __future__ import annotations
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli"
FORBIDDEN_SYMBOLS = [
"upsert_context_section",
"remove_context_section",
"_agent_context_extension_enabled",
"_resolve_context_markers",
"_resolve_context_files",
"_resolve_context_file_values",
"_build_context_section",
"_AGENT_CTX_EXT_CONFIG",
"_load_agent_context_config",
"_save_agent_context_config",
"_update_agent_context_config_file",
"CONTEXT_MARKER_START",
"CONTEXT_MARKER_END",
"agent-context-config",
"agent_context_config",
"__CONTEXT_FILE__",
"_context_file_display",
"Inline agent-context updates",
"v0.12.0",
]
@pytest.fixture(scope="module")
def cli_source_texts() -> list[tuple[str, str]]:
"""Read every CLI source file once, shared across all parametrized cases."""
return [
(str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8"))
for path in SRC_ROOT.rglob("*.py")
]
@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS)
def test_symbol_absent_from_cli_source(symbol, cli_source_texts):
offenders = [rel for rel, text in cli_source_texts if symbol in text]
assert not offenders, (
f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}"
)
@@ -0,0 +1,989 @@
"""Tests for the bundled ``agent-context`` extension and related plumbing."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from specify_cli import (
save_init_options,
)
from specify_cli.agents import CommandRegistrar
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
BASH = shutil.which("bash")
POWERSHELL = (
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
)
# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a
# test needs to exercise a 5.1-specific code path; fall back to whatever
# POWERSHELL resolves to elsewhere.
WINDOWS_POWERSHELL = (
(shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL)
if os.name == "nt"
else POWERSHELL
)
def _write_ext_config(project_root: Path, **overrides: object) -> None:
"""Write a minimal agent-context extension config directly.
The CLI no longer owns the extension config — the bundled extension does —
so tests write it themselves rather than going through any CLI helper.
"""
cfg: dict = {
"context_file": overrides.get("context_file", ""),
"context_files": overrides.get("context_files", []),
"context_markers": overrides.get(
"context_markers",
{
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
),
}
path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False),
encoding="utf-8",
)
# ── Bundled extension layout ─────────────────────────────────────────────────
class TestExtensionLayout:
"""The bundled agent-context extension ships a complete package."""
def test_extension_yml_exists(self):
assert (EXT_DIR / "extension.yml").is_file()
def test_extension_yml_has_required_fields(self):
manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text())
assert manifest["extension"]["id"] == "agent-context"
assert manifest["extension"]["name"] == "Coding Agent Context"
assert manifest["extension"]["author"] == "spec-kit-core"
# Provides at least the manual update command
commands = {c["name"] for c in manifest["provides"]["commands"]}
assert "speckit.agent-context.update" in commands
def test_readme_exists(self):
readme = EXT_DIR / "README.md"
assert readme.is_file()
text = readme.read_text(encoding="utf-8")
assert "Coding Agent Context Extension" in text
def test_config_template_exists(self):
cfg = EXT_DIR / "agent-context-config.yml"
assert cfg.is_file()
parsed = yaml.safe_load(cfg.read_text(encoding="utf-8"))
assert "context_file" in parsed
assert "context_markers" in parsed
def test_command_file_exists(self):
cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md"
assert cmd.is_file()
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
def test_command_file_documents_context_file_constraints(self):
text = (
EXT_DIR / "commands" / "speckit.agent-context.update.md"
).read_text(encoding="utf-8")
assert "context file(s)" in text
assert "Windows drive paths" in text
assert "backslash separators" in text
def test_bundled_scripts_exist(self):
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
def test_bash_script_reads_extension_config(self):
text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text(
encoding="utf-8"
)
# The script must consult the extension config, not init-options.json
assert "agent-context-config.yml" in text
assert "context_file" in text
assert "context_markers" in text
# ── Catalog registration ─────────────────────────────────────────────────────
class TestCatalogEntry:
def test_catalog_lists_agent_context_as_bundled(self):
catalog = json.loads(
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
)
entry = catalog["extensions"]["agent-context"]
assert entry["bundled"] is True
assert entry["id"] == "agent-context"
assert entry["author"] == "spec-kit-core"
def _install_agent_context_config(project_root: Path, **overrides: object) -> None:
_write_ext_config(project_root, **overrides)
# Mirror the real install layout: the extension ships its own
# agent->context-file defaults map alongside the config. Self-seeding
# tests depend on it, so require it to exist and always copy it rather
# than silently skipping when it is missing.
defaults_src = EXT_DIR / "agent-context-defaults.json"
assert defaults_src.is_file(), (
f"bundled agent-context defaults map missing: {defaults_src}"
)
defaults_dst = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-defaults.json"
)
defaults_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(defaults_src, defaults_dst)
def _bash_posix_path(path: Path) -> str:
"""Convert a Windows path to the POSIX form used by the available bash."""
resolved = str(path.resolve())
if os.name != "nt":
return resolved
if BASH:
converted = subprocess.run(
[
BASH,
"-lc",
"command -v cygpath >/dev/null 2>&1 && cygpath -u \"$1\"",
"bash",
resolved,
],
capture_output=True,
text=True,
timeout=30,
)
if converted.returncode == 0 and converted.stdout.strip():
return converted.stdout.strip()
drive = path.drive.rstrip(":").lower()
posix = path.as_posix()
return f"/mnt/{drive}{posix[2:]}" if drive else posix
def _ensure_test_python_on_path(project_root: Path) -> Path:
"""Create python/python3 shims that run the current pytest interpreter."""
shim_dir = project_root / ".test-python-bin"
shim_dir.mkdir(exist_ok=True)
python_exe = Path(sys.executable).resolve()
shell_python = _bash_posix_path(python_exe)
for name in ("python", "python3"):
shell_shim = shim_dir / name
shell_shim.write_text(
f"#!/usr/bin/env sh\nexec {shlex_quote(shell_python)} \"$@\"\n",
encoding="utf-8",
newline="\n",
)
shell_shim.chmod(0o755)
if os.name == "nt":
cmd_shim = shim_dir / f"{name}.cmd"
cmd_shim.write_text(
f'@echo off\r\n"{python_exe}" %*\r\n',
encoding="utf-8",
)
return shim_dir
def _current_pythonpath() -> str:
"""Return sys.path entries needed by child script interpreters."""
entries = [
entry
for entry in sys.path
if isinstance(entry, str) and entry
]
existing = os.environ.get("PYTHONPATH")
if existing:
entries.extend(entry for entry in existing.split(os.pathsep) if entry)
return os.pathsep.join(dict.fromkeys(entries))
def _bundled_script_env(
project_root: Path,
*,
for_bash: bool = False,
speckit_python: str | None = None,
) -> dict[str, str]:
env = os.environ.copy()
shim_dir = _ensure_test_python_on_path(project_root)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
env["SPECKIT_PYTHON"] = (
speckit_python
if speckit_python is not None
else (_bash_posix_path(Path(sys.executable)) if for_bash else sys.executable)
)
pythonpath = _current_pythonpath()
if pythonpath:
env["PYTHONPATH"] = pythonpath
return env
def _run_bash_agent_context_script(
project_root: Path,
*,
speckit_python: str | None = None,
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
env = _bundled_script_env(
project_root,
for_bash=True,
speckit_python=speckit_python,
)
if os.name == "nt":
root = _bash_posix_path(project_root)
script_path = _bash_posix_path(script)
shim_dir = _bash_posix_path(_ensure_test_python_on_path(project_root))
command = (
f"export PATH={shlex_quote(shim_dir)}:\"$PATH\"; "
f"cd {shlex_quote(root)} && {shlex_quote(script_path)}"
)
return subprocess.run(
[BASH, "-lc", command],
env=env,
capture_output=True,
text=True,
timeout=30,
)
return subprocess.run(
[BASH, str(script)],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
def shlex_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _run_powershell_agent_context_script(
project_root: Path, powershell: str | None = None
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root)
return subprocess.run(
[
powershell or POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
def _run_powershell_agent_context_script_with_env(
project_root: Path,
*,
speckit_python: str,
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root, speckit_python=speckit_python)
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
class TestBundledUpdaterPathValidation:
def test_bundled_script_env_makes_yaml_importable(self, tmp_path):
env = _bundled_script_env(tmp_path)
result = subprocess.run(
[env["SPECKIT_PYTHON"], "-c", "import yaml"],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_bash_script_trims_context_file_fallback(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file=" AGENTS.md ",
context_files=[],
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
assert not (project / " AGENTS.md ").exists()
@requires_bash
def test_bash_script_rejects_symlink_escape(self, tmp_path):
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["link/out.md"],
)
if os.name == "nt":
root = _bash_posix_path(tmp_path)
create_link = subprocess.run(
[
BASH,
"-lc",
f"ln -s {shlex_quote(root + '/outside')} "
f"{shlex_quote(root + '/project/link')}",
],
capture_output=True,
text=True,
timeout=30,
)
if create_link.returncode != 0:
pytest.skip(f"symlink unavailable: {create_link.stderr}")
else:
try:
(project / "link").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlink unavailable: {exc}")
result = _run_bash_agent_context_script(project)
assert result.returncode == 1
assert "resolves outside the project root" in result.stderr
assert not (outside / "out.md").exists()
@requires_bash
def test_bash_script_deduplicates_context_files_in_order(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
output = result.stderr + result.stdout
assert output.count("agent-context: updated AGENTS.md") == 1
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@requires_bash
def test_bash_script_discovers_nested_plan(self, tmp_path):
"""Plan discovery recurses into scoped layouts (#3024)."""
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=[],
)
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
plan.parent.mkdir(parents=True)
plan.write_text("# Plan\n", encoding="utf-8")
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
# The old one-level glob (specs/*/plan.md) would find nothing here, so no
# "at" line would be emitted. Normalize separators before matching: on
# MSYS bash the emitted path may be absolute with backslashes.
assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/")
@requires_bash
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
result = _run_bash_agent_context_script(
project,
speckit_python="/definitely/missing/python",
)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_rejects_backslash_context_files(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["nested\\AGENTS.md"],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "must not contain backslash separators" in (
result.stderr + result.stdout
)
assert not (project / "nested" / "AGENTS.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_rejects_drive_qualified_context_files(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["C:tmp/outside.md"],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "must be project-relative paths" in (result.stderr + result.stdout)
assert not (project / "tmp" / "outside.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
output = result.stderr + result.stdout
assert output.count("agent-context: updated AGENTS.md") == 1
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_discovers_nested_plan(self, tmp_path):
"""Plan discovery recurses into scoped layouts (#3024).
The relative-path fix this covers is specific to Windows PowerShell 5.1
(.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to
actually exercise that failure mode on Windows.
"""
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=[],
)
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
plan.parent.mkdir(parents=True)
plan.write_text("# Plan\n", encoding="utf-8")
result = _run_powershell_agent_context_script(
project, powershell=WINDOWS_POWERSHELL
)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "at specs/scope/001-feature/plan.md" in text
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
result = _run_powershell_agent_context_script_with_env(
project,
speckit_python=str(project / "missing-python"),
)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
@pytest.mark.skipif(
POWERSHELL is None or os.name != "nt",
reason="Windows PowerShell junction test requires Windows",
)
def test_powershell_script_rejects_junction_escape(self, tmp_path):
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["link/out.md"],
)
create_link = subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
(
"New-Item -ItemType Junction "
f"-Path {str(project / 'link')!r} "
f"-Target {str(outside)!r} | Out-Null"
),
],
capture_output=True,
text=True,
timeout=30,
)
if create_link.returncode != 0:
pytest.skip(f"junction unavailable: {create_link.stderr}")
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "resolves outside the project root" in (result.stderr + result.stdout)
assert not (outside / "out.md").exists()
# ── CLI does not resolve agent context placeholders ──────────────────────────
class TestSkillPlaceholderContextResolution:
"""The CLI no longer resolves any ``__CONTEXT_FILE__`` placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the CLI neither reads integration metadata nor the
extension config when rendering commands/skills.
"""
def test_cli_does_not_resolve_context_placeholder(self, tmp_path):
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read __CONTEXT_FILE__"
def test_extension_config_does_not_influence_resolution(self, tmp_path):
# Even a populated extension config must not influence resolution.
_write_ext_config(
tmp_path,
context_file="FROM_CONFIG.md",
context_files=["ALSO_CONFIG.md"],
)
content = CommandRegistrar.resolve_skill_placeholders(
"claude",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert "FROM_CONFIG.md" not in content
assert "ALSO_CONFIG.md" not in content
assert content == "Read __CONTEXT_FILE__"
# ── CLI no longer owns the agent-context extension config ────────────────────
class TestCliDoesNotManageExtensionConfig:
"""The Python codebase must not read or write the extension config."""
def test_config_helpers_are_removed(self):
import specify_cli
for name in (
"_load_agent_context_config",
"_save_agent_context_config",
"_update_agent_context_config_file",
"_AGENT_CTX_EXT_CONFIG",
):
assert not hasattr(specify_cli, name), name
def test_no_agent_context_config_symbols_in_source(self):
src = PROJECT_ROOT / "src" / "specify_cli"
offenders = []
for path in src.rglob("*.py"):
text = path.read_text(encoding="utf-8")
if "agent-context-config" in text or "agent_context_config" in text:
offenders.append(str(path.relative_to(PROJECT_ROOT)))
assert not offenders, offenders
def test_update_init_options_does_not_create_ext_config(self, tmp_path):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations._helpers import (
_update_init_options_for_integration,
)
_update_init_options_for_integration(
tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh"
)
cfg = (
tmp_path
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
assert not cfg.exists()
def test_clear_init_options_does_not_create_ext_config(self, tmp_path):
from specify_cli.integrations._helpers import (
_clear_init_options_for_integration,
)
save_init_options(tmp_path, {"integration": "claude", "ai": "claude"})
_clear_init_options_for_integration(tmp_path, "claude")
cfg = (
tmp_path
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
assert not cfg.exists()
# ── Extension self-seeds its target from the active integration ──────────────
class TestExtensionSelfSeed:
"""When its own config declares no target, the bundled extension derives
the context file from the active integration using its OWN bundled
agent->context-file defaults map (no Specify CLI dependency)."""
@requires_bash
def test_bash_script_self_seeds_from_active_integration(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
# Config present but empty — no context_file / context_files.
_install_agent_context_config(project, context_file="", context_files=[])
# Active integration recorded in init-options.json (codex -> AGENTS.md).
save_init_options(project, {"integration": "codex", "ai": "codex"})
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
assert "<!-- SPECKIT START -->" in (
project / "AGENTS.md"
).read_text(encoding="utf-8")
@requires_bash
def test_bash_script_nothing_to_do_without_integration(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file="", context_files=[])
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
assert "nothing to do" in (result.stderr + result.stdout)
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
class TestPlanDiscovery:
"""Mtime fallback must find plans in nested spec layouts (#3024).
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
glob never matches those.
"""
@staticmethod
def _make_plans(project: Path) -> Path:
# Older flat plan plus a newer nested plan: recursive discovery
# must pick the nested one by mtime.
flat = project / "specs" / "old-feature" / "plan.md"
flat.parent.mkdir(parents=True)
flat.write_text("flat plan\n", encoding="utf-8")
os.utime(flat, (1_000_000_000, 1_000_000_000))
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
nested.parent.mkdir(parents=True)
nested.write_text("nested plan\n", encoding="utf-8")
return nested
@requires_bash
def test_bash_script_finds_nested_plan(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
self._make_plans(project)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
content = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "specs/scope/new-feature/plan.md" in content
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_finds_nested_plan(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
self._make_plans(project)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
content = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "specs/scope/new-feature/plan.md" in content
class TestMdcFrontmatter:
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""
@requires_bash
def test_bash_script_prepends_mdc_frontmatter(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8")
assert text.startswith("---\nalwaysApply: true\n---\n")
assert "<!-- SPECKIT START -->" in text
@requires_bash
def test_bash_script_mdc_frontmatter_is_idempotent(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
_run_bash_agent_context_script(project)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8")
assert text.count("alwaysApply: true") == 1
@requires_bash
def test_bash_script_repairs_existing_mdc_frontmatter(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
target = project / _MDC_CONTEXT_FILE
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
"---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n",
encoding="utf-8",
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = target.read_text(encoding="utf-8")
assert "alwaysApply: true" in text
assert "alwaysApply: false" not in text
assert "description: My rules" in text
assert "User notes" in text
@requires_bash
def test_bash_script_skips_frontmatter_for_non_mdc(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file="AGENTS.md")
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "alwaysApply" not in text
assert text.startswith("<!-- SPECKIT START -->")
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_prepends_mdc_frontmatter(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8")
assert text.startswith("---\nalwaysApply: true\n---\n")
assert "<!-- SPECKIT START -->" in text
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_repairs_existing_mdc_frontmatter(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
target = project / _MDC_CONTEXT_FILE
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
"---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n",
encoding="utf-8",
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = target.read_text(encoding="utf-8")
assert "alwaysApply: true" in text
assert "alwaysApply: false" not in text
assert "description: My rules" in text
assert "User notes" in text
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_skips_frontmatter_for_non_mdc(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(project, context_file="AGENTS.md")
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
text = (project / "AGENTS.md").read_text(encoding="utf-8")
assert "alwaysApply" not in text
assert text.startswith("<!-- SPECKIT START -->")
_LEGACY_CONTEXT = (
"# CLAUDE.md\n\n"
"Some user notes.\n\n"
"<!-- SPECKIT START -->\n"
"Legacy managed section written by an older Spec Kit version.\n"
"<!-- SPECKIT END -->\n\n"
"More user notes.\n"
)
class TestBackwardCompatibility:
"""Legacy projects must keep working; the CLI never touches their artifacts."""
def _seed_legacy_project(self, project_root: Path) -> Path:
ctx = project_root / "CLAUDE.md"
ctx.write_text(_LEGACY_CONTEXT, encoding="utf-8")
_write_ext_config(project_root, context_file="CLAUDE.md")
save_init_options(project_root, {"integration": "claude", "ai": "claude"})
return ctx
def test_integration_setup_leaves_legacy_artifacts_untouched(self, tmp_path):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "legacy"
project.mkdir()
ctx = self._seed_legacy_project(project)
cfg_path = (
project / ".specify" / "extensions" / "agent-context"
/ "agent-context-config.yml"
)
before_ctx = ctx.read_text(encoding="utf-8")
before_cfg = cfg_path.read_text(encoding="utf-8")
integration = INTEGRATION_REGISTRY["claude"]
m = IntegrationManifest("claude", project)
integration.setup(project, m)
assert ctx.read_text(encoding="utf-8") == before_ctx
assert cfg_path.read_text(encoding="utf-8") == before_cfg
def test_integration_switch_and_uninstall_leave_legacy_artifacts_untouched(
self, tmp_path
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations._helpers import (
_clear_init_options_for_integration,
_update_init_options_for_integration,
)
project = tmp_path / "legacy"
project.mkdir()
ctx = self._seed_legacy_project(project)
cfg_path = (
project / ".specify" / "extensions" / "agent-context"
/ "agent-context-config.yml"
)
before_ctx = ctx.read_text(encoding="utf-8")
before_cfg = cfg_path.read_text(encoding="utf-8")
# Switch to a different integration.
_update_init_options_for_integration(
project, INTEGRATION_REGISTRY["gemini"], script_type="sh"
)
assert ctx.read_text(encoding="utf-8") == before_ctx
assert cfg_path.read_text(encoding="utf-8") == before_cfg
# Uninstall.
_clear_init_options_for_integration(project, "gemini")
assert ctx.read_text(encoding="utf-8") == before_ctx
assert cfg_path.read_text(encoding="utf-8") == before_cfg
@@ -0,0 +1,211 @@
"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.extensions.test_extension_agent_context import (
BASH,
POWERSHELL,
_bash_posix_path,
_run_bash_agent_context_script,
_run_powershell_agent_context_script,
)
def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None:
"""Write agent-context extension config as JSON.
JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in
ConvertFrom-Json can parse it without needing powershell-yaml or Python.
Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json
fallback actually works on Windows CI.
"""
cfg_dir = root / ".specify" / "extensions" / "agent-context"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "agent-context-config.yml").write_text(
json.dumps({
"context_file": context_file,
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
}),
encoding="utf-8",
)
def _write_feature_json(root: Path, feature_directory: str) -> None:
specify_dir = root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path:
p = root / feature_dir / "plan.md"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return p
@requires_bash
def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
"""feature.json points to the active feature; that plan.md is injected."""
_setup_project(tmp_path)
_make_plan(tmp_path, "specs/001-active")
_write_feature_json(tmp_path, "specs/001-active")
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
@requires_bash
def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
"""An older spec's plan.md modified more recently must NOT win over feature.json."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
_write_feature_json(tmp_path, "specs/001-active")
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
@requires_bash
def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None:
"""No feature.json → mtime fallback selects the most recently modified plan."""
_setup_project(tmp_path)
old = _make_plan(tmp_path, "specs/000-old")
newer = _make_plan(tmp_path, "specs/001-newer")
now = time.time()
os.utime(old, (now - 10, now - 10))
os.utime(newer, (now, now))
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-newer/plan.md" in ctx
@requires_bash
def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None:
"""feature.json exists but plan.md not yet written → fall back to mtime."""
_setup_project(tmp_path)
_make_plan(tmp_path, "specs/000-old")
_write_feature_json(tmp_path, "specs/001-new")
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/000-old/plan.md" in ctx
@requires_bash
def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None:
"""Absolute feature_directory under PROJECT_ROOT → project-relative path in context."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
# Write POSIX absolute path — mtime would pick 000-stale without feature.json
_write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active"))
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
assert _bash_posix_path(tmp_path) not in ctx
@requires_bash
def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
"""Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context."""
project = tmp_path / "project"
external = tmp_path / "external" / "001-feature"
project.mkdir()
external.mkdir(parents=True)
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
_setup_project(project)
_write_feature_json(project, _bash_posix_path(external))
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
assert _bash_posix_path(external) + "/plan.md" in ctx
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
"""PowerShell: absolute feature_directory under project root is normalized to relative path."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
# Native str() — PowerShell expects Windows-native paths, not MSYS2 /c/... form
_write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active"))
result = _run_powershell_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "at specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
assert tmp_path.resolve().as_posix() not in ctx
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
"""PowerShell: stale plan touched more recently must not win over feature.json."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
_write_feature_json(tmp_path, "specs/001-active")
result = _run_powershell_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
"""PowerShell: absolute feature_directory outside project root → absolute path preserved."""
project = tmp_path / "project"
external = tmp_path / "external" / "001-feature"
project.mkdir()
external.mkdir(parents=True)
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
_setup_project(project)
_write_feature_json(project, str(external))
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
assert external.resolve().as_posix() + "/plan.md" in ctx
@@ -0,0 +1,481 @@
"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1.
Each test prepares two identical project trees, runs the bash script in one
and the Python port in the other, then compares exit codes, output (with
project roots normalized) and the resulting context-file bytes. PowerShell
tests compare the resulting file content only and are skipped when ``pwsh``
is unavailable.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import pytest
from tests.extensions.test_extension_agent_context import (
BASH,
EXT_DIR,
POWERSHELL,
_bundled_script_env,
)
PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py"
BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
requires_posix_bash = pytest.mark.skipif(
not BASH or os.name == "nt",
reason="POSIX bash required for side-by-side parity runs",
)
def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[BASH, str(BASH_SCRIPT), *args],
cwd=project_root,
env=_bundled_script_env(project_root, for_bash=True),
capture_output=True,
text=True,
timeout=30,
)
def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(PY_SCRIPT), *args],
cwd=project_root,
env=_bundled_script_env(project_root),
capture_output=True,
text=True,
timeout=30,
)
def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(PS_SCRIPT),
*args,
],
cwd=project_root,
env=_bundled_script_env(project_root),
capture_output=True,
text=True,
timeout=30,
)
def normalize(text: str, project_root: Path) -> str:
return text.replace(str(project_root.resolve()), "__ROOT__").replace(
str(project_root), "__ROOT__"
)
def write_config(project_root: Path, **overrides: object) -> None:
"""Write the extension config as JSON (valid YAML, PS-parseable too)."""
cfg: dict = {
"context_file": overrides.get("context_file", ""),
"context_files": overrides.get("context_files", []),
"context_markers": overrides.get(
"context_markers",
{"start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->"},
),
}
cfg_dir = project_root / ".specify" / "extensions" / "agent-context"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "agent-context-config.yml").write_text(
json.dumps(cfg), encoding="utf-8"
)
def make_project(root: Path, **config: object) -> Path:
root.mkdir(parents=True, exist_ok=True)
write_config(root, **config)
return root
def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None:
plan = project_root / feature_dir / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
(project_root / ".specify").mkdir(parents=True, exist_ok=True)
(project_root / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_dir}), encoding="utf-8"
)
def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]:
return (
make_project(tmp_path / "proj-a", **config),
make_project(tmp_path / "proj-b", **config),
)
def assert_parity(
bash: subprocess.CompletedProcess,
py: subprocess.CompletedProcess,
repo_a: Path,
repo_b: Path,
) -> None:
assert py.returncode == bash.returncode, py.stderr + bash.stderr
assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a)
assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a)
# ── Fresh file and upsert behavior ───────────────────────────────────────────
@requires_posix_bash
def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
add_plan(repo_a)
add_plan(repo_b)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content_a = (repo_a / "AGENTS.md").read_bytes()
content_b = (repo_b / "AGENTS.md").read_bytes()
assert content_a == content_b
assert b"at specs/001-demo/plan.md" in content_b
@requires_posix_bash
def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
existing = (
"# My project\n\n"
"<!-- SPECKIT START -->\nstale section\n<!-- SPECKIT END -->\n"
"\nTrailing prose stays.\n"
)
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
assert "stale section" not in content
assert content.startswith("# My project\n")
assert "Trailing prose stays." in content
@requires_posix_bash
@pytest.mark.parametrize(
"existing",
[
"# Doc\n<!-- SPECKIT START -->\ndangling start\n",
"dangling end\n<!-- SPECKIT END -->\nrest\n",
"no markers at all",
],
ids=["start-only", "end-only", "no-markers-no-newline"],
)
def test_python_handles_partial_markers_matching_bash(
tmp_path: Path, existing: str
) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
@requires_posix_bash
def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
markers = {"start": "<!-- CTX BEGIN -->", "end": "<!-- CTX FINISH -->"}
repo_a, repo_b = twin_projects(
tmp_path, context_file="AGENTS.md", context_markers=markers
)
existing = "intro\n<!-- CTX BEGIN -->\nold\n<!-- CTX FINISH -->\noutro\n"
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
assert "<!-- CTX BEGIN -->" in content
assert "old" not in content
@requires_posix_bash
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
repo_a, repo_b = twin_projects(tmp_path, context_files=files)
add_plan(repo_a)
add_plan(repo_b)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert bash.stdout.count("agent-context: updated") == 2
for name in ("AGENTS.md", "docs/CONTEXT.md"):
assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes()
@requires_posix_bash
def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
existing = b"# Doc\r\n\r\n<!-- SPECKIT START -->\r\nold\r\n<!-- SPECKIT END -->\r\ntail\r\n"
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_bytes(existing)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"\r" not in content
@requires_posix_bash
def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None:
mdc = ".cursor/rules/specify-rules.mdc"
cases = {
"missing": "# Rules\n",
"false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n",
"no-key": "---\ndescription: rules\n---\n\n# Rules\n",
}
for name, existing in cases.items():
repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc)
repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc)
for repo in (repo_a, repo_b):
add_plan(repo)
target = repo / mdc
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(existing, encoding="utf-8")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / mdc).read_text(encoding="utf-8")
assert content == (repo_a / mdc).read_text(encoding="utf-8"), name
assert "alwaysApply: true" in content, name
# ── Plan-path resolution ─────────────────────────────────────────────────────
@requires_posix_bash
def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
bash = run_bash(repo_a, "specs/009-explicit/plan.md")
py = run_python(repo_b, "specs/009-explicit/plan.md")
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"at specs/009-explicit/plan.md" in content
@requires_posix_bash
def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
now = time.time()
for repo in (repo_a, repo_b):
for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)):
plan = repo / feature / "plan.md"
plan.parent.mkdir(parents=True, exist_ok=True)
plan.write_text("# plan\n", encoding="utf-8")
os.utime(plan, (now - age, now - age))
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"at specs/001-new/plan.md" in content
@requires_posix_bash
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
now = time.time()
for repo in (repo_a, repo_b):
add_plan(repo, "specs/001-active")
stale = repo / "specs" / "000-stale" / "plan.md"
stale.parent.mkdir(parents=True, exist_ok=True)
stale.write_text("# plan\n", encoding="utf-8")
os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10))
os.utime(stale, (now, now))
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"at specs/001-active/plan.md" in content
@requires_posix_bash
def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
content = (repo_b / "AGENTS.md").read_bytes()
assert content == (repo_a / "AGENTS.md").read_bytes()
assert b"\nat " not in content
# ── Config gates and path validation ─────────────────────────────────────────
@requires_posix_bash
def test_python_missing_config_matching_bash(tmp_path: Path) -> None:
repo_a = tmp_path / "proj-a"
repo_b = tmp_path / "proj-b"
repo_a.mkdir()
repo_b.mkdir()
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 0
assert "not found; nothing to do." in py.stderr
@requires_posix_bash
def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None:
repo_a = tmp_path / "proj-a"
repo_b = tmp_path / "proj-b"
for repo in (repo_a, repo_b):
cfg_dir = repo / ".specify" / "extensions" / "agent-context"
cfg_dir.mkdir(parents=True)
(cfg_dir / "agent-context-config.yml").write_text(
"context_file: [unclosed\n", encoding="utf-8"
)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 0
assert "cannot update context." in py.stderr
assert "agent-context: skipping update (see above for details)." in py.stderr
@requires_posix_bash
def test_python_empty_config_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 0
assert "context_files/context_file not set" in py.stderr
@requires_posix_bash
def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path)
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / ".specify" / "init-options.json").write_text(
json.dumps({"integration": "claude"}), encoding="utf-8"
)
shutil.copy(
EXT_DIR / "agent-context-defaults.json",
repo
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-defaults.json",
)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes()
@requires_posix_bash
@pytest.mark.parametrize(
"bad_path",
["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"],
ids=["absolute", "backslash", "dotdot", "nested-dotdot"],
)
def test_python_rejects_escaping_paths_matching_bash(
tmp_path: Path, bad_path: str
) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path)
bash = run_bash(repo_a)
py = run_python(repo_b)
assert_parity(bash, py, repo_a, repo_b)
assert py.returncode == 1
assert not (repo_b / "AGENTS.md").exists()
# ── PowerShell parity (content only) ─────────────────────────────────────────
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
add_plan(repo_a)
add_plan(repo_b)
ps = run_powershell(repo_a)
py = run_python(repo_b)
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
existing = (
"# My project\n\n"
"<!-- SPECKIT START -->\nstale\n<!-- SPECKIT END -->\n"
"\ntail\n"
)
for repo in (repo_a, repo_b):
add_plan(repo)
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
ps = run_powershell(repo_a)
py = run_python(repo_b)
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
+30
View File
@@ -0,0 +1,30 @@
# Testing Extension Hooks
This directory contains a mock project to verify that LLM agents correctly identify and execute hook commands defined in `.specify/extensions.yml`.
## Test 1: Testing `before_tasks` and `after_tasks`
1. Open a chat with an LLM (like GitHub Copilot) in this project.
2. Ask it to generate tasks for the current directory:
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
3. **Expected Behavior**:
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
- It should then proceed to read the `.md` docs and produce a `tasks.md`.
- After generation, it should output the optional `after_tasks` hook (`post_tasks_test`) block, asking if you want to run it.
## Test 2: Testing `before_implement` and `after_implement`
*(Requires `tasks.md` from Test 1 to exist)*
1. In the same (or new) chat, ask the LLM to implement the tasks:
> "Please follow `/speckit.implement` for the `./tests/hooks` directory."
2. **Expected Behavior**:
- The LLM should first check for `before_implement` hooks.
- It should state it is executing `EXECUTE_COMMAND: pre_implement_test` BEFORE doing any actual task execution.
- It should evaluate the checklists and execute the code writing tasks.
- Upon completion, it should output the optional `after_implement` hook (`post_implement_test`) block.
## How it works
The templates for these commands in `templates/commands/tasks.md` and `templates/commands/implement.md` contains strict ordered lists. The new `before_*` hooks are explicitly formulated in a **Pre-Execution Checks** section prior to the outline to ensure they're evaluated first without breaking template step numbers.
+3
View File
@@ -0,0 +1,3 @@
# Test Setup for Hooks
This feature is designed to test if LLMs correctly invoke Spec Kit extensions hooks when generating tasks and implementing code.
+1
View File
@@ -0,0 +1 @@
- **User Story 1:** I want a test script that prints "Hello hooks!".
+1
View File
@@ -0,0 +1 @@
- [ ] T001 [US1] Create script that prints 'Hello hooks!' in hello.py
+15
View File
@@ -0,0 +1,15 @@
"""HTTP test helpers shared by version-related CLI tests."""
import json
from unittest.mock import MagicMock
def mock_urlopen_response(payload: dict) -> MagicMock:
"""Build a urlopen context-manager mock whose read returns JSON."""
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
@@ -0,0 +1,120 @@
"""Integration tests for the catalog stack: precedence, policy gating, search."""
from __future__ import annotations
import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
from specify_cli.bundler.services.catalog_stack import CatalogStack
from tests.bundler_helpers import catalog_entry_dict, catalog_payload
def _source(source_id, priority, policy, url="builtin://x"):
return CatalogSource(
id=source_id, url=url, priority=priority,
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
)
def _stack(sources, payloads):
def fetcher(src):
return payloads[src.id]
return CatalogStack(sources, fetcher)
def test_resolve_prefers_highest_precedence_source():
sources = [
_source("low", 2, "install-allowed"),
_source("high", 1, "discovery-only"),
]
payloads = {
"high": catalog_payload({"b": catalog_entry_dict("b", version="9.0.0")}),
"low": catalog_payload({"b": catalog_entry_dict("b", version="1.0.0")}),
}
resolved = _stack(sources, payloads).resolve("b")
assert resolved.source.id == "high"
assert resolved.entry.version == "9.0.0"
assert resolved.install_allowed is False
def test_resolve_unknown_bundle_errors():
stack = _stack(
[_source("only", 1, "install-allowed")],
{"only": catalog_payload({})},
)
with pytest.raises(BundlerError, match="not found"):
stack.resolve("missing")
def test_search_dedupes_by_precedence_and_filters():
sources = [_source("a", 1, "install-allowed"), _source("b", 2, "install-allowed")]
payloads = {
"a": catalog_payload({
"alpha": catalog_entry_dict("alpha", role="developer"),
}),
"b": catalog_payload({
"alpha": catalog_entry_dict("alpha", version="0.0.1"),
"beta": catalog_entry_dict("beta", role="qa"),
}),
}
stack = _stack(sources, payloads)
all_results = stack.search()
ids = [r.entry.id for r in all_results]
assert ids == ["alpha", "beta"]
# alpha resolved from the higher-precedence source 'a'.
alpha = next(r for r in all_results if r.entry.id == "alpha")
assert alpha.source.id == "a"
qa_only = stack.search("qa")
assert [r.entry.id for r in qa_only] == ["beta"]
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
"""Search must resolve each id at its highest-precedence source, then
filter — never fall through to a shadowed lower-precedence entry the query
happens to match.
If the query matched only the lower-precedence copy of an id, search used
to return that copy, even though `resolve()`/install always use the
higher-precedence one. That advertised a bundle (name/version/source) the
user could never actually get.
"""
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
payloads = {
# Highest-precedence entry for 'shared' does NOT match "widget".
"high": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Alpha Tool", role="developer",
description="nothing relevant", version="2.0.0",
),
}),
# Lower-precedence entry for the same id DOES match "widget".
"low": catalog_payload({
"shared": catalog_entry_dict(
"shared", name="Searchable Widget", version="1.0.0",
),
}),
}
stack = _stack(sources, payloads)
# resolve() uses the high-precedence entry.
assert stack.resolve("shared").source.id == "high"
# A query that only the shadowed low-precedence entry matches returns
# nothing — search agrees with resolve().
assert stack.search("widget") == []
# And a query the high-precedence entry matches returns it (from 'high').
alpha = stack.search("alpha tool")
assert [r.entry.id for r in alpha] == ["shared"]
assert alpha[0].source.id == "high"
def test_unreachable_source_raises_named_error():
def fetcher(src):
raise RuntimeError("boom")
stack = CatalogStack([_source("bad", 1, "install-allowed")], fetcher)
with pytest.raises(BundlerError, match="bad"):
stack.resolve("anything")
@@ -0,0 +1,92 @@
"""Install-time initialization and integration precedence (T049, T050).
``specify bundle install`` into an uninitialized directory must scaffold a Spec
Kit project first (FR-012), choosing the integration by precedence (FR-013):
explicit ``--integration`` override → bundle-declared integration → default.
The end-to-end test runs fully offline against bundled assets.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import yaml
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.commands.bundle import _resolve_init_integration
from specify_cli.bundler.services.packager import build_bundle
from tests.bundler_helpers import valid_manifest_dict
runner = CliRunner()
def _manifest(**overrides):
data = valid_manifest_dict(**overrides)
return BundleManifest.from_dict(data)
def test_precedence_override_wins():
manifest = _manifest(integration={"id": "claude"})
assert _resolve_init_integration("gemini", manifest) == "gemini"
def test_precedence_bundle_declared_when_no_override():
manifest = _manifest(integration={"id": "claude"})
assert _resolve_init_integration(None, manifest) == "claude"
def test_precedence_default_when_unspecified():
manifest = _manifest()
assert _resolve_init_integration(None, manifest) == "copilot"
assert _resolve_init_integration(None, None) == "copilot"
def _build_mini(tmp_path: Path) -> Path:
bundle = tmp_path / "mini"
bundle.mkdir()
(bundle / "bundle.yml").write_text(
yaml.safe_dump(
{
"schema_version": "1.0",
"bundle": {
"id": "mini",
"name": "Mini",
"version": "1.0.0",
"role": "developer",
"description": "minimal",
"author": "tests",
"license": "MIT",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]},
}
),
encoding="utf-8",
)
(bundle / "README.md").write_text("# Mini\n", encoding="utf-8")
return build_bundle(bundle).artifact_path
def test_install_initializes_uninitialized_project(tmp_path: Path):
project = tmp_path / "proj"
project.mkdir()
artifact = _build_mini(tmp_path)
previous = Path.cwd()
os.chdir(project)
try:
result = runner.invoke(
app, ["bundle", "install", str(artifact), "--offline"]
)
assert result.exit_code == 0, result.output
finally:
os.chdir(previous)
assert (project / ".specify").is_dir()
marker = project / ".specify" / "integration.json"
assert marker.exists()
data = json.loads(marker.read_text(encoding="utf-8"))
assert "copilot" in json.dumps(data)
@@ -0,0 +1,287 @@
"""Integration tests for the install → record → remove lifecycle (offline, fake installer).
Uses :class:`FakeInstaller` so no network or real primitive machinery is touched
(Constitution Principle II network-mocking, Principle IV offline-first).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.manifest import BundleManifest
from specify_cli.bundler.models.records import load_records
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
from specify_cli.bundler.services.resolver import resolve_install_plan
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
def _plan(manifest):
return resolve_install_plan(
manifest, speckit_version="0.11.2", active_integration="copilot"
)
def test_install_records_and_invokes_primitives(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
assert len(result.installed) == 4
assert len(installer.install_calls) == 4
records = load_records(tmp_path)
assert len(records) == 1
assert records[0].bundle_id == "demo-bundle"
def test_install_is_idempotent(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
second = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
# Second install adds nothing and does not duplicate the record.
assert second.installed == []
assert len(second.skipped) == 4
assert len(load_records(tmp_path)) == 1
def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller(fail_on="preset-a")
with pytest.raises(BundlerError):
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
# ext-a was installed first, then rolled back; no record persisted.
assert installer.installed == set()
assert load_records(tmp_path) == []
def test_remove_is_non_collateral(tmp_path: Path):
make_project(tmp_path)
installer = FakeInstaller()
# Bundle A provides a shared preset; Bundle B also provides it.
data_a = valid_manifest_dict()
data_a["bundle"]["id"] = "a"
data_b = valid_manifest_dict()
data_b["bundle"]["id"] = "b"
data_b["provides"] = {"presets": [
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
]}
man_a = BundleManifest.from_dict(data_a)
man_b = BundleManifest.from_dict(data_b)
install_bundle(tmp_path, _plan(man_a), installer, manifest=man_a)
install_bundle(tmp_path, _plan(man_b), installer, manifest=man_b)
# Removing B must NOT uninstall preset-a (still needed by A).
result = remove_bundle(tmp_path, "b", installer)
assert ("presets", "preset-a") in {(c.kind, c.id) for c in result.skipped}
assert installer.is_installed(tmp_path, man_a.presets[0]) is True
remaining = {r.bundle_id for r in load_records(tmp_path)}
assert remaining == {"a"}
def test_remove_unknown_bundle_errors(tmp_path: Path):
make_project(tmp_path)
with pytest.raises(BundlerError, match="not installed"):
remove_bundle(tmp_path, "ghost", FakeInstaller())
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
result = remove_bundle(tmp_path, "demo-bundle", installer)
# Removal flows populate the dedicated ``uninstalled`` list; ``installed``
# stays empty so the result type is never ambiguous for callers.
assert result.installed == []
assert len(result.uninstalled) == 4
assert installer.installed == set()
def test_remove_counts_only_components_actually_removed(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
# Simulate one contributed component already gone from disk (e.g. removed
# out of band). It must not be reported as uninstalled and remove() must
# not be called for it.
gone = manifest.components[0]
installer.installed.discard((gone.kind, gone.id))
result = remove_bundle(tmp_path, "demo-bundle", installer)
assert len(result.uninstalled) == 3
assert (gone.kind, gone.id) not in installer.remove_calls
assert gone in result.skipped
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
result = install_bundle(
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
)
# With refresh, already-installed components are re-applied, not skipped.
assert result.skipped == []
assert len(result.refreshed) == 4
assert len(installer.refresh_calls) == 4
assert result.changed is True
def test_refresh_falls_back_to_install_without_hook(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
class NoRefreshInstaller(FakeInstaller):
refresh = None # type: ignore[assignment]
installer = NoRefreshInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
before = len(installer.install_calls)
result = install_bundle(
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
)
# No refresh hook → re-install path keeps components current.
assert len(result.refreshed) == 4
assert len(installer.install_calls) == before + 4
def test_update_preserves_original_installed_at(tmp_path: Path):
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
original = load_records(tmp_path)[0].installed_at
# A refresh (bundle update) must not rewrite the original install timestamp.
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
assert load_records(tmp_path)[0].installed_at == original
def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path):
# bundle update (refresh) must not re-apply a component installed
# independently and tracked by no bundle — refreshing it would be a
# collateral change to something the bundle does not own (FR-022).
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
installer.installed.add(("extensions", "ext-a"))
result = install_bundle(
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
)
# ext-a is skipped (not refreshed) and never attributed to the bundle.
assert ("extensions", "ext-a") not in installer.refresh_calls
assert ("extensions", "ext-a") in {(c.kind, c.id) for c in result.skipped}
assert ("extensions", "ext-a") not in {(c.kind, c.id) for c in result.refreshed}
contributed = {
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
}
assert ("extensions", "ext-a") not in contributed
def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
# A component installed independently (before any bundle) must not be
# attributed to the bundle, so removing the bundle never uninstalls it
# (FR-022, no collateral removal).
make_project(tmp_path)
manifest = BundleManifest.from_dict(valid_manifest_dict())
installer = FakeInstaller()
# Pre-install ext-a independently — no bundle record references it yet.
installer.installed.add(("extensions", "ext-a"))
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
contributed = {
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
}
assert ("extensions", "ext-a") not in contributed
remove_bundle(tmp_path, "demo-bundle", installer)
assert ("extensions", "ext-a") in installer.installed
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
data = valid_manifest_dict()
data["bundle"]["id"] = manifest_id
data["bundle"]["version"] = version
data["provides"] = {
"extensions": [{"id": e, "version": version} for e in ext_ids]
}
return BundleManifest.from_dict(data)
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
"""`bundle update` must uninstall components the new version no longer
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
make_project(tmp_path)
installer = FakeInstaller()
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
assert ("extensions", "ext-b") in installer.installed
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
result = install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b was dropped by v2 -> uninstalled and reported.
assert ("extensions", "ext-b") in installer.remove_calls
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
assert ("extensions", "ext-b") not in installer.installed
assert ("extensions", "ext-a") in installer.installed
# The saved record lists only ext-a.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
keys = {(c.kind, c.id) for c in rec.contributed_components}
assert ("extensions", "ext-a") in keys
assert ("extensions", "ext-b") not in keys
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
"""A dropped component still owned by another bundle stays installed."""
make_project(tmp_path)
installer = FakeInstaller()
man_sib = _bundle("sibling", ["ext-b"])
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
install_bundle(
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
)
# ext-b is still needed by 'sibling' -> not removed, stays installed.
assert ("extensions", "ext-b") not in installer.remove_calls
assert ("extensions", "ext-b") in installer.installed
# But demo's record no longer attributes it.
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
assert ("extensions", "ext-b") not in {
(c.kind, c.id) for c in rec.contributed_components
}
@@ -0,0 +1,114 @@
"""Tests for installing a bundle from a local artifact/path (T045).
The resolution-level tests are pure; the end-to-end test installs the bundled
``agent-context`` extension fully offline from a built ``.zip`` artifact,
proving the real in-process primitive dispatch (T044) works without a network.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import yaml
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.bundler import BundlerError
from specify_cli.commands.bundle import _local_manifest_source
from tests.bundler_helpers import make_project, valid_manifest_dict, write_manifest
def test_local_source_none_for_non_path():
assert _local_manifest_source("some-catalog-bundle-id") is None
def test_local_source_from_directory(tmp_path: Path):
write_manifest(tmp_path, valid_manifest_dict())
manifest = _local_manifest_source(str(tmp_path))
assert manifest is not None
assert manifest.bundle.id == "demo-bundle"
def test_local_source_from_bundle_yml(tmp_path: Path):
path = write_manifest(tmp_path, valid_manifest_dict())
manifest = _local_manifest_source(str(path))
assert manifest is not None
assert manifest.bundle.id == "demo-bundle"
def test_local_source_from_zip_artifact(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
write_manifest(bundle_dir, valid_manifest_dict())
(bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
assert result.exit_code == 0, result.output
artifact = next(bundle_dir.glob("*.zip"))
manifest = _local_manifest_source(str(artifact))
assert manifest is not None
assert manifest.bundle.id == "demo-bundle"
def test_local_source_rejects_unknown_file(tmp_path: Path):
weird = tmp_path / "thing.txt"
weird.write_text("nope", encoding="utf-8")
with pytest.raises(BundlerError, match="not a recognised bundle source"):
_local_manifest_source(str(weird))
def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
"""End-to-end: build → install (offline, local .zip) → list → remove."""
project = make_project(tmp_path / "proj")
bundle_dir = tmp_path / "mini"
bundle_dir.mkdir()
(bundle_dir / "bundle.yml").write_text(
yaml.safe_dump(
{
"schema_version": "1.0",
"bundle": {
"id": "mini",
"name": "Mini",
"version": "1.0.0",
"role": "developer",
"description": "minimal",
"author": "tests",
"license": "MIT",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"extensions": [{"id": "agent-context", "version": "1.0.0"}]
},
}
),
encoding="utf-8",
)
(bundle_dir / "README.md").write_text("# Mini\n", encoding="utf-8")
runner = CliRunner()
previous = Path.cwd()
os.chdir(project)
try:
build = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
assert build.exit_code == 0, build.output
artifact = next(bundle_dir.glob("*.zip"))
install = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"])
assert install.exit_code == 0, install.output
from specify_cli.extensions import ExtensionManager
assert ExtensionManager(project).registry.is_installed("agent-context")
listing = runner.invoke(app, ["bundle", "list"])
assert "mini" in listing.output
remove = runner.invoke(app, ["bundle", "remove", "mini"])
assert remove.exit_code == 0, remove.output
assert not ExtensionManager(project).registry.is_installed("agent-context")
finally:
os.chdir(previous)
+78
View File
@@ -0,0 +1,78 @@
"""Offline-first tests (Constitution Principle IV).
Assert that consume/author flows work with no network access: built-in catalogs
resolve offline, file:// catalogs resolve offline, and http(s) sources are
refused (never silently attempted) when network is disabled.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
from specify_cli.bundler.services.adapters import make_catalog_fetcher
from specify_cli.bundler.services.catalog_stack import CatalogStack
from tests.bundler_helpers import catalog_entry_dict, write_catalog_file
def _src(source_id, url, priority=1, policy="install-allowed"):
return CatalogSource(
id=source_id, url=url, priority=priority,
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
)
def test_builtin_catalog_resolves_offline():
fetcher = make_catalog_fetcher(allow_network=False)
stack = CatalogStack([_src("default", "builtin://default")], fetcher)
# Built-in default ships empty; search works without network and returns [].
assert stack.search() == []
def test_file_catalog_resolves_offline(tmp_path: Path):
catalog_path = tmp_path / "catalog.json"
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
fetcher = make_catalog_fetcher(allow_network=False)
stack = CatalogStack([_src("local", str(catalog_path))], fetcher)
resolved = stack.resolve("demo")
assert resolved.entry.id == "demo"
def test_http_source_refused_when_offline():
fetcher = make_catalog_fetcher(allow_network=False)
stack = CatalogStack([_src("remote", "https://example.com/catalog.json")], fetcher)
with pytest.raises(BundlerError, match="Network access disabled"):
stack.resolve("anything")
def test_missing_file_catalog_errors_offline(tmp_path: Path):
fetcher = make_catalog_fetcher(allow_network=False)
stack = CatalogStack([_src("local", str(tmp_path / "nope.json"))], fetcher)
with pytest.raises(BundlerError):
stack.resolve("anything")
def test_file_url_catalog_resolves_offline(tmp_path: Path):
catalog_path = tmp_path / "catalog.json"
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
fetcher = make_catalog_fetcher(allow_network=False)
stack = CatalogStack([_src("local", catalog_path.as_uri())], fetcher)
resolved = stack.resolve("demo")
assert resolved.entry.id == "demo"
def test_plain_http_remote_rejected_before_network():
# HTTPS is required for non-localhost catalogs; reject http:// up front.
fetcher = make_catalog_fetcher(allow_network=True)
stack = CatalogStack([_src("remote", "http://example.com/catalog.json")], fetcher)
with pytest.raises(BundlerError, match="must use HTTPS"):
stack.resolve("anything")
def test_remote_url_without_host_rejected():
fetcher = make_catalog_fetcher(allow_network=True)
stack = CatalogStack([_src("remote", "https:///catalog.json")], fetcher)
with pytest.raises(BundlerError, match="valid URL with a host"):
stack.resolve("anything")
@@ -0,0 +1,192 @@
"""Security tests: path-traversal / symlink confinement (Constitution Principle V).
These assert the bundler refuses to read or write outside an allowed root, so a
malicious manifest or artifact path cannot escape the project/bundle directory.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from specify_cli.bundler import BundlerError
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath
def test_ensure_within_allows_child(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
child = root / "sub" / "file.txt"
assert ensure_within(root, child) == child.resolve()
def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
escape = root / ".." / "secret.txt"
with pytest.raises(BundlerError, match="escapes"):
ensure_within(root, escape)
def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
with pytest.raises(BundlerError):
ensure_within(root, Path("/etc/passwd"))
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
root = tmp_path / "bundle"
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("secret", encoding="utf-8")
link = root / "link.txt"
link.symlink_to(outside)
with pytest.raises(BundlerError, match="escapes"):
ensure_within(root, link)
@pytest.mark.parametrize("rel,safe", [
("a/b.txt", True),
("./a.txt", True),
("../escape", False),
("a/../../escape", False),
("/abs", False),
("C:/abs", False),
("C:\\abs", False),
("\\\\server\\share", False),
("", False),
])
def test_is_safe_relpath(rel, safe):
assert is_safe_relpath(rel) is safe
def test_build_skips_symlinks(tmp_path: Path):
"""Packager must not follow symlinks out of the bundle dir."""
import yaml
from specify_cli.bundler.services.packager import build_bundle
from tests.bundler_helpers import valid_manifest_dict
bundle = tmp_path / "bundle"
bundle.mkdir()
(bundle / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
(bundle / "README.md").write_text("# Demo", encoding="utf-8")
if os.name != "nt":
secret = tmp_path / "secret.txt"
secret.write_text("top secret", encoding="utf-8")
(bundle / "leak.txt").symlink_to(secret)
result = build_bundle(bundle, output_dir=tmp_path / "out")
import zipfile
with zipfile.ZipFile(result.artifact_path) as archive:
names = archive.namelist()
assert "leak.txt" not in names
assert "bundle.yml" in names
def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
# Reading bundle-records.json must honour the same confinement as writes:
# a symlinked .specify pointing outside project_root is refused.
from specify_cli.bundler.models.records import load_records
project = tmp_path / "proj"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "bundle-records.json").write_text(
'{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
)
(project / ".specify").symlink_to(outside, target_is_directory=True)
with pytest.raises(BundlerError, match="escapes the allowed root"):
load_records(project)
def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
# Reading the integration marker must not follow a .specify symlink that
# resolves outside project_root; an escape is treated as "not determinable".
from specify_cli.bundler.lib.project import active_integration
project = tmp_path / "proj"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "integration.json").write_text(
'{"integration": "leaked"}', encoding="utf-8"
)
(project / ".specify").symlink_to(outside, target_is_directory=True)
assert active_integration(project) is None
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
from specify_cli.bundler.commands_impl import catalog_config as cc
project = tmp_path / "proj"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "bundle-catalogs.yml").write_text(
"schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
)
(project / ".specify").symlink_to(outside, target_is_directory=True)
with pytest.raises(BundlerError, match="escapes the allowed root"):
cc._read(project)
def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
from specify_cli.bundler.models.catalog import load_source_stack
project = tmp_path / "project"
project.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
try:
(project / ".specify").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
with pytest.raises(BundlerError, match="escapes the allowed root"):
load_source_stack(project)
def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
from specify_cli.bundler.lib.project import find_project_root
real = tmp_path / "real-specify"
real.mkdir()
project = tmp_path / "project"
project.mkdir()
try:
(project / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
# A symlinked .specify must not be accepted as a project root.
assert find_project_root(project) is None
def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch):
"""The SPECIFY_INIT_DIR override path refuses a symlinked .specify too,
matching the cwd loop path (regression: the override returned early and
skipped the symlink guard)."""
from specify_cli.bundler.lib.project import find_project_root
real = tmp_path / "real-specify"
real.mkdir()
project = tmp_path / "project"
project.mkdir()
try:
(project / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
monkeypatch.setenv("SPECIFY_INIT_DIR", str(project))
with pytest.raises(BundlerError, match="symlinked \\.specify"):
find_project_root(None)
View File
+58
View File
@@ -0,0 +1,58 @@
"""Shared test helpers for integration tests."""
import pytest
from specify_cli.integrations.base import MarkdownIntegration
def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None:
"""Point HOME/USERPROFILE/XDG env vars at an isolated *home* directory."""
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
path.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
@pytest.fixture(scope="session", autouse=True)
def _isolate_integration_home_session(tmp_path_factory):
"""Isolate the user home for setup that runs outside a test function.
The per-test fixture below re-points HOME for each test, but function-scoped
fixtures do not apply to module-/session-scoped fixtures. Some of those (e.g.
the ``status_*_template`` fixtures in ``test_integration_subcommand.py``) run
``specify init`` during setup, before any per-test isolation takes effect.
A standalone ``MonkeyPatch`` gives them an isolated home too.
"""
monkeypatch = pytest.MonkeyPatch()
_redirect_home(monkeypatch, tmp_path_factory.mktemp("session-home"))
yield
monkeypatch.undo()
@pytest.fixture(autouse=True)
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
"""Keep integration tests from reading or writing the real user home."""
_redirect_home(monkeypatch, tmp_path / "home")
class StubIntegration(MarkdownIntegration):
"""Minimal concrete integration for testing."""
key = "stub"
config = {
"name": "Stub Agent",
"folder": ".stub/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".stub/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
+558
View File
@@ -0,0 +1,558 @@
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
import sys
import pytest
from specify_cli.integrations.base import (
IntegrationBase,
IntegrationOption,
MarkdownIntegration,
SkillsIntegration,
)
from specify_cli.integrations.manifest import IntegrationManifest
from .conftest import StubIntegration
class TestIntegrationOption:
def test_defaults(self):
opt = IntegrationOption(name="--flag")
assert opt.name == "--flag"
assert opt.is_flag is False
assert opt.required is False
assert opt.default is None
assert opt.help == ""
def test_flag_option(self):
opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills")
assert opt.is_flag is True
assert opt.default is True
assert opt.help == "Enable skills"
def test_required_option(self):
opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path")
assert opt.required is True
def test_frozen(self):
opt = IntegrationOption(name="--x")
with pytest.raises(AttributeError):
opt.name = "--y" # type: ignore[misc]
class TestIntegrationBase:
def test_key_and_config(self):
i = StubIntegration()
assert i.key == "stub"
assert i.config["name"] == "Stub Agent"
assert i.registrar_config["format"] == "markdown"
def test_options_default_empty(self):
assert StubIntegration.options() == []
def test_shared_commands_dir(self):
i = StubIntegration()
cmd_dir = i.shared_commands_dir()
assert cmd_dir is not None
assert cmd_dir.is_dir()
def test_setup_uses_shared_templates(self, tmp_path):
i = StubIntegration()
manifest = IntegrationManifest("stub", tmp_path)
created = i.setup(tmp_path, manifest)
assert len(created) > 0
for f in created:
assert f.parent == tmp_path / ".stub" / "commands"
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
def test_setup_copies_templates(self, tmp_path, monkeypatch):
tpl = tmp_path / "_templates"
tpl.mkdir()
(tpl / "plan.md").write_text("plan content", encoding="utf-8")
(tpl / "specify.md").write_text("spec content", encoding="utf-8")
i = StubIntegration()
monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md")))
project = tmp_path / "project"
project.mkdir()
created = i.setup(project, IntegrationManifest("stub", project))
assert len(created) == 2
assert (project / ".stub" / "commands" / "speckit.plan.md").exists()
assert (project / ".stub" / "commands" / "speckit.specify.md").exists()
def test_install_delegates_to_setup(self, tmp_path):
i = StubIntegration()
manifest = IntegrationManifest("stub", tmp_path)
result = i.install(tmp_path, manifest)
assert len(result) > 0
def test_uninstall_delegates_to_teardown(self, tmp_path):
i = StubIntegration()
manifest = IntegrationManifest("stub", tmp_path)
removed, skipped = i.uninstall(tmp_path, manifest)
assert removed == []
assert skipped == []
class TestMarkdownIntegration:
def test_is_subclass_of_base(self):
assert issubclass(MarkdownIntegration, IntegrationBase)
def test_stub_is_markdown(self):
assert isinstance(StubIntegration(), MarkdownIntegration)
class TestBasePrimitives:
def test_shared_commands_dir_returns_path(self):
i = StubIntegration()
cmd_dir = i.shared_commands_dir()
assert cmd_dir is not None
assert cmd_dir.is_dir()
def test_shared_templates_dir_returns_path(self):
i = StubIntegration()
tpl_dir = i.shared_templates_dir()
assert tpl_dir is not None
assert tpl_dir.is_dir()
def test_list_command_templates_returns_md_files(self):
i = StubIntegration()
templates = i.list_command_templates()
assert len(templates) > 0
assert all(t.suffix == ".md" for t in templates)
def test_list_command_templates_keeps_checklist_after_plan(self):
i = StubIntegration()
stems = [template.stem for template in i.list_command_templates()]
assert stems.index("plan") < stems.index("checklist")
def test_command_filename_default(self):
i = StubIntegration()
assert i.command_filename("plan") == "speckit.plan.md"
def test_commands_dest(self, tmp_path):
i = StubIntegration()
dest = i.commands_dest(tmp_path)
assert dest == tmp_path / ".stub" / "commands"
def test_commands_dest_no_config_raises(self, tmp_path):
class NoConfig(MarkdownIntegration):
key = "noconfig"
with pytest.raises(ValueError, match="config is not set"):
NoConfig().commands_dest(tmp_path)
def test_copy_command_to_directory(self, tmp_path):
src = tmp_path / "source.md"
src.write_text("content", encoding="utf-8")
dest_dir = tmp_path / "output"
result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md")
assert result == dest_dir / "speckit.plan.md"
assert result.read_text(encoding="utf-8") == "content"
def test_record_file_in_manifest(self, tmp_path):
f = tmp_path / "f.txt"
f.write_text("hello", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
IntegrationBase.record_file_in_manifest(f, tmp_path, m)
assert "f.txt" in m.files
def test_write_file_and_record(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
dest = tmp_path / "sub" / "f.txt"
result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m)
assert result == dest
assert dest.read_text(encoding="utf-8") == "content"
assert "sub/f.txt" in m.files
def test_setup_copies_shared_templates(self, tmp_path):
i = StubIntegration()
m = IntegrationManifest("stub", tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
for f in created:
assert f.parent.name == "commands"
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
class TestBuildCommandInvocation:
"""Tests for build_command_invocation across integration types."""
def test_base_core_command_dotted(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"
def test_base_core_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("plan") == "/speckit.plan"
def test_base_core_command_with_args(self):
i = StubIntegration()
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"
def test_base_extension_command(self):
i = StubIntegration()
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"
def test_base_extension_command_bare(self):
i = StubIntegration()
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"
def test_skills_core_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
class TestResolveCommandRefs:
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
def test_dot_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run `/speckit.plan` to plan."
def test_hyphen_separator_core_command(self):
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."
def test_multiple_placeholders(self):
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"
def test_extension_command_dot(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "Run /speckit.git.commit to commit."
def test_extension_command_hyphen(self):
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run /speckit-git-commit to commit."
def test_no_placeholders_unchanged(self):
text = "No placeholders here."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_default_separator_is_dot(self):
text = "__SPECKIT_COMMAND_PLAN__"
assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan"
def test_invoke_separator_class_attribute(self):
assert IntegrationBase.invoke_separator == "."
assert SkillsIntegration.invoke_separator == "-"
def test_effective_invoke_separator_default(self):
"""Base classes return invoke_separator regardless of parsed_options."""
from .conftest import StubIntegration
stub = StubIntegration()
assert stub.effective_invoke_separator() == "."
assert stub.effective_invoke_separator({"skills": True}) == "."
def test_process_template_resolves_placeholders(self):
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
result = IntegrationBase.process_template(
content, "test-agent", "sh", invoke_separator="."
)
assert "/speckit.plan" in result
assert "__SPECKIT_COMMAND_" not in result
def test_process_template_skills_separator(self):
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
result = IntegrationBase.process_template(
content, "test-agent", "sh", invoke_separator="-"
)
assert "/speckit-plan" in result
assert "__SPECKIT_COMMAND_" not in result
def test_unclosed_placeholder_unchanged(self):
text = "Run __SPECKIT_COMMAND_PLAN to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_empty_name_not_matched(self):
text = "Run __SPECKIT_COMMAND___ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_lowercase_placeholder_not_matched(self):
text = "Run __SPECKIT_COMMAND_plan__ to plan."
assert IntegrationBase.resolve_command_refs(text, ".") == text
def test_placeholder_adjacent_to_text(self):
text = "foo__SPECKIT_COMMAND_PLAN__bar"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "foo/speckit.planbar"
def test_placeholder_with_digits(self):
text = "__SPECKIT_COMMAND_V2_PLAN__"
result = IntegrationBase.resolve_command_refs(text, ".")
assert result == "/speckit.v2.plan"
class TestResolvePythonInterpreter:
def test_returns_python_on_path(self, monkeypatch):
# Positive: when python3 is on PATH it is preferred over python.
# Pin a POSIX platform so the Windows stub probe (tested separately
# below) does not reject the fake PATH entries on Windows CI.
def fake_which(name):
return f"/usr/bin/{name}" if name in ("python3", "python") else None
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", fake_which
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
def test_falls_back_to_python_when_no_python3(self, monkeypatch):
def fake_which(name):
return "/usr/bin/python" if name == "python" else None
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", fake_which
)
assert IntegrationBase.resolve_python_interpreter() == "python"
def test_falls_back_to_sys_executable_when_nothing_found(self, monkeypatch):
# Negative: nothing on PATH and no venv -> the running interpreter
# (sys.executable) is used so the command works in this environment.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", "/opt/py/bin/python"
)
assert IntegrationBase.resolve_python_interpreter() == "/opt/py/bin/python"
def test_falls_back_to_python3_when_no_interpreter_at_all(self, monkeypatch):
# Negative edge: neither PATH nor sys.executable resolves.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", ""
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
def test_prefers_project_venv_posix(self, monkeypatch, tmp_path):
venv_python = tmp_path / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
# Even if python3 is on PATH, the project venv wins. The returned
# path is relative to the project root for portability.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3",
)
result = IntegrationBase.resolve_python_interpreter(tmp_path)
assert result == ".venv/bin/python"
def test_prefers_project_venv_windows(self, monkeypatch, tmp_path):
venv_python = tmp_path / ".venv" / "Scripts" / "python.exe"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
result = IntegrationBase.resolve_python_interpreter(tmp_path)
assert result == ".venv/Scripts/python.exe"
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
# Negative: no venv directory -> PATH resolution is used instead.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
def test_windows_skips_store_alias_stub(self, monkeypatch):
# On Windows, python3 on PATH may be the Microsoft Store App
# Execution Alias stub: it exists but only prints an installer
# hint and exits non-zero. Existence is not enough; the
# interpreter must actually run (mirrors #3304 for the CLI).
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: f"C:\\WindowsApps\\{name}.exe"
if name in ("python3", "python")
else None,
)
monkeypatch.setattr(
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: False)
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", "C:\\Python\\python.exe"
)
result = IntegrationBase.resolve_python_interpreter()
assert result == "C:\\Python\\python.exe"
def test_windows_keeps_working_interpreter(self, monkeypatch):
# Positive: a real python3 on Windows PATH passes the run check.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: f"C:\\Python\\{name}.exe" if name == "python3" else None,
)
monkeypatch.setattr(
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: True)
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
def test_windows_stub_python3_falls_through_to_working_python(self, monkeypatch):
# python3 is the stub but python is a real install: pick python.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: f"C:\\somewhere\\{name}.exe"
if name in ("python3", "python")
else None,
)
monkeypatch.setattr(
IntegrationBase,
"_interpreter_runs",
staticmethod(lambda path: path.endswith("python.exe")),
)
assert IntegrationBase.resolve_python_interpreter() == "python"
def test_posix_does_not_spawn_run_check(self, monkeypatch):
# Non-Windows platforms have no App Execution Alias; existence
# on PATH stays sufficient and no subprocess is spawned.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
def boom(path):
raise AssertionError("run check must not execute on POSIX")
monkeypatch.setattr(
IntegrationBase, "_interpreter_runs", staticmethod(boom)
)
assert IntegrationBase.resolve_python_interpreter() == "python3"
class TestProcessTemplatePyScriptType:
CONTENT = (
"---\n"
"scripts:\n"
" sh: scripts/bash/check-prerequisites.sh --json\n"
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
" py: scripts/python/check-prerequisites.py --json\n"
"---\n"
"Run {SCRIPT} now."
)
def test_py_prefixes_interpreter(self, monkeypatch):
# Positive: py script type prefixes a resolved interpreter and the
# script path is rewritten to the .specify location.
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert "python3 .specify/scripts/python/check-prerequisites.py --json" in result
# The scripts: frontmatter block is stripped.
assert "scripts:" not in result
def test_sh_does_not_prefix_interpreter(self):
# Negative: non-py script types are never prefixed with an interpreter.
result = IntegrationBase.process_template(self.CONTENT, "agent", "sh")
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
assert "python" not in result
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
# An interpreter path containing whitespace (e.g. Windows
# ``Program Files``) must be quoted so it isn't split into args.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
r"C:\Program Files\Python\python.exe",
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert (
'"C:\\Program Files\\Python\\python.exe" '
".specify/scripts/python/check-prerequisites.py --json"
) in result
def test_py_does_not_quote_interpreter_without_spaces(self, monkeypatch):
# Negative: a whitespace-free interpreter is left unquoted.
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert '"' not in result.split("check-prerequisites.py")[0]
def test_py_uses_project_venv(self, monkeypatch, tmp_path):
venv_python = tmp_path / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True)
venv_python.write_text("")
result = IntegrationBase.process_template(
self.CONTENT, "agent", "py", project_root=tmp_path
)
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
class TestInstallScriptsPython:
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
scripts_src = tmp_path / "bundled_scripts"
scripts_src.mkdir()
(scripts_src / "common.py").write_text("print('hi')\n")
(scripts_src / "common.sh").write_text("echo hi\n")
(scripts_src / "notes.txt").write_text("not executable\n")
integration = StubIntegration()
monkeypatch.setattr(
integration, "integration_scripts_dir", lambda: scripts_src
)
return integration
def test_copies_all_script_files(self, monkeypatch, tmp_path):
# Cross-platform: every bundled file is copied into the project.
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
project_root = tmp_path / "proj"
project_root.mkdir()
manifest = IntegrationManifest("stub", project_root.resolve())
created = integration.install_scripts(project_root, manifest)
names = {p.name for p in created}
assert {"common.py", "common.sh", "notes.txt"} == names
@pytest.mark.skipif(
sys.platform == "win32", reason="chmod exec bit not reliable on Windows"
)
def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path):
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
project_root = tmp_path / "proj"
project_root.mkdir()
manifest = IntegrationManifest("stub", project_root.resolve())
integration.install_scripts(project_root, manifest)
dest = project_root / ".specify" / "integrations" / "stub" / "scripts"
py_file = dest / "common.py"
sh_file = dest / "common.sh"
txt_file = dest / "notes.txt"
# Positive: .py and .sh are executable.
assert py_file.stat().st_mode & 0o111
assert sh_file.stat().st_mode & 0o111
# Negative: a non-script file is not made executable.
assert not (txt_file.stat().st_mode & 0o111)
File diff suppressed because it is too large Load Diff
+633
View File
@@ -0,0 +1,633 @@
"""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",
]
+25
View File
@@ -0,0 +1,25 @@
"""Regression tests for integration-test environment isolation."""
from __future__ import annotations
import os
from pathlib import Path
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
home = tmp_path / "home"
assert Path(os.environ["HOME"]) == home
assert Path(os.environ["USERPROFILE"]) == home
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
# Most integrations resolve the user home via Path.home() (e.g. Hermes,
# catalog), so the isolation has to reach that API, not just the env vars.
assert Path.home() == home
assert home.is_dir()
assert (home / ".cache").is_dir()
assert (home / ".config").is_dir()
assert (home / ".local" / "share").is_dir()
+154
View File
@@ -0,0 +1,154 @@
"""Tests for AgyIntegration (Antigravity)."""
from specify_cli.integrations import get_integration
from .test_integration_base_skills import SkillsIntegrationTests
class TestAgyIntegration(SkillsIntegrationTests):
KEY = "agy"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
def test_options_include_skills_flag(self):
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
i = get_integration(self.KEY)
skills_opts = [o for o in i.options() if o.name == "--skills"]
assert len(skills_opts) == 0
def test_requires_cli_is_true(self):
"""agy is a CLI tool; requires_cli must be True."""
i = get_integration(self.KEY)
assert i.config["requires_cli"] is True
def test_install_url_is_set(self):
"""install_url must point to the official installation page."""
i = get_integration(self.KEY)
assert i.config["install_url"] == "https://antigravity.google/"
class TestAgyInitFlow:
"""--integration agy creates expected files."""
def test_integration_agy_creates_skills(self, tmp_path):
"""--integration agy should create skills directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_agy_setup_warning(self, tmp_path):
"""Agy integration should print a warning about v1.20.5 requirement during setup."""
from typer.testing import CliRunner
from specify_cli import app
# Click >= 8.2 separates stdout and stderr natively
runner = CliRunner()
target = tmp_path / "test-proj2"
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
class TestAgyBuildExecArgs:
"""agy non-interactive execution argument building."""
def test_build_exec_args_returns_print_command(self):
"""build_exec_args should return ['agy', '--print', prompt]."""
from specify_cli.integrations import get_integration
i = get_integration("agy")
result = i.build_exec_args("describe my feature")
assert result == ["agy", "--print", "describe my feature"]
def test_build_exec_args_ignores_model(self):
"""agy does not support --model; model param must be ignored."""
from specify_cli.integrations import get_integration
i = get_integration("agy")
result = i.build_exec_args("my prompt", model="gemini-pro")
assert result == ["agy", "--print", "my prompt"]
def test_build_exec_args_ignores_output_json(self):
"""agy does not support JSON output; output_json param must be ignored."""
from specify_cli.integrations import get_integration
i = get_integration("agy")
result = i.build_exec_args("my prompt", output_json=False)
assert result == ["agy", "--print", "my prompt"]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
agy previously skipped _apply_extra_args_env_var entirely, so the
documented per-integration extra-args hook was silently ignored
(same class as the merged cursor-agent fix #3265).
"""
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
i = get_integration("agy")
assert i.build_exec_args("my prompt") == [
"agy", "--print", "my prompt", "--verbose",
]
def test_build_exec_args_honors_executable_override(self, monkeypatch):
from specify_cli.integrations import get_integration
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
i = get_integration("agy")
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
class TestAgyHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
"""Skills with hook sections should contain the normalization note."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
i = get_integration("agy")
m = IntegrationManifest("agy", tmp_path)
i.setup(tmp_path, m, script_type="sh")
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
def test_hook_note_not_in_skills_without_hooks(self):
"""Skills without hook sections should not get the note."""
from specify_cli.integrations.agy import AgyIntegration
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
result = AgyIntegration._inject_hook_command_note(content)
assert "replace dots" not in result
def test_hook_note_idempotent(self):
"""Injecting the note twice must not duplicate it."""
from specify_cli.integrations.agy import AgyIntegration
content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
once = AgyIntegration._inject_hook_command_note(content)
twice = AgyIntegration._inject_hook_command_note(once)
assert once == twice, "Hook note injection should be idempotent"
def test_hook_note_preserves_indentation(self):
"""The injected note must match the indentation of the target line."""
from specify_cli.integrations.agy import AgyIntegration
content = (
"---\nname: test\n---\n\n"
" - For each executable hook, output the following\n"
)
result = AgyIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [ln for ln in lines if "replace dots" in ln][0]
assert note_line.startswith(" "), "Note should preserve indentation"
@@ -0,0 +1,10 @@
"""Tests for AmpIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestAmpIntegration(MarkdownIntegrationTests):
KEY = "amp"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".agents/commands"
@@ -0,0 +1,10 @@
"""Tests for AuggieIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestAuggieIntegration(MarkdownIntegrationTests):
KEY = "auggie"
FOLDER = ".augment/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".augment/commands"
@@ -0,0 +1,313 @@
"""Reusable test mixin for standard MarkdownIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``MarkdownIntegrationTests``.
"""
import os
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import MarkdownIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class MarkdownIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".claude/"
COMMANDS_SUBDIR: str — e.g. "commands"
REGISTRAR_DIR: str — e.g. ".claude/commands"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_markdown_integration(self):
assert isinstance(get_integration(self.KEY), MarkdownIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == ".md"
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_templates_are_processed(self, tmp_path):
"""Command files must have placeholders replaced, not raw templates."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan command must not reference one.
"""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
assert plan_file.exists(), f"Plan file {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content, (
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
)
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- Context file ownership (extension-owned, opt-in) -----------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
# -- CLI integration flag -------------------------------------------------
def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
commands = sorted(cmd_dir.glob("speckit.*"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the expected file list for this integration + script variant."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.md")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
"setup-plan.sh", "setup-tasks.sh"]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
"setup-plan.ps1", "setup-tasks.ps1"]:
files.append(f".specify/scripts/powershell/{name}")
for name in ["checklist-template.md",
"constitution-template.md", "plan-template.md",
"spec-template.md", "tasks-template.md"]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
@@ -0,0 +1,488 @@
"""Reusable test mixin for standard SkillsIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``SkillsIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
"""
import os
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import SkillsIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class SkillsIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".agents/"
COMMANDS_SUBDIR: str — e.g. "skills"
REGISTRAR_DIR: str — e.g. ".agents/skills"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_skills_integration(self):
assert isinstance(get_integration(self.KEY), SkillsIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == "/SKILL.md"
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
assert f.exists()
assert f.name == "SKILL.md"
assert f.parent.name.startswith("speckit-")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.skills_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0, "No skill files were created"
for f in skill_files:
# Each SKILL.md is in speckit-<name>/ under the skills directory
assert f.resolve().parent.parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_skill_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
expected_commands = {
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
}
# Derive command names from the skill directory names
actual_commands = set()
for f in skill_files:
skill_dir_name = f.parent.name # e.g. "speckit-plan"
assert skill_dir_name.startswith("speckit-")
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
assert actual_commands == expected_commands
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---\n"), f"{f} missing frontmatter"
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "name" in fm, f"{f} frontmatter missing 'name'"
assert "description" in fm, f"{f} frontmatter missing 'description'"
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
assert fm["metadata"]["author"] == "github-spec-kit"
assert "source" in fm["metadata"]
def test_skill_uses_template_descriptions(self, tmp_path):
"""SKILL.md should use the original template description for ZIP parity."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
# Description must be a non-empty string (from the template)
assert isinstance(fm["description"], str)
assert len(fm["description"]) > 0, f"{f} has empty description"
def test_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced, not raw templates."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_command_refs_use_hyphen_separator(self, tmp_path):
"""Skills agents must resolve command refs with hyphen separator."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
# Skills agents must use /speckit-<name>, not /speckit.<name>
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
f"skills agents must use /speckit-<name>"
)
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path):
"""Generated skills with hook sections must explain dotted command conversion."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content, (
"speckit-specify should explain dotted hook command conversion"
)
assert content.count("replace dots") == content.count(
"- For each executable hook, output the following"
)
def test_hook_note_injected_for_each_instruction_independently(self):
"""Existing hook notes should not suppress later missing notes."""
content = (
"---\n"
"name: test\n"
"---\n\n"
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
"- For each executable hook, output the following first block:\n"
"\n"
"- For each executable hook, output the following second block:\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
assert result.count("replace dots (`.`) with hyphens") == 2
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content after the frontmatter."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
content = f.read_text(encoding="utf-8")
# Body is everything after the second ---
parts = content.split("---", 2)
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""The generated plan skill must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan skill must not reference one.
"""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md"
assert plan_file.exists(), f"Plan skill {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content, (
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
)
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
def test_pre_existing_skills_not_removed(self, tmp_path):
"""Pre-existing non-speckit skills should be left untouched."""
i = get_integration(self.KEY)
skills_dir = i.skills_dest(tmp_path)
foreign_dir = skills_dir / "other-tool"
foreign_dir.mkdir(parents=True)
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
# -- Context file ownership (extension-owned, opt-in) -----------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
# -- CLI integration flag -------------------------------------------------
def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"--integration {self.KEY} did not create skills directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
def test_init_does_not_create_agent_context_config(self, tmp_path):
"""agent-context is opt-in: init must not auto-install the extension
or write its config."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"opts-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
assert not ext_cfg_path.exists()
# -- IntegrationOption ------------------------------------------------
def test_options_include_skills_flag(self):
i = get_integration(self.KEY)
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 1
assert skills_opts[0].is_flag is True
# -- Complete file inventory ------------------------------------------
_SKILL_COMMANDS = [
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the full expected file list for a given script variant."""
i = get_integration(self.KEY)
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
files = []
# Skill files (core commands)
for cmd in self._SKILL_COMMANDS:
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
# Integration metadata
files += [
".specify/init-options.json",
".specify/integration.json",
f".specify/integrations/{self.KEY}.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
]
# Script variant
if script_variant == "sh":
files += [
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
]
else:
files += [
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
]
# Templates
files += [
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
]
# Bundled workflow
files += [
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
]
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
@@ -0,0 +1,597 @@
"""Reusable test mixin for standard TomlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``TomlIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` closely — same test structure,
adapted for TOML output format.
"""
import os
import tomllib
import pytest
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import TomlIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class TomlIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".gemini/"
COMMANDS_SUBDIR: str — e.g. "commands"
REGISTRAR_DIR: str — e.g. ".gemini/commands"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_toml_integration(self):
assert isinstance(get_integration(self.KEY), TomlIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "toml"
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".toml"
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".toml")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), (
f"Expected directory {expected_dir} was not created"
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_templates_are_processed(self, tmp_path):
"""Command files must have placeholders replaced and be valid TOML."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_toml_has_description(self, tmp_path):
"""Every TOML command file should have a description key."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert 'description = "' in content, f"{f.name} missing description key"
def test_toml_has_prompt(self, tmp_path):
"""Every TOML command file should have a prompt key."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "prompt = " in content, f"{f.name} missing prompt key"
def test_toml_uses_correct_arg_placeholder(self, tmp_path):
"""TOML commands must use {{args}} (from {ARGS} replacement)."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
# At least one file should contain {{args}} from the {ARGS} placeholder
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No TOML command file contains {{args}} placeholder"
has_dollar_args = any(
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
)
assert not has_dollar_args, (
"TOML command still contains $ARGUMENTS instead of {{args}}"
)
@pytest.mark.parametrize(
("frontmatter", "expected"),
[
(
"---\ndescription: |\n First line\n Second line\n---\nBody\n",
"First line\nSecond line\n",
),
(
"---\ndescription: >\n First line\n Second line\n---\nBody\n",
"First line Second line\n",
),
(
"---\ndescription: |-\n First line\n Second line\n---\nBody\n",
"First line\nSecond line",
),
(
"---\ndescription: >-\n First line\n Second line\n---\nBody\n",
"First line Second line",
),
],
)
def test_toml_extract_description_supports_block_scalars(
self, frontmatter, expected
):
assert TomlIntegration._extract_description(frontmatter) == expected
def test_split_frontmatter_ignores_indented_delimiters(self):
content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
frontmatter, body = TomlIntegration._split_frontmatter(content)
assert "line two" in frontmatter
assert body == "Body\n"
def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Summary line one\n"
"scripts:\n"
" sh: scripts/bash/example.sh\n"
"---\n"
"Body line one\n"
"Body line two\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
generated = cmd_files[0].read_text(encoding="utf-8")
parsed = tomllib.loads(generated)
assert parsed["description"] == "Summary line one"
assert parsed["prompt"] == "Body line one\nBody line two"
assert "description:" not in parsed["prompt"]
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
"""Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113)."""
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Test\n"
"scripts:\n"
" sh: echo ok\n"
"---\n"
"Check the following:\n"
'- Correct: "Is X clearly specified?"\n',
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
raw = cmd_files[0].read_text(encoding="utf-8")
assert '""""' not in raw, "closing delimiter must not merge with body quote"
assert '"""\n' in raw, "body must use multiline basic string"
parsed = tomllib.loads(raw)
assert parsed["prompt"].endswith('specified?"')
assert not parsed["prompt"].endswith("\n"), (
"parsed value must not gain a trailing newline"
)
def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch):
"""Body containing `\"\"\"` and ending with `'` falls back to escaped basic string."""
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Test\n"
"scripts:\n"
" sh: echo ok\n"
"---\n"
'Use """triple""" quotes\n'
"and end with 'single'\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
raw = cmd_files[0].read_text(encoding="utf-8")
assert "''''" not in raw, (
"literal string must not produce ambiguous closing quotes"
)
parsed = tomllib.loads(raw)
assert parsed["prompt"].endswith("'single'")
assert '"""triple"""' in parsed["prompt"]
assert not parsed["prompt"].endswith("\n"), (
"parsed value must not gain a trailing newline"
)
def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch):
"""Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline)."""
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Test\n"
"scripts:\n"
" sh: echo ok\n"
"---\n"
"Line one\n"
"Plain body content\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
raw = cmd_files[0].read_text(encoding="utf-8")
parsed = tomllib.loads(raw)
assert parsed["prompt"] == "Line one\nPlain body content"
assert raw.rstrip().endswith('content"""'), (
"closing delimiter should be inline when body does not end with a quote"
)
def test_toml_string_escapes_control_characters(self):
"""A value with control chars / a bare CR must render as parseable TOML.
TOML forbids literal control characters (U+0000U+001F except tab and
newline, plus U+007F) in every string form, and a bare CR that is not
part of a CRLF pair. The renderer used to emit these raw into a basic or
``\"\"\"`` multiline string, producing a config file that fails to parse."""
value = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
rendered = TomlIntegration._render_toml_string(value)
parsed = tomllib.loads(f"prompt = {rendered}")
assert parsed["prompt"] == value
def test_toml_is_valid(self, tmp_path):
"""Every generated TOML file must parse without errors."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
raw = f.read_bytes()
try:
parsed = tomllib.loads(raw.decode("utf-8"))
except Exception as exc:
raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc
assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key"
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan command must not reference one.
"""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
assert plan_file.exists(), f"Plan file {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content, (
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
)
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- Context file ownership (extension-owned, opt-in) -----------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
# -- CLI integration flag -------------------------------------------------
def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, (
f"init --integration {self.KEY} failed: {result.output}"
)
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
commands = sorted(cmd_dir.glob("speckit.*.toml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze",
"clarify",
"constitution",
"converge",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the expected file list for this integration + script variant."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files (.toml)
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.toml")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"ps",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
@@ -0,0 +1,481 @@
"""Reusable test mixin for standard YamlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``YamlIntegrationTests``.
Mirrors ``TomlIntegrationTests`` closely — same test structure,
adapted for YAML recipe output format.
"""
import os
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import YamlIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class YamlIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".goose/"
COMMANDS_SUBDIR: str — e.g. "recipes"
REGISTRAR_DIR: str — e.g. ".goose/recipes"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_yaml_integration(self):
assert isinstance(get_integration(self.KEY), YamlIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "yaml"
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".yaml"
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".yaml")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), (
f"Expected directory {expected_dir} was not created"
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_templates_are_processed(self, tmp_path):
"""Command files must have placeholders replaced."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_yaml_has_title(self, tmp_path):
"""Every YAML recipe should have a title field."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "title:" in content, f"{f.name} missing title field"
def test_yaml_has_prompt(self, tmp_path):
"""Every YAML recipe should have a prompt block scalar."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "prompt: |" in content, f"{f.name} missing prompt block scalar"
def test_yaml_uses_correct_arg_placeholder(self, tmp_path):
"""YAML recipes must use {{args}} placeholder."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No YAML recipe contains {{args}} placeholder"
has_dollar_args = any(
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
)
assert not has_dollar_args, (
"YAML recipe still contains $ARGUMENTS instead of {{args}}"
)
def test_yaml_is_valid(self, tmp_path):
"""Every generated YAML file must parse without errors."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
# Strip trailing source comment before parsing
lines = content.split("\n")
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
try:
parsed = yaml.safe_load("\n".join(yaml_lines))
except Exception as exc:
raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc
assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key"
assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key"
def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Summary line one\n"
"scripts:\n"
" sh: scripts/bash/example.sh\n"
"---\n"
"Body line one\n"
"Body line two\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
content = cmd_files[0].read_text(encoding="utf-8")
# Strip source comment for parsing
lines = content.split("\n")
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert "description:" not in parsed["prompt"]
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
"""A body whose first line is indented must still parse.
A bare ``|`` block scalar infers its indentation from the first
non-empty line, so a body starting with an indented line (e.g. a
markdown code block or nested list item) made the parser expect that
deeper indent for the whole block and reject the later, shallower
lines. The explicit ``|2`` indicator pins the indent so it parses."""
body = " indented first line\nback to normal\n indented again"
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
yaml_lines = [
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert parsed["prompt"].rstrip("\n") == body
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan command must not reference one.
"""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
assert plan_file.exists(), f"Plan file {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content, (
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
)
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- Context file ownership (extension-owned, opt-in) -----------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
# -- CLI integration flag -------------------------------------------------
def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, (
f"init --integration {self.KEY} failed: {result.output}"
)
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze",
"clarify",
"constitution",
"converge",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the expected file list for this integration + script variant."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files (.yaml)
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.yaml")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"ps",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
@@ -0,0 +1,10 @@
"""Tests for BobIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestBobIntegration(MarkdownIntegrationTests):
KEY = "bob"
FOLDER = ".bob/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".bob/commands"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,869 @@
"""Tests for ClaudeIntegration."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS
from specify_cli.integrations.manifest import IntegrationManifest
class TestClaudeIntegration:
def test_registered(self):
assert "claude" in INTEGRATION_REGISTRY
assert get_integration("claude") is not None
def test_is_base_integration(self):
assert isinstance(get_integration("claude"), IntegrationBase)
def test_config_uses_skills(self):
integration = get_integration("claude")
assert integration.config["folder"] == ".claude/"
assert integration.config["commands_subdir"] == "skills"
def test_registrar_config_uses_skill_layout(self):
integration = get_integration("claude")
assert integration.registrar_config["dir"] == ".claude/skills"
assert integration.registrar_config["format"] == "markdown"
assert integration.registrar_config["args"] == "$ARGUMENTS"
assert integration.registrar_config["extension"] == "/SKILL.md"
def test_setup_creates_skill_files(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
skill_files = [path for path in created if path.name == "SKILL.md"]
assert skill_files
skills_dir = tmp_path / ".claude" / "skills"
assert skills_dir.is_dir()
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists()
content = plan_skill.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content
assert "{ARGS}" not in content
assert "__AGENT__" not in content
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed["name"] == "speckit-plan"
assert parsed["user-invocable"] is True
assert parsed["disable-model-invocation"] is False
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
def test_render_skill_unicode(self):
"""Test rendering a skill preserves non-ASCII characters."""
integration = get_integration("claude")
rendered = integration._render_skill(
"constitution",
{"description": "Prüfe Konformität der Implementierung"},
"Body",
)
assert "Prüfe Konformität" in rendered
def test_setup_does_not_write_context_section(self, tmp_path):
"""The CLI no longer manages the agent context file — that is owned by
the opt-in agent-context extension. Setup must not create or touch it."""
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
"""A user-authored context file is left intact on teardown."""
integration = get_integration("claude")
ctx_path = tmp_path / "CLAUDE.md"
original = "# CLAUDE.md\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
integration.teardown(tmp_path, manifest)
assert ctx_path.read_text(encoding="utf-8") == original
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-promote"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"claude",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert not (project / ".claude" / "commands").exists()
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "claude"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "claude"
def test_integration_flag_creates_skill_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-integration"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"claude",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
def test_interactive_claude_selection_uses_integration_path(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "claude-interactive"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
with (
patch("specify_cli.commands.init._stdin_is_interactive", return_value=True),
patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
):
result = runner.invoke(
app,
[
"init",
"--here",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert (project / ".specify" / "integration.json").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert skill_file.exists()
skill_content = skill_file.read_text(encoding="utf-8")
assert "user-invocable: true" in skill_content
assert "disable-model-invocation: false" in skill_content
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["ai"] == "claude"
assert init_options["ai_skills"] is True
assert init_options["integration"] == "claude"
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
"""Claude init should succeed even without install_skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "fail-proj"
result = runner.invoke(
app,
["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
)
assert result.exit_code == 0
assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
def test_claude_hooks_render_skill_invocation(self, tmp_path):
from specify_cli.extensions import HookExecutor
project = tmp_path / "claude-hooks"
project.mkdir()
init_options = project / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True}))
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/speckit-plan`" in message
assert "EXECUTE_COMMAND: speckit.plan" in message
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
from specify_cli import save_init_options
from specify_cli.presets import PresetManager
project = tmp_path / "claude-preset-skill"
project.mkdir()
save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"})
skills_dir = project / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = tmp_path / "claude-skill-command"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.research.md").write_text(
"---\n"
"description: Research workflow\n"
"---\n\n"
"preset:claude-skill-command\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "claude-skill-command",
"name": "Claude Skill Command",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.research",
"file": "commands/speckit.research.md",
}
]
},
}
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)
manager = PresetManager(project)
manager.install_from_directory(preset_dir, "0.1.5")
skill_file = skills_dir / "speckit-research" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "preset:claude-skill-command" in content
assert "name: speckit-research" in content
assert "user-invocable: true" in content
assert "disable-model-invocation: false" in content
metadata = manager.registry.get("claude-skill-command")
assert "speckit-research" in metadata.get("registered_skills", [])
class TestClaudeArgumentHints:
"""Verify that argument-hint frontmatter is injected for Claude skills."""
def test_converge_has_no_argument_hint(self):
"""Converge should not advertise unsupported feature-name arguments."""
assert "converge" not in ARGUMENT_HINTS
def test_all_skills_have_hints(self, tmp_path):
"""Every skill with a configured hint must contain an argument-hint line."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-"):]
content = f.read_text(encoding="utf-8")
if stem in ARGUMENT_HINTS:
assert "argument-hint:" in content, (
f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
)
else:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
def test_hints_match_expected_values(self, tmp_path):
"""Each skill's argument-hint must match the expected text."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
# Extract stem: speckit-plan -> plan
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-"):]
expected_hint = ARGUMENT_HINTS.get(stem)
content = f.read_text(encoding="utf-8")
if expected_hint is None:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
else:
assert f'argument-hint: "{expected_hint}"' in content, (
f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
)
def test_hint_is_inside_frontmatter(self, tmp_path):
"""argument-hint must appear between the --- delimiters, not in the body."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
frontmatter = parts[1]
body = parts[2]
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-"):]
if stem in ARGUMENT_HINTS:
assert "argument-hint:" in frontmatter, (
f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
)
assert "argument-hint:" not in body, (
f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
)
else:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
def test_hint_appears_after_description(self, tmp_path):
"""argument-hint must immediately follow the description line."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
lines = content.splitlines()
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-"):]
if stem not in ARGUMENT_HINTS:
assert "argument-hint:" not in content, (
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
)
continue
found_description = False
for idx, line in enumerate(lines):
if line.startswith("description:"):
found_description = True
assert idx + 1 < len(lines), (
f"{f.parent.name}/SKILL.md: description is last line"
)
assert lines[idx + 1].startswith("argument-hint:"), (
f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
)
break
assert found_description, (
f"{f.parent.name}/SKILL.md: no description: line found in output"
)
def test_inject_argument_hint_only_in_frontmatter(self):
"""inject_argument_hint must not modify description: lines in the body."""
from specify_cli.integrations.claude import ClaudeIntegration
content = (
"---\n"
"description: My command\n"
"---\n"
"\n"
"description: this is body text\n"
)
result = ClaudeIntegration.inject_argument_hint(content, "Test hint")
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1, (
f"Expected exactly 1 argument-hint line, found {hint_count}"
)
def test_inject_argument_hint_skips_if_already_present(self):
"""inject_argument_hint must not duplicate if argument-hint already exists."""
from specify_cli.integrations.claude import ClaudeIntegration
content = (
"---\n"
"description: My command\n"
'argument-hint: "Existing hint"\n'
"---\n"
"\n"
"Body text\n"
)
result = ClaudeIntegration.inject_argument_hint(content, "New hint")
assert result == content, "Content should be unchanged when hint already exists"
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1
class TestClaudeDisableModelInvocation:
"""Verify disable-model-invocation is false for Claude skills."""
def test_setup_sets_disable_model_invocation_false(self, tmp_path):
"""Generated SKILL.md files must have disable-model-invocation: false."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed["disable-model-invocation"] is False, (
f"{f.parent.name}: expected disable-model-invocation: false"
)
def test_disable_model_invocation_not_true(self, tmp_path):
"""No Claude skill should have disable-model-invocation: true."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
for f in created:
if f.name != "SKILL.md":
continue
content = f.read_text(encoding="utf-8")
assert "disable-model-invocation: true" not in content, (
f"{f.parent.name}: must not have disable-model-invocation: true"
)
def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path):
"""Non-Claude skill agents should not get disable-model-invocation."""
from specify_cli.agents import CommandRegistrar
fm = CommandRegistrar.build_skill_frontmatter(
"codex", "speckit-plan", "desc", "templates/commands/plan.md"
)
assert "disable-model-invocation" not in fm
assert "user-invocable" not in fm
def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path):
"""SkillsIntegration agents without an override preserve non-hook content."""
# ``agy`` is a plain SkillsIntegration with no post-process override,
# so it stands in for the base-class default behavior.
agy = get_integration("agy")
if agy is None:
return # agy not registered in this build
content = "---\nname: test\n---\nBody"
assert agy.post_process_skill_content(content) == content
class TestClaudeForkContext:
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
def test_no_commands_fork_by_default(self):
"""FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork.
``analyze`` was removed (#3185) because its verbose report defeated the
purpose of forking and compounded context overhead across repeated runs.
"""
assert FORK_CONTEXT_COMMANDS == {}
def test_analyze_skill_does_not_fork(self, tmp_path):
"""speckit-analyze must run in the main session, not a forked subagent (#3185)."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
assert analyze_skill.exists()
content = analyze_skill.read_text(encoding="utf-8")
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert "context" not in parsed
assert "agent" not in parsed
def test_no_skills_fork(self, tmp_path):
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
stem = f.parent.name
if stem.startswith("speckit-"):
stem = stem[len("speckit-"):]
if stem in FORK_CONTEXT_COMMANDS:
continue
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert "context" not in parsed, (
f"{f.parent.name}: must not have context frontmatter"
)
assert "agent" not in parsed, (
f"{f.parent.name}: must not have agent frontmatter"
)
def test_post_process_no_fork_for_skills(self):
"""With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent."""
i = get_integration("claude")
for name in ("speckit-analyze", "speckit-plan"):
content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parsed = yaml.safe_load(result.split("---", 2)[1])
assert "context" not in parsed
assert "agent" not in parsed
def test_fork_mechanism_injects_when_configured(self, monkeypatch):
"""The injection mechanism still works for any command added to
FORK_CONTEXT_COMMANDS, even though none ships enabled by default."""
import specify_cli.integrations.claude as claude_mod
monkeypatch.setitem(
claude_mod.FORK_CONTEXT_COMMANDS,
"analyze",
{"context": "fork", "agent": "general-purpose"},
)
i = get_integration("claude")
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parts = result.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed.get("context") == "fork"
assert parsed.get("agent") == "general-purpose"
# Flags must land in the frontmatter, not the body.
assert "context: fork" in parts[1]
assert "context: fork" not in parts[2]
# Re-running must not duplicate the injected keys.
twice = i.post_process_skill_content(result)
assert result == twice
assert twice.count("context: fork") == 1
assert twice.count("agent: general-purpose") == 1
class TestClaudeHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
"""Skills that have hook sections should get the normalization note."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
# specify.md has hook sections
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
"""Skills without hook sections should not get the note."""
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
result = SkillsIntegration._inject_hook_command_note(content)
assert "replace dots" not in result
def test_hook_note_idempotent(self, tmp_path):
"""Injecting the note twice should not duplicate it."""
content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
once = SkillsIntegration._inject_hook_command_note(content)
twice = SkillsIntegration._inject_hook_command_note(once)
assert once == twice, "Hook note injection should be idempotent"
def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
"""Already-noted hook sections should not suppress later sections."""
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
content = (
"---\nname: test\n---\n\n"
f"{_HOOK_COMMAND_NOTE}"
"- For each executable hook, output the following based on its flag:\n"
"\n"
" - For each executable hook, output the following based on its flag:\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
assert result.count("replace dots (`.`) with hyphens") == 2
def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
"""Unrelated text should not trip the hook-note idempotence guard."""
content = (
"---\nname: test\n---\n\n"
"This paragraph says replace dots in a different context.\n"
"- For each executable hook, output the following based on its flag:\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
assert "This paragraph says replace dots in a different context." in result
assert result.count("replace dots (`.`) with hyphens") == 1
def test_hook_note_preserves_indentation(self, tmp_path):
"""The injected note should match the indentation of the target line."""
content = (
"---\nname: test\n---\n\n"
" - For each executable hook, output the following\n"
)
result = SkillsIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [line for line in lines if "replace dots" in line][0]
assert note_line.startswith(" "), "Note should preserve indentation"
def test_post_process_injects_all_claude_flags(self):
"""post_process_skill_content should inject all Claude-specific fields."""
i = get_integration("claude")
content = (
"---\nname: test\ndescription: test\n---\n\n"
"- For each executable hook, output the following\n"
)
result = i.post_process_skill_content(content)
assert "user-invocable: true" in result
assert "disable-model-invocation: false" in result
assert "replace dots" in result
class TestSpeckitManifestRecordsSkippedFiles:
"""Regression test for issue #2107.
``install_shared_infra`` must record every shared-infrastructure file
under ``.specify/`` in ``speckit.manifest.json``, including files that
were *skipped* because they already existed on disk and ``force=False``.
Before the fix, the skip branches in the scripts and templates loops
appended to ``skipped_files`` without calling ``manifest.record_existing``.
So when ``install_shared_infra`` ran with a fresh (or lost) manifest
against an already-populated ``.specify/`` tree, every file went down the
skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and
``manifest.save()`` wrote an empty ``files`` field — leaving the
integration believing nothing was installed.
Reproduction (without the fix) using ``install_shared_infra`` directly:
install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files
(p / ".specify/integrations/speckit.manifest.json").unlink()
install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files
# ^^ BUG: empty
"""
def _read_manifest_files(self, project_path: Path) -> dict:
manifest_path = (
project_path / ".specify" / "integrations" / "speckit.manifest.json"
)
assert manifest_path.exists(), (
f"speckit.manifest.json not written at {manifest_path}"
)
data = json.loads(manifest_path.read_text(encoding="utf-8"))
# ``IntegrationManifest.save`` serialises a ``files`` dict — assert
# the schema explicitly so a regression to a different key (e.g.
# the internal ``_files`` attribute name) fails loudly instead of
# being masked by a silent fallback.
assert isinstance(data, dict), (
f"manifest root is not a dict, got {type(data).__name__}"
)
assert "files" in data, (
f"manifest missing 'files' key, got keys: {sorted(data.keys())}"
)
files = data["files"]
assert isinstance(files, dict), (
f"manifest 'files' is not a dict, got {type(files).__name__}"
)
return files
def test_install_shared_infra_records_skipped_files(self, tmp_path):
"""With ``force=False`` and ``.specify/`` already populated, the
manifest must still record every file — the skip branches are not
allowed to drop files from the manifest."""
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
# Resolve the project's own packaged sources by walking up from this
# test file to the repo root (which contains ``scripts/`` and
# ``templates/`` that ``shared_scripts_source`` looks for).
repo_root = Path(__file__).resolve().parents[2]
console = Console(quiet=True)
# First run — fresh project, manifest gets populated normally.
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
first_files = self._read_manifest_files(tmp_path)
assert first_files, "first install produced an empty manifest"
# Simulate a lost manifest while ``.specify/`` is still on disk
# (e.g. the manifest was deleted, corrupted, or the layout was
# extracted out-of-band).
manifest_path = (
tmp_path / ".specify" / "integrations" / "speckit.manifest.json"
)
manifest_path.unlink()
# Second run — every file already exists, so every iteration takes
# the skip branch. With the fix, those files are still recorded.
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
second_files = self._read_manifest_files(tmp_path)
assert second_files, (
"speckit.manifest.json files dict is empty after install with "
"skipped files (issue #2107) — every file went down the skip "
"branch but none were recorded"
)
# The recovered manifest must cover everything the first run tracked.
missing = set(first_files) - set(second_files)
assert not missing, (
f"these files were tracked on the first install but missing after "
f"the skipped-files re-install: {sorted(missing)[:5]}"
)
def test_install_shared_infra_handles_directory_at_script_destination(
self, tmp_path
):
"""A non-file (directory) at a script's destination must NOT crash
``install_shared_infra`` and must NOT be recorded in the manifest —
the path still appears in the user-visible skipped-paths warning.
"""
from io import StringIO
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
repo_root = Path(__file__).resolve().parents[2]
output = StringIO()
console = Console(file=output, force_terminal=False, width=200)
# Pre-create the .specify/scripts/bash tree, then plant a directory
# where a script file is expected so the skip branch hits a
# non-regular-file path.
bash_dir = tmp_path / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True)
(bash_dir / "common.sh").mkdir() # collision: dir where file expected
# Must not crash.
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
files = self._read_manifest_files(tmp_path)
assert ".specify/scripts/bash/common.sh" not in files, (
"directory at script dst must not be recorded in the manifest"
)
text = output.getvalue()
assert "common.sh" in text, (
"directory-at-script-dst path must surface in the skipped warning"
)
def test_install_shared_infra_handles_directory_at_template_destination(
self, tmp_path
):
"""Symmetric coverage for the templates loop: a directory at a
template's destination must NOT crash install nor be recorded."""
from io import StringIO
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
repo_root = Path(__file__).resolve().parents[2]
output = StringIO()
console = Console(file=output, force_terminal=False, width=200)
templates_dir = tmp_path / ".specify" / "templates"
templates_dir.mkdir(parents=True)
src_templates = repo_root / "templates"
real_template = next(
(
p.name
for p in src_templates.iterdir()
if p.is_file()
and not p.name.startswith(".")
and p.name != "vscode-settings.json"
),
None,
)
assert real_template, (
"no real template found in repo to collide against"
)
(templates_dir / real_template).mkdir() # collision
install_shared_infra(
tmp_path,
"sh",
version="0.0.0",
core_pack=None,
repo_root=repo_root,
console=console,
force=False,
)
files = self._read_manifest_files(tmp_path)
template_rel = f".specify/templates/{real_template}"
assert template_rel not in files, (
"directory at template dst must not be recorded in manifest"
)
text = output.getvalue()
assert real_template in text, (
"directory-at-template-dst path must surface in the skipped warning"
)
@@ -0,0 +1,222 @@
"""Tests for ClineIntegration."""
import os
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.cline import format_cline_command_name
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestClineCommandNameFormatter:
"""Test the Cline command name formatter."""
def test_simple_name_without_prefix(self):
"""Test formatting a simple name without 'speckit.' prefix."""
assert format_cline_command_name("plan") == "speckit-plan"
assert format_cline_command_name("tasks") == "speckit-tasks"
assert format_cline_command_name("specify") == "speckit-specify"
def test_name_with_speckit_prefix(self):
"""Test formatting a name that already has 'speckit.' prefix."""
assert format_cline_command_name("speckit.plan") == "speckit-plan"
assert format_cline_command_name("speckit.tasks") == "speckit-tasks"
def test_extension_command_name(self):
"""Test formatting extension command names with dots."""
assert (
format_cline_command_name("speckit.my-extension.example")
== "speckit-my-extension-example"
)
assert (
format_cline_command_name("my-extension.example")
== "speckit-my-extension-example"
)
def test_idempotent_already_hyphenated(self):
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
assert format_cline_command_name("speckit-plan") == "speckit-plan"
assert (
format_cline_command_name("speckit-my-extension-example")
== "speckit-my-extension-example"
)
class TestClineIntegration(MarkdownIntegrationTests):
KEY = "cline"
FOLDER = ".clinerules/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".clinerules/workflows"
@pytest.mark.parametrize(
"cmd_name, expected_filename",
[
("plan", "speckit-plan.md"),
("speckit.plan", "speckit-plan.md"),
("speckit.git.commit", "speckit-git-commit.md"),
("speckit", "speckit-speckit.md"),
("speckitfoo", "speckit-speckitfoo.md"),
],
)
def test_cline_command_filename(self, cmd_name, expected_filename):
"""Verify Cline uses hyphenated filenames."""
cline = get_integration("cline")
assert cline.command_filename(cmd_name) == expected_filename
def test_cline_invoke_separator(self):
"""Verify Cline uses hyphen as invoke separator."""
cline = get_integration("cline")
assert cline.invoke_separator == "-"
assert cline.registrar_config["invoke_separator"] == "-"
def test_cline_name_injection_and_formatting(self):
"""Verify Cline has inject_name and format_name configured."""
cline = get_integration("cline")
assert cline.registrar_config["inject_name"] is True
assert cline.registrar_config["format_name"] == format_cline_command_name
def test_cline_handoff_rewrite(self):
"""Verify Cline rewrites agent: speckit.foo to agent: speckit-foo."""
cline = get_integration("cline")
content = "---\nagent: speckit.plan\n---\n"
rewritten = cline._rewrite_handoff_references(content)
assert rewritten == "---\nagent: speckit-plan\n---\n"
def test_cline_hook_instruction_injection(self):
"""Verify Cline injects the dot-to-hyphen note for hooks."""
cline = get_integration("cline")
content = "- For each executable hook, output the following:\n"
injected = cline._inject_hook_command_note(content)
assert "replace dots (`.`) with hyphens (`-`)" in injected
assert "- For each executable hook, output the following:" in injected
def test_cline_hook_instruction_injection_no_trailing_newline(self):
"""Note must not collapse onto the instruction line when the
instruction is the final line with no trailing newline.
The injection regex matches the end-of-line via ``(\\r\\n|\\n|$)``, so
the captured ``eol`` is empty on a file's last line that lacks a
trailing newline. Without an ``or "\\n"`` fallback the note text and
the instruction are emitted on the same line.
"""
cline = get_integration("cline")
content = "- For each executable hook, output the following:" # no trailing \n
injected = cline._inject_hook_command_note(content)
assert "replace dots (`.`) with hyphens (`-`)" in injected
# Instruction stays on its own line rather than being mashed onto the note.
assert "\n- For each executable hook, output the following:" in injected
# -- Overrides for MarkdownIntegrationTests ---------------------------
def test_setup_creates_files(self, tmp_path):
from specify_cli.integrations.manifest import IntegrationManifest
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [
f
for f in created
if "scripts" not in f.parts
and f.suffix == ".md"
]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit-")
assert f.name.endswith(".md")
specify_file = next(
(f for f in cmd_files if f.name == "speckit-specify.md"), None
)
assert specify_file is not None
specify_contents = specify_file.read_text(encoding="utf-8")
assert "/speckit-plan" in specify_contents
assert "/speckit.plan" not in specify_contents
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir()
commands = sorted(cmd_dir.glob("speckit-*"))
assert len(commands) > 0
def _expected_files(self, script_variant: str) -> list[str]:
"""Override to expect hyphenated speckit- prefix."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files
for stem in (
self.COMMANDS_SUBDIR_STEMS
if hasattr(self, "COMMANDS_SUBDIR_STEMS")
else self.COMMAND_STEMS
):
files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# Bundled workflow
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
return sorted(files)
@@ -0,0 +1,21 @@
"""Tests for CodebuddyIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestCodebuddyIntegration(MarkdownIntegrationTests):
KEY = "codebuddy"
FOLDER = ".codebuddy/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".codebuddy/commands"
def test_install_url_points_to_official_cli_install_docs(self):
integration = get_integration(self.KEY)
assert integration is not None
assert (
integration.config["install_url"]
== "https://www.codebuddy.cn/docs/cli/installation"
)
@@ -0,0 +1,192 @@
"""Tests for CodexIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
class TestCodexIntegration(SkillsIntegrationTests):
KEY = "codex"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
class TestCodexInitFlow:
"""--integration codex creates expected files."""
def test_integration_codex_creates_skills(self, tmp_path):
"""--integration codex should create skills in .agents/skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""The core plan skill must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
target = tmp_path / "test-proj"
target.mkdir()
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_plan_skill_ignores_extension_config(self, tmp_path):
"""The extension config must not influence rendered commands: the CLI
no longer reads any context-file metadata when rendering."""
import yaml
target = tmp_path / "test-proj"
target.mkdir()
ext_cfg = (
target
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
ext_cfg.parent.mkdir(parents=True, exist_ok=True)
ext_cfg.write_text(
yaml.safe_dump(
{
"context_file": "FROM_CONFIG.md",
"context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"],
}
),
encoding="utf-8",
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "FROM_CONFIG.md" not in content
assert "ALSO_CONFIG.md" not in content
assert "__CONTEXT_FILE__" not in content
class TestCodexHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections.
Hook commands in ``extensions.yml`` use dotted ids like
``speckit.git.commit`` but Codex skills are named with hyphens
(``speckit-git-commit``). Without this note, Codex emits
``/speckit.git.commit``, which does not resolve.
"""
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
"""Skills that have hook sections should get the normalization note."""
i = get_integration("codex")
m = IntegrationManifest("codex", tmp_path)
i.setup(tmp_path, m, script_type="sh")
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
def test_hook_note_not_in_skills_without_hooks(self):
"""Skills without hook sections should not get the note."""
from specify_cli.integrations.codex import CodexIntegration
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
result = CodexIntegration._inject_hook_command_note(content)
assert "replace dots" not in result
def test_hook_note_idempotent(self):
"""Injecting the note twice should not duplicate it."""
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
once = CodexIntegration._inject_hook_command_note(content)
twice = CodexIntegration._inject_hook_command_note(once)
assert once == twice, "Hook note injection should be idempotent"
def test_hook_note_fills_missing_repeated_instructions(self):
"""Already-noted hook sections should not suppress later sections."""
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
f"{_HOOK_COMMAND_NOTE}"
"- For each executable hook, output the following based on its flag:\n"
"\n"
" - For each executable hook, output the following based on its flag:\n"
)
result = CodexIntegration._inject_hook_command_note(content)
assert result.count("replace dots (`.`) with hyphens") == 2
def test_hook_note_not_suppressed_by_unrelated_phrase(self):
"""Unrelated text should not trip the hook-note idempotence guard."""
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
"This paragraph says replace dots in a different context.\n"
"- For each executable hook, output the following based on its flag:\n"
)
result = CodexIntegration._inject_hook_command_note(content)
assert "This paragraph says replace dots in a different context." in result
assert result.count("replace dots (`.`) with hyphens") == 1
def test_hook_note_preserves_indentation(self):
"""The injected note should match the indentation of the target line."""
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
" - For each executable hook, output the following\n"
)
result = CodexIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [line for line in lines if "replace dots" in line][0]
assert note_line.startswith(" "), "Note should preserve indentation"
def test_hook_note_when_instruction_is_final_line_without_newline(self):
"""Note must not collapse onto the instruction line when the file
ends without a trailing newline and the preceding line is not blank.
"""
from specify_cli.integrations.codex import CodexIntegration
# No blank line before the instruction and no trailing newline:
# this is the case where the captured ``eol`` is empty and the
# captured indent is also empty, so a missing line separator would
# cause the note and instruction to collapse onto one line.
content = (
"---\nname: test\n---\n"
"Body line\n"
"- For each executable hook, output the following"
)
result = CodexIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line_idx = next(
i for i, line in enumerate(lines) if "replace dots" in line
)
instruction_line_idx = next(
i for i, line in enumerate(lines)
if line.lstrip().startswith("- For each executable hook")
)
assert note_line_idx < instruction_line_idx, (
"Note must appear before the instruction"
)
assert "For each executable hook" not in lines[note_line_idx], (
"Note and instruction must not be on the same line"
)
@@ -0,0 +1,827 @@
"""Tests for CopilotIntegration."""
import json
import os
import warnings
import pytest
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
class TestCopilotIntegration:
def test_copilot_key_and_config(self):
copilot = get_integration("copilot")
assert copilot is not None
assert copilot.key == "copilot"
assert copilot.config["folder"] == ".github/"
assert copilot.config["commands_subdir"] == "agents"
assert copilot.registrar_config["extension"] == ".agent.md"
def test_command_filename_agent_md(self):
copilot = get_integration("copilot")
assert copilot.command_filename("plan") == "speckit.plan.agent.md"
def test_setup_creates_agent_md_files(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
assert len(created) > 0
agent_files = [f for f in created if ".agent." in f.name]
assert len(agent_files) > 0
for f in agent_files:
assert f.parent == tmp_path / ".github" / "agents"
assert f.name.endswith(".agent.md")
def test_setup_warns_legacy_markdown_default_is_deprecated(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
with pytest.warns(UserWarning, match="Copilot legacy markdown mode is deprecated"):
created = copilot.setup(tmp_path, m)
assert any(f.name.endswith(".agent.md") for f in created)
def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
assert not any(
"Copilot legacy markdown mode is deprecated" in str(item.message)
for item in caught
)
assert any(f.name == "SKILL.md" for f in created)
def test_setup_creates_companion_prompts(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
prompt_files = [f for f in created if f.parent.name == "prompts"]
assert len(prompt_files) > 0
for f in prompt_files:
assert f.name.endswith(".prompt.md")
content = f.read_text(encoding="utf-8")
assert content.startswith("---\nagent: speckit.")
def test_agent_and_prompt_counts_match(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
agents = [f for f in created if ".agent.md" in f.name]
prompts = [f for f in created if ".prompt.md" in f.name]
assert len(agents) == len(prompts)
def test_setup_creates_vscode_settings_new(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
assert copilot._vscode_settings_path() is not None
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
settings = tmp_path / ".vscode" / "settings.json"
assert settings.exists()
assert settings in created
assert any("settings.json" in k for k in m.files)
def test_setup_merges_existing_vscode_settings(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir(parents=True)
existing = {"editor.fontSize": 14, "custom.setting": True}
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
settings = tmp_path / ".vscode" / "settings.json"
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["editor.fontSize"] == 14
assert data["custom.setting"] is True
assert settings not in created
assert not any("settings.json" in k for k in m.files)
def test_all_created_files_tracked_in_manifest(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"Created file {rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = copilot.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = copilot.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
def test_directory_structure(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
agents_dir = tmp_path / ".github" / "agents"
assert agents_dir.is_dir()
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
assert len(agent_files) == 10
expected_commands = {
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
}
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
assert actual_commands == expected_commands
def test_templates_are_processed(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
agents_dir = tmp_path / ".github" / "agents"
for agent_file in agents_dir.glob("speckit.*.agent.md"):
content = agent_file.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content
def test_specify_agent_resolves_active_spec_template(self, tmp_path):
"""Generated specify agent must not hardcode the core spec template."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
assert "specify preset resolve spec-template" in content
assert "resolved active `spec-template`" in content
assert "Copy `.specify/templates/spec-template.md`" not in content
assert "Load `.specify/templates/spec-template.md`" not in content
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([
".github/agents/speckit.analyze.agent.md",
".github/agents/speckit.checklist.agent.md",
".github/agents/speckit.clarify.agent.md",
".github/agents/speckit.constitution.agent.md",
".github/agents/speckit.converge.agent.md",
".github/agents/speckit.implement.agent.md",
".github/agents/speckit.plan.agent.md",
".github/agents/speckit.specify.agent.md",
".github/agents/speckit.tasks.agent.md",
".github/agents/speckit.taskstoissues.agent.md",
".github/prompts/speckit.analyze.prompt.md",
".github/prompts/speckit.checklist.prompt.md",
".github/prompts/speckit.clarify.prompt.md",
".github/prompts/speckit.constitution.prompt.md",
".github/prompts/speckit.converge.prompt.md",
".github/prompts/speckit.implement.prompt.md",
".github/prompts/speckit.plan.prompt.md",
".github/prompts/speckit.specify.prompt.md",
".github/prompts/speckit.tasks.prompt.md",
".github/prompts/speckit.taskstoissues.prompt.md",
".vscode/settings.json",
".specify/integration.json",
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration copilot --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-ps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "ps",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([
".github/agents/speckit.analyze.agent.md",
".github/agents/speckit.checklist.agent.md",
".github/agents/speckit.clarify.agent.md",
".github/agents/speckit.constitution.agent.md",
".github/agents/speckit.converge.agent.md",
".github/agents/speckit.implement.agent.md",
".github/agents/speckit.plan.agent.md",
".github/agents/speckit.specify.agent.md",
".github/agents/speckit.tasks.agent.md",
".github/agents/speckit.taskstoissues.agent.md",
".github/prompts/speckit.analyze.prompt.md",
".github/prompts/speckit.checklist.prompt.md",
".github/prompts/speckit.clarify.prompt.md",
".github/prompts/speckit.constitution.prompt.md",
".github/prompts/speckit.converge.prompt.md",
".github/prompts/speckit.implement.prompt.md",
".github/prompts/speckit.plan.prompt.md",
".github/prompts/speckit.specify.prompt.md",
".github/prompts/speckit.tasks.prompt.md",
".github/prompts/speckit.taskstoissues.prompt.md",
".vscode/settings.json",
".specify/integration.json",
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/constitution.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_default_cli_init_warns_legacy_markdown_is_deprecated(self, tmp_path):
"""Default Copilot init should warn users about the future skills default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "default-warning"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
with pytest.warns(
UserWarning,
match="Copilot legacy markdown mode is deprecated",
):
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
def test_skills_cli_init_does_not_warn_about_legacy_markdown(self, tmp_path):
"""Explicit Copilot skills mode should not warn about the legacy default."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "skills-no-warning"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert not any(
"Copilot legacy markdown mode is deprecated" in str(item.message)
for item in caught
)
class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
_SKILL_COMMANDS = [
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
def _make_copilot(self):
from specify_cli.integrations.copilot import CopilotIntegration
return CopilotIntegration()
def _setup_skills(self, copilot, tmp_path):
m = IntegrationManifest("copilot", tmp_path)
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
return created, m
# -- Options ----------------------------------------------------------
def test_options_include_skills_flag(self):
copilot = get_integration("copilot")
opts = copilot.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 1
assert skills_opts[0].is_flag is True
assert skills_opts[0].default is False
# -- Skills directory structure ---------------------------------------
def test_skills_creates_skill_files(self, tmp_path):
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
assert len(created) > 0
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
assert f.exists()
assert f.parent.name.startswith("speckit-")
def test_skills_directory_under_github_skills(self, tmp_path):
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skills_dir = tmp_path / ".github" / "skills"
assert skills_dir.is_dir()
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
assert f.resolve().parent.parent == skills_dir.resolve(), (
f"{f} is not under {skills_dir}"
)
def test_skills_directory_structure(self, tmp_path):
"""Each command produces speckit-<name>/SKILL.md."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
expected_commands = set(self._SKILL_COMMANDS)
actual_commands = set()
for f in skill_files:
skill_dir_name = f.parent.name
assert skill_dir_name.startswith("speckit-")
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
assert actual_commands == expected_commands
# -- No companion files in skills mode --------------------------------
def test_skills_no_prompt_md_companions(self, tmp_path):
"""Skills mode must not generate .prompt.md companion files."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
assert prompt_files == []
prompts_dir = tmp_path / ".github" / "prompts"
if prompts_dir.exists():
assert list(prompts_dir.iterdir()) == []
def test_skills_no_vscode_settings(self, tmp_path):
"""Skills mode must not create or merge .vscode/settings.json."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
settings = tmp_path / ".vscode" / "settings.json"
assert not settings.exists()
def test_skills_no_agent_md_files(self, tmp_path):
"""Skills mode must not produce .agent.md files."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
agent_files = [f for f in created if f.name.endswith(".agent.md")]
assert agent_files == []
# -- Frontmatter structure --------------------------------------------
def test_skill_frontmatter_structure(self, tmp_path):
"""SKILL.md must have name, description, compatibility, metadata."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---\n"), f"{f} missing frontmatter"
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "name" in fm, f"{f} frontmatter missing 'name'"
assert "description" in fm, f"{f} frontmatter missing 'description'"
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
assert fm["metadata"]["author"] == "github-spec-kit"
# -- Copilot-specific post-processing ---------------------------------
def test_post_process_skill_content_does_not_inject_mode(self):
"""post_process_skill_content() must NOT inject mode: — VS Code Copilot does not support it."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-plan"\n'
'description: "Plan workflow"\n'
"---\n"
"\nBody content\n"
)
updated = copilot.post_process_skill_content(content)
assert "mode:" not in updated
def test_post_process_skill_content_injects_hook_note(self):
"""post_process_skill_content() should inject shared hook guidance but not mode:."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-specify"\n'
'description: "Specify workflow"\n'
"---\n"
"\n- For each executable hook, output the following\n"
)
updated = copilot.post_process_skill_content(content)
assert "replace dots" in updated
assert "mode:" not in updated
def test_post_process_idempotent(self):
"""post_process_skill_content() must be idempotent."""
copilot = self._make_copilot()
content = (
"---\n"
'name: "speckit-plan"\n'
'description: "Plan workflow"\n'
"---\n"
"\nBody content\n"
)
first = copilot.post_process_skill_content(content)
second = copilot.post_process_skill_content(first)
assert first == second
def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path):
"""Generated SKILL.md files must NOT contain mode: — VS Code Copilot does not support it."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
fm = yaml.safe_load(parts[1])
assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field"
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
"""Generated skills with hook sections should include shared hook guidance."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md"
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content
# -- Template processing ----------------------------------------------
def test_skills_templates_are_processed(self, tmp_path):
"""Skill body must have placeholders replaced."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_skills_command_refs_use_hyphen(self, tmp_path):
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) > 0
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert "/speckit." not in content, (
f"{f.name} contains dot-notation /speckit. reference; "
f"skills mode must use /speckit-<name>"
)
def test_skills_mode_invoke_separator(self):
"""Copilot effective_invoke_separator should reflect skills mode."""
copilot = self._make_copilot()
assert copilot.effective_invoke_separator() == "."
assert copilot.effective_invoke_separator({"skills": True}) == "-"
assert copilot.effective_invoke_separator({"skills": False}) == "."
def test_skill_body_has_content(self, tmp_path):
"""Each SKILL.md body should contain template content."""
copilot = self._make_copilot()
created, _ = self._setup_skills(copilot, tmp_path)
skill_files = [f for f in created if f.name == "SKILL.md"]
for f in skill_files:
content = f.read_text(encoding="utf-8")
parts = content.split("---", 2)
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""The core plan skill must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
# -- Manifest tracking ------------------------------------------------
def test_all_files_tracked_in_manifest(self, tmp_path):
copilot = self._make_copilot()
created, m = self._setup_skills(copilot, tmp_path)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
# -- Install/uninstall roundtrip --------------------------------------
def test_install_uninstall_roundtrip(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = copilot.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
copilot = self._make_copilot()
m = IntegrationManifest("copilot", tmp_path)
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = copilot.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- build_command_invocation -----------------------------------------
def test_build_command_invocation_skills_mode(self):
copilot = self._make_copilot()
copilot._skills_mode = True
assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan") == "/speckit-plan"
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
def test_build_command_invocation_skills_extension_command(self):
copilot = self._make_copilot()
copilot._skills_mode = True
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
def test_build_command_invocation_default_mode(self):
copilot = self._make_copilot()
assert copilot.build_command_invocation("plan", "my args") == "my args"
assert copilot.build_command_invocation("plan") == ""
# -- Context section ---------------------------------------------------
def test_skills_setup_does_not_write_context_section(self, tmp_path):
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
# -- CLI integration test ---------------------------------------------
def test_init_with_integration_options_skills(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-skills"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
skills_dir = project / ".github" / "skills"
assert skills_dir.is_dir(), "Skills directory was not created"
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
# Verify no default-mode artifacts
assert not (project / ".github" / "agents").exists()
assert not (project / ".github" / "prompts").exists()
assert not (project / ".vscode" / "settings.json").exists()
def test_complete_file_inventory_skills_sh(self, tmp_path):
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-skills-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([
# Skill files (core commands)
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
# Integration metadata
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/copilot.manifest.json",
".specify/integrations/speckit.manifest.json",
# Scripts (sh)
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
# Templates
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/memory/constitution.md",
# Bundled workflow
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
# -- Singleton leak: _skills_mode must reset --------------------------
def test_skills_mode_resets_on_default_setup(self, tmp_path):
"""setup() with skills=True then without must reset _skills_mode."""
copilot = self._make_copilot()
# First call: skills mode
(tmp_path / "proj1").mkdir()
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
assert copilot._skills_mode is True
# Second call: default mode (no skills option)
(tmp_path / "proj2").mkdir()
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
copilot.setup(tmp_path / "proj2", m2)
assert copilot._skills_mode is False
# build_command_invocation must use default (dotted) mode
assert copilot.build_command_invocation("plan", "args") == "args"
# -- Auto-detection must ignore unrelated .github/skills/ -------------
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
copilot = self._make_copilot()
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
unrelated.mkdir(parents=True)
(unrelated / "README.md").write_text("# GitHub Skills training\n")
# Should NOT detect skills mode — cli_args should contain --agent
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
call_args = mock_run.call_args[0][0]
assert "--agent" in call_args, (
f"Expected --agent in cli_args but got: {call_args}"
)
assert "speckit.plan" in call_args
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
copilot = self._make_copilot()
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
import unittest.mock as mock
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
call_args = mock_run.call_args[0][0]
assert "--agent" not in call_args, (
f"Skills mode should not use --agent, got: {call_args}"
)
prompt = call_args[call_args.index("-p") + 1]
assert "/speckit-plan" in prompt, (
f"Skills mode prompt should invoke /speckit-plan, got: {prompt}"
)
assert "my args" in prompt, (
f"Skills mode prompt should preserve user args, got: {prompt}"
)
# -- Next-steps display for Copilot skills mode -----------------------
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "copilot-nextsteps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot",
"--integration-options", "--skills",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
# Skills mode should show /speckit-plan (hyphenated)
assert "/speckit-plan" in result.output, (
f"Expected /speckit-plan in next steps but got:\n{result.output}"
)
# Must NOT show the dotted /speckit.plan form
assert "/speckit.plan" not in result.output, (
f"Should not show /speckit.plan in skills mode:\n{result.output}"
)
@@ -0,0 +1,233 @@
"""Tests for CursorAgentIntegration."""
from urllib.parse import urlparse
from specify_cli.integrations import get_integration
from .test_integration_base_skills import SkillsIntegrationTests
class TestCursorAgentIntegration(SkillsIntegrationTests):
KEY = "cursor-agent"
FOLDER = ".cursor/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".cursor/skills"
class TestCursorAgentInitFlow:
"""--integration cursor-agent creates expected files."""
def test_integration_cursor_agent_creates_skills(self, tmp_path):
"""--integration cursor-agent should create skills in .cursor/skills."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
class TestCursorAgentCliDispatch:
"""Verify the CLI dispatch path for cursor-agent (issue #2629).
The ``cursor-agent`` CLI supports headless execution via ``-p`` (with
full tool access including write/shell) and requires ``--trust`` to
bypass the Workspace Trust prompt. These tests pin the exact argv
shape that the workflow runner will use.
"""
def test_requires_cli_is_false_for_ide_first_flow(self):
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
``specify init --integration cursor-agent`` (without ``--ignore-agent-tools``)
treats ``requires_cli=True`` as a hard precheck and fails when the
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
/ skills flow can run without it. Workflow dispatch support is
signalled by overriding ``build_exec_args()`` instead, mirroring
``CopilotIntegration``.
"""
i = get_integration("cursor-agent")
assert i.config.get("requires_cli") is False
def test_install_url_is_set(self):
i = get_integration("cursor-agent")
url = i.config.get("install_url")
assert url is not None
# CodeQL: use a hostname comparison instead of a substring check
# to avoid the "Incomplete URL substring sanitization" warning
# (substring "cursor.com" can also appear in attacker-controlled
# positions of an arbitrary URL).
host = (urlparse(url).hostname or "").lower()
assert host == "cursor.com" or host.endswith(".cursor.com")
def test_build_exec_args_default_includes_headless_flags_and_json(self):
"""Default argv emits the full headless flag set: -p --trust
--approve-mcps --force, then prompt, then --output-format json.
"""
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-specify some-feature")
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-specify some-feature",
"--output-format", "json",
]
def test_build_exec_args_text_output_omits_format(self):
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-plan",
]
def test_build_exec_args_with_model(self):
i = get_integration("cursor-agent")
args = i.build_exec_args(
"/speckit-specify", model="sonnet-4-thinking", output_json=False
)
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-specify",
"--model", "sonnet-4-thinking",
]
def test_build_exec_args_contains_mandatory_headless_flags(self):
"""The four headless flags must always appear together.
``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc)
actually load in headless mode; ``--force`` is required so the
agent doesn't block on tool-call approval prompts during the
speckit workflow. Together with ``-p`` and ``--trust`` they
bring cursor-agent's headless behaviour in line with
``claude -p`` / ``codex --exec`` from spec-kit's perspective.
"""
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-implement", output_json=False)
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
assert flag in args, f"missing mandatory headless flag: {flag}"
def test_build_exec_args_supports_dispatch_without_requires_cli(self):
"""``build_exec_args`` must return argv even though ``requires_cli``
is ``False``.
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
precheck (so ``specify init`` doesn't fail when the CLI isn't on
PATH) but still supports workflow dispatch. The presence of a
non-``None`` argv from ``build_exec_args()`` is what the engine
keys off — pin that invariant.
"""
i = get_integration("cursor-agent")
assert i.config.get("requires_cli") is False
argv = i.build_exec_args("/speckit-plan", output_json=False)
assert argv is not None
assert argv[0] == "cursor-agent"
def test_build_exec_args_honors_executable_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE`` overrides argv[0].
Every other CLI-dispatch integration (codex, devin, ...) routes
argv[0] through ``_resolve_executable()`` so operators can pin a
binary path (issue #2596). cursor-agent hardcoded ``self.key`` and
silently ignored the documented override.
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE", "/custom/cursor"
)
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args[0] == "/custom/cursor"
# The mandatory headless flags must still be present.
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
assert flag in args
def test_build_exec_args_honors_extra_args_override(self, monkeypatch):
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS`` flags are injected
*before* Spec Kit's canonical ``--model`` / ``--output-format`` flags.
The ``_apply_extra_args_env_var()`` hook (issue #2595) was never
invoked by cursor-agent, so operator-supplied flags were dropped.
Insertion order is the real contract: extra args must land after the
mandatory headless flags but before ``--model`` / ``--output-format``,
so they cannot clobber, displace, or reorder Spec Kit's canonical
trailing flags. Exercise with both a model and JSON output so both
canonical flags are present to pin against.
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS", "--foo bar"
)
i = get_integration("cursor-agent")
args = i.build_exec_args(
"/speckit-plan", model="sonnet-4-thinking", output_json=True
)
assert "--foo" in args
assert "bar" in args
# "bar" is the value of "--foo": the tokens stay adjacent and in order.
assert args.index("bar") == args.index("--foo") + 1
# Extra args are inserted before the canonical flags, so they cannot
# clobber or reorder them (the behavioral contract this test guards).
assert args.index("--foo") < args.index("--model")
assert args.index("--foo") < args.index("--output-format")
# The canonical flags themselves remain intact and correctly paired.
assert args[args.index("--model") + 1] == "sonnet-4-thinking"
assert args[args.index("--output-format") + 1] == "json"
def test_build_command_invocation_uses_hyphenated_skill_name(self):
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
i = get_integration("cursor-agent")
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
and finds them, but Python's ``subprocess.run`` calls
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
``shutil.which`` so the full ``.cmd`` path is what reaches
``CreateProcess``.
"""
from unittest.mock import patch, MagicMock
i = get_integration("cursor-agent")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "ok"
mock_result.stderr = ""
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
with patch(
"specify_cli.integrations.base.shutil.which", return_value=fake_path
), patch("subprocess.run", return_value=mock_result) as mock_run:
result = i.dispatch_command(
"speckit.plan", args="feature-x", stream=False, timeout=5
)
assert result["exit_code"] == 0
argv = mock_run.call_args[0][0]
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"]
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
existing ``FileNotFoundError`` path remains observable to callers."""
from unittest.mock import patch, MagicMock
i = get_integration("cursor-agent")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
with patch(
"specify_cli.integrations.base.shutil.which", return_value=None
), patch("subprocess.run", return_value=mock_result) as mock_run:
i.dispatch_command("speckit.plan", stream=False, timeout=5)
argv = mock_run.call_args[0][0]
assert argv[0] == "cursor-agent"
@@ -0,0 +1,74 @@
"""Tests for DevinIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestDevinIntegration(SkillsIntegrationTests):
KEY = "devin"
FOLDER = ".devin/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".devin/skills"
class TestDevinBuildExecArgs:
"""Regression tests for DevinIntegration.build_exec_args.
Devin's CLI has no --output-format flag, so build_exec_args must
omit it regardless of the output_json argument. The integration
must also remain dispatchable (must not return None, which is the
codebase's IDE-only sentinel checked by CommandStep).
"""
def test_returns_args_not_none_for_dispatch(self):
"""Devin is CLI-dispatchable; build_exec_args must not return None."""
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args = impl.build_exec_args("test prompt")
assert args is not None, (
"DevinIntegration.build_exec_args must not return None. "
"None is the codebase sentinel for IDE-only integrations "
"(see KilocodeIntegration); Devin is dispatchable via 'devin -p'."
)
assert args[:3] == ["devin", "-p", "test prompt"]
def test_output_json_does_not_emit_output_format_flag(self):
"""Devin has no --output-format flag; output_json=True must not add it."""
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args_json = impl.build_exec_args("hello", output_json=True)
args_text = impl.build_exec_args("hello", output_json=False)
assert "--output-format" not in args_json
assert "json" not in args_json[3:]
# The two should be identical: output_json is documented as having
# no effect on the command line for Devin (plain-text stdout).
assert args_json == args_text
def test_model_flag_passed_through(self):
"""--model is supported and should appear when provided."""
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args = impl.build_exec_args("hi", model="claude-sonnet-4")
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
class TestDevinInitFlow:
"""--integration devin creates expected files."""
def test_integration_devin_creates_skills(self, tmp_path):
"""--integration devin should create skills directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(
app,
["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
)
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
@@ -0,0 +1,44 @@
"""Tests for FirebenderIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestFirebenderIntegration(MarkdownIntegrationTests):
KEY = "firebender"
FOLDER = ".firebender/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".firebender/commands"
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
# so this integration uses the ``.mdc`` extension instead of the ``.md``
# default the base mixin assumes. Override the two extension-specific tests.
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == ".mdc"
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".mdc")
def _expected_files(self, script_variant: str) -> list[str]:
# Firebender emits ``.mdc`` command files, so remap the base mixin's
# ``.md`` expectations for files under this integration's command dir.
cmd_dir = get_integration(self.KEY).registrar_config["dir"]
prefix = cmd_dir + "/"
return sorted(
f[:-3] + ".mdc" if f.startswith(prefix) and f.endswith(".md") else f
for f in super()._expected_files(script_variant)
)
@@ -0,0 +1,477 @@
"""Tests for ForgeIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from specify_cli.integrations.forge import format_forge_command_name
class TestForgeCommandNameFormatter:
"""Test the centralized Forge command name formatter."""
def test_simple_name_without_prefix(self):
"""Test formatting a simple name without 'speckit.' prefix."""
assert format_forge_command_name("plan") == "speckit-plan"
assert format_forge_command_name("tasks") == "speckit-tasks"
assert format_forge_command_name("specify") == "speckit-specify"
def test_name_with_speckit_prefix(self):
"""Test formatting a name that already has 'speckit.' prefix."""
assert format_forge_command_name("speckit.plan") == "speckit-plan"
assert format_forge_command_name("speckit.tasks") == "speckit-tasks"
def test_extension_command_name(self):
"""Test formatting extension command names with dots."""
assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example"
assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example"
def test_complex_nested_name(self):
"""Test formatting deeply nested command names."""
assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status"
assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz"
def test_name_with_hyphens_preserved(self):
"""Test that existing hyphens are preserved."""
assert format_forge_command_name("my-extension") == "speckit-my-extension"
assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd"
def test_alias_formatting(self):
"""Test formatting alias names."""
assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short"
def test_idempotent_already_hyphenated(self):
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
assert format_forge_command_name("speckit-plan") == "speckit-plan"
assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example"
assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status"
class TestForgeIntegration:
def test_forge_key_and_config(self):
forge = get_integration("forge")
assert forge is not None
assert forge.key == "forge"
assert forge.config["folder"] == ".forge/"
assert forge.config["commands_subdir"] == "commands"
assert forge.config["requires_cli"] is True
assert forge.registrar_config["args"] == "{{parameters}}"
assert forge.registrar_config["extension"] == ".md"
def test_command_filename_md(self):
forge = get_integration("forge")
assert forge.command_filename("plan") == "speckit.plan.md"
def test_setup_creates_md_files(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
created = forge.setup(tmp_path, m)
assert len(created) > 0
# Separate command files from scripts
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
assert len(command_files) > 0
for f in command_files:
assert f.name.endswith(".md")
def test_setup_does_not_write_context_section(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
def test_all_created_files_tracked_in_manifest(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
created = forge.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"Created file {rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
created = forge.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = forge.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
created = forge.install(tmp_path, m)
m.save()
# Modify a command file (not a script)
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
modified_file = command_files[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = forge.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
def test_directory_structure(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
assert commands_dir.is_dir()
# Derive expected command names from the Forge command templates so the test
# stays in sync if templates are added/removed.
templates = forge.list_command_templates()
expected_commands = {t.stem for t in templates}
assert len(expected_commands) > 0, "No command templates found"
# Check generated files match templates
command_files = sorted(commands_dir.glob("speckit.*.md"))
assert len(command_files) == len(expected_commands)
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".md") for f in command_files}
assert actual_commands == expected_commands
def test_templates_are_processed(self, tmp_path):
import re
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
# Check standard replacements
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__"
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
# Frontmatter sections should be stripped
assert "\nscripts:\n" not in content
# Check Forge-specific: command references use hyphen notation, not dot notation
assert not re.search(r"/speckit\.[a-z]", content), (
f"{cmd_file.name} contains dot-notation command reference (/speckit.<cmd>); "
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
)
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_forge_specific_transformations(self, tmp_path):
"""Test Forge-specific processing: name injection and handoffs stripping."""
from specify_cli.integrations.forge import ForgeIntegration
from specify_cli.agents import CommandRegistrar
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
registrar = CommandRegistrar()
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
frontmatter, _ = registrar.parse_frontmatter(content)
# Check that name field is injected in frontmatter
assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter"
# Check that handoffs frontmatter key is stripped
assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter"
def test_uses_parameters_placeholder(self, tmp_path):
"""Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files."""
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
# The registrar_config should specify {{parameters}}
assert forge.registrar_config["args"] == "{{parameters}}"
# Generate files and verify $ARGUMENTS is replaced with {{parameters}}
from specify_cli.integrations.manifest import IntegrationManifest
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
# Check all generated command files
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
# $ARGUMENTS should be replaced with {{parameters}}
assert "$ARGUMENTS" not in content, (
f"{cmd_file.name} still contains $ARGUMENTS - it should be replaced with {{{{parameters}}}}"
)
# At least some files should have {{parameters}} (those with user input sections)
# We'll check the checklist file specifically as it has a User Input section
# Verify checklist specifically has {{parameters}} in the User Input section
checklist = commands_dir / "speckit.checklist.md"
if checklist.exists():
content = checklist.read_text(encoding="utf-8")
assert "{{parameters}}" in content, (
"checklist should contain {{parameters}} in User Input section"
)
def test_command_refs_use_hyphen_notation(self, tmp_path):
"""Verify all generated Forge command files use /speckit-foo, not /speckit.foo."""
import re
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
files_with_refs = []
files_with_dot_refs = []
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
if re.search(r"/speckit-[a-z]", content):
files_with_refs.append(cmd_file.name)
if re.search(r"/speckit\.[a-z]", content):
files_with_dot_refs.append(cmd_file.name)
assert files_with_dot_refs == [], (
f"Files contain dot-notation command references: {files_with_dot_refs}. "
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility."
)
assert len(files_with_refs) > 0, (
"Expected at least one generated Forge command to contain /speckit-<cmd> reference, "
"but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved."
)
def test_name_field_uses_hyphenated_format(self, tmp_path):
"""Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan)."""
from specify_cli.integrations.forge import ForgeIntegration
from specify_cli.agents import CommandRegistrar
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
commands_dir = tmp_path / ".forge" / "commands"
# Check that name fields use hyphenated format
registrar = CommandRegistrar()
for cmd_file in commands_dir.glob("speckit.*.md"):
content = cmd_file.read_text(encoding="utf-8")
# Extract the name field from frontmatter using the parser
frontmatter, _ = registrar.parse_frontmatter(content)
assert "name" in frontmatter, (
f"{cmd_file.name} missing injected 'name' field in frontmatter"
)
name_value = frontmatter["name"]
# Name should use hyphens, not dots
assert "." not in name_value, (
f"{cmd_file.name} has name field with dots: {name_value} "
f"(should use hyphens for Forge/ZSH compatibility)"
)
assert name_value.startswith("speckit-"), (
f"{cmd_file.name} name field should start with 'speckit-': {name_value}"
)
class TestForgeCommandRegistrar:
"""Test CommandRegistrar's Forge-specific name formatting."""
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
# Create a test command with dot notation name
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Test extension command\n"
"---\n\n"
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Forge
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.my-extension.example",
"file": "commands/example.md"
}
]
registered = registrar.register_commands(
"forge",
commands,
"test-extension",
ext_dir,
tmp_path
)
# Verify registration succeeded
assert "speckit.my-extension.example" in registered
# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
assert forge_cmd.exists()
content = forge_cmd.read_text(encoding="utf-8")
# Parse frontmatter to validate name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
assert "name" in frontmatter, "name field should be injected in frontmatter"
# Name field should use hyphens, not dots
assert frontmatter["name"] == "speckit-my-extension-example"
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Test command with alias\n"
"---\n\n"
"Test content\n",
encoding="utf-8"
)
# Register with Forge including an alias
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.my-extension.example",
"file": "commands/example.md",
"aliases": ["speckit.my-extension.ex"]
}
]
registrar.register_commands(
"forge",
commands,
"test-extension",
ext_dir,
tmp_path
)
# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
assert alias_file.exists()
content = alias_file.read_text(encoding="utf-8")
# Parse frontmatter to validate alias name field precisely
frontmatter, _ = registrar.parse_frontmatter(content)
assert "name" in frontmatter, "name field should be injected in alias frontmatter"
# Alias name field should also use hyphens
assert frontmatter["name"] == "speckit-my-extension-ex"
def test_registrar_does_not_affect_other_agents(self, tmp_path):
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
from specify_cli.agents import CommandRegistrar
# Create a mock extension command file
ext_dir = tmp_path / "extension"
ext_dir.mkdir()
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir()
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Test command\n"
"---\n\n"
"Test content with $ARGUMENTS\n",
encoding="utf-8"
)
# Register with Kilo Code (standard markdown agent without inject_name)
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.my-extension.example",
"file": "commands/example.md"
}
]
registrar.register_commands(
"kilocode",
commands,
"test-extension",
ext_dir,
tmp_path
)
# Kilo Code uses standard markdown format without name injection.
# The format_name callback should not be invoked for non-Forge agents.
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
assert kilocode_cmd.exists()
content = kilocode_cmd.read_text(encoding="utf-8")
# Kilo Code should NOT have a name field injected
assert "name:" not in content, (
"Kilo Code should not inject name field - format_name callback should be Forge-only"
)
def test_git_extension_command_uses_hyphen_notation(self, tmp_path):
"""Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge."""
from pathlib import Path
from specify_cli.agents import CommandRegistrar
# Locate the real git extension command source file
repo_root = Path(__file__).resolve().parent.parent.parent
ext_dir = repo_root / "extensions" / "git"
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
assert cmd_source.exists(), (
f"Git extension command source not found at {cmd_source}. "
"Ensure extensions/git/commands/speckit.git.feature.md exists."
)
registrar = CommandRegistrar()
commands = [
{
"name": "speckit.git.feature",
"file": "commands/speckit.git.feature.md",
}
]
registered = registrar.register_commands(
"forge",
commands,
"git",
ext_dir,
tmp_path,
)
assert "speckit.git.feature" in registered
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-git-feature.md"
assert forge_cmd.exists(), "Expected Forge command file was not created"
content = forge_cmd.read_text(encoding="utf-8")
assert "/speckit-specify" in content, (
"Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, "
"but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly."
)
assert "/speckit.specify" not in content, (
"Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. "
"Forge requires hyphen notation for ZSH compatibility."
)
@@ -0,0 +1,10 @@
"""Tests for GeminiIntegration."""
from .test_integration_base_toml import TomlIntegrationTests
class TestGeminiIntegration(TomlIntegrationTests):
KEY = "gemini"
FOLDER = ".gemini/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".gemini/commands"
@@ -0,0 +1,362 @@
"""Tests for GenericIntegration."""
import os
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.base import MarkdownIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class TestGenericIntegration:
"""Tests for GenericIntegration — requires --commands-dir option."""
# -- Registration -----------------------------------------------------
def test_registered(self):
from specify_cli.integrations import INTEGRATION_REGISTRY
assert "generic" in INTEGRATION_REGISTRY
def test_is_markdown_integration(self):
assert isinstance(get_integration("generic"), MarkdownIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder_is_none(self):
i = get_integration("generic")
assert i.config["folder"] is None
def test_config_requires_cli_false(self):
i = get_integration("generic")
assert i.config["requires_cli"] is False
# -- Options ----------------------------------------------------------
def test_options_include_commands_dir(self):
i = get_integration("generic")
opts = i.options()
assert len(opts) == 1
assert opts[0].name == "--commands-dir"
assert opts[0].required is True
assert opts[0].is_flag is False
# -- Setup / teardown -------------------------------------------------
def test_setup_requires_commands_dir(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={})
def test_setup_requires_nonempty_commands_dir(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
with pytest.raises(ValueError, match="--commands-dir is required"):
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".myagent/commands"},
)
expected_dir = tmp_path / ".myagent" / "commands"
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_setup_creates_md_files(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
assert f.name.startswith("speckit.")
assert f.name.endswith(".md")
def test_templates_are_processed(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.setup(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.install(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
created = i.install(
tmp_path, m,
parsed_options={"commands_dir": ".custom/cmds"},
)
m.save()
modified = created[0]
modified.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified.exists()
assert modified in skipped
def test_different_commands_dirs(self, tmp_path):
"""Generic should work with various user-specified paths."""
for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]:
project = tmp_path / path.replace("/", "-")
project.mkdir()
i = get_integration("generic")
m = IntegrationManifest("generic", project)
created = i.setup(
project, m,
parsed_options={"commands_dir": path},
)
expected = project / path
assert expected.is_dir(), f"Dir {expected} not created for {path}"
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
# -- Context section ---------------------------------------------------
def test_setup_does_not_write_context_section(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
"""The generated plan command should keep quickstart.md out of implementation scope."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert "Create quickstart validation guide" in content
assert "runnable validation scenarios" in content
assert "Do not include full implementation code" in content
assert "implementation details belong in `tasks.md` and the implementation phase" in content
def test_implement_loads_constitution_context(self, tmp_path):
"""The generated implement command should load constitution governance context."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
implement_file = tmp_path / ".custom" / "cmds" / "speckit.implement.md"
assert implement_file.exists()
content = implement_file.read_text(encoding="utf-8")
assert ".specify/memory/constitution.md" in content
@pytest.mark.parametrize(
"command_stem",
[
"analyze",
"clarify",
"converge",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
],
)
def test_command_loads_constitution_context(self, tmp_path, command_stem):
"""Every command except constitution must reference constitution.md."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
cmd_file = tmp_path / ".custom" / "cmds" / f"speckit.{command_stem}.md"
assert cmd_file.exists(), f"Command file missing: {cmd_file.name}"
content = cmd_file.read_text(encoding="utf-8")
assert "constitution.md" in content, (
f"speckit.{command_stem}.md must reference constitution.md"
)
def test_constitution_command_exists(self, tmp_path):
"""The constitution command itself must exist but is not required to load itself."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
cmd_file = tmp_path / ".custom" / "cmds" / "speckit.constitution.md"
assert cmd_file.exists()
# -- CLI --------------------------------------------------------------
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
"""--integration generic without --integration-options should fail."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-generic"), "--integration", "generic",
])
# Generic requires --commands-dir via --integration-options
assert result.exit_code != 0
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-generic-sh"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = sorted([
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
".myagent/commands/speckit.constitution.md",
".myagent/commands/speckit.converge.md",
".myagent/commands/speckit.implement.md",
".myagent/commands/speckit.plan.md",
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
".specify/scripts/bash/check-prerequisites.sh",
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "inventory-generic-ps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands",
"--script", "ps",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = sorted([
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
".myagent/commands/speckit.constitution.md",
".myagent/commands/speckit.converge.md",
".myagent/commands/speckit.implement.md",
".myagent/commands/speckit.plan.md",
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
".specify/integrations/speckit.manifest.json",
".specify/memory/constitution.md",
".specify/scripts/powershell/check-prerequisites.ps1",
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
".specify/templates/spec-template.md",
".specify/templates/tasks-template.md",
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
])
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
@@ -0,0 +1,85 @@
"""Tests for GooseIntegration."""
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_yaml import YamlIntegrationTests
class TestGooseIntegration(YamlIntegrationTests):
KEY = "goose"
FOLDER = ".goose/"
COMMANDS_SUBDIR = "recipes"
REGISTRAR_DIR = ".goose/recipes"
def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path):
# “If a generated Goose recipe uses {{args}} in its prompt, it
# must declare a corresponding args parameter.”
integration = get_integration("goose")
assert integration is not None
manifest = IntegrationManifest("goose", tmp_path)
created = integration.setup(tmp_path, manifest, script_type="sh")
recipe_files = [path for path in created if path.suffix == ".yaml"]
assert recipe_files
for recipe_file in recipe_files:
data = yaml.safe_load(recipe_file.read_text(encoding="utf-8"))
if "{{args}}" not in data["prompt"]:
continue
assert any(
param.get("key") == "args"
for param in data.get("parameters", [])
), f"{recipe_file} uses {{{{args}}}} but does not declare args"
class TestGooseCommandPlaceholderResolution:
"""register_commands must resolve skill placeholders for the yaml branch.
The yaml (Goose recipe) branch previously skipped
resolve_skill_placeholders / _convert_argument_placeholder that the
markdown and toml branches apply, so extension/preset command bodies
kept literal {SCRIPT} / __AGENT__ / repo-relative paths.
"""
def test_register_commands_resolves_placeholders_in_recipe(self, tmp_path):
from specify_cli.agents import CommandRegistrar
ext_dir = tmp_path / "extension"
cmd_dir = ext_dir / "commands"
cmd_dir.mkdir(parents=True)
cmd_file = cmd_dir / "example.md"
cmd_file.write_text(
"---\n"
"description: Placeholder command\n"
"scripts:\n"
" sh: scripts/bash/do.sh\n"
" ps: scripts/powershell/do.ps1\n"
"---\n\n"
"Run {SCRIPT} for agent __AGENT__ with $ARGUMENTS.\n",
encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.example", "file": "commands/example.md"}]
registrar.register_commands("goose", commands, "test-ext", ext_dir, tmp_path)
recipe = tmp_path / ".goose" / "recipes" / "speckit.example.yaml"
assert recipe.exists(), "goose recipe should be generated"
# Parse the recipe and assert the prompt actually got the correct
# replacements — not merely that the literal tokens are absent (which
# a wrong-but-token-free output could also satisfy).
data = yaml.safe_load(recipe.read_text(encoding="utf-8"))
prompt = data["prompt"]
assert ".specify/scripts/" in prompt # {SCRIPT} -> resolved script path
assert "agent goose" in prompt # __AGENT__ -> agent name
assert "{{args}}" in prompt # $ARGUMENTS -> goose args token
# And the raw placeholders must not survive.
assert "{SCRIPT}" not in prompt
assert "__AGENT__" not in prompt
assert "$ARGUMENTS" not in prompt
@@ -0,0 +1,390 @@
"""Tests for HermesIntegration.
Hermes is special among SkillsIntegration subclasses: it writes skills
to ``~/.hermes/skills/`` (global) rather than the project-local
``.hermes/skills/`` directory. A project-local marker (empty directory)
is created so extension commands (e.g. git) can detect Hermes.
All tests that touch ``~/.hermes/`` use ``monkeypatch`` to isolate
``Path.home()`` to a temp directory so the test suite is hermetic and
non-destructive to a developer's real Hermes installation.
"""
from pathlib import Path
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
def _fake_home(tmp_path: Path) -> Path:
"""Create and return an isolated home directory under *tmp_path*."""
home = tmp_path / "home"
home.mkdir(exist_ok=True)
return home
class TestHermesIntegration(SkillsIntegrationTests):
KEY = "hermes"
FOLDER = ".hermes/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = "~/.hermes/skills"
# -- Hermes-specific setup: skills go to ~/.hermes/skills/ -------------
def test_setup_writes_to_global_skills_dir(self, tmp_path, monkeypatch):
"""Skills are written to ~/.hermes/skills/, not project-local."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
skill_files = [f for f in created if "scripts" not in f.parts]
assert len(skill_files) > 0, "No skill files were created"
for f in skill_files:
# Every skill file should be under ~/.hermes/skills/speckit-*/
expected_prefix = str(home / ".hermes" / "skills")
assert str(f).startswith(expected_prefix), (
f"{f} is not under ~/.hermes/skills/"
)
def test_local_marker_dir_created(self, tmp_path, monkeypatch):
"""Project-local .hermes/skills/ should exist but be empty."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
marker = tmp_path / ".hermes" / "skills"
assert marker.is_dir(), "Marker directory was not created"
# Should be empty (no SKILL.md files)
children = list(marker.iterdir())
assert children == [], f"Marker directory should be empty, got: {children}"
# -- Override shared tests that assume project-local skills ------------
def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch):
"""Override: Hermes writes to global, not project-local."""
self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch)
def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch):
"""The core plan skill must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
# Find the plan skill in global ~/.hermes/skills/
plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists(), f"Plan skill {plan_file} not created globally"
content = plan_file.read_text(encoding="utf-8")
assert "__CONTEXT_FILE__" not in content, (
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
)
def test_all_files_tracked_in_manifest(self, tmp_path, monkeypatch):
"""Override: Hermes does not track skills in the project manifest
since they live globally. Only project-local files (scripts,
templates, context) are tracked."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
# Global files (in ~/.hermes/) are not tracked in manifest
if str(f).startswith(str(home)):
continue
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path, monkeypatch):
"""Override: Hermes uninstall removes global skills + local marker."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
# All SKILL.md files should exist globally
for f in created:
if "SKILL.md" in str(f):
assert f.exists(), f"{f} does not exist"
# Global skills are removed on teardown without needing force
removed, skipped = i.teardown(tmp_path, m, force=False)
for f in created:
if "SKILL.md" in str(f):
assert not f.exists(), f"{f} should have been removed"
# Local marker should be gone
assert not (tmp_path / ".hermes" / "skills").exists()
def test_modified_file_survives_uninstall(self, tmp_path, monkeypatch):
"""Override: Hermes global skills are ALWAYS removed on uninstall
(they live outside the project root and aren't hash-tracked in the
manifest), so a modified global skill is still removed — matching
the standard behaviour where all integration files are cleaned up."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
# Pick a global skill file
skill_files = [f for f in created if "SKILL.md" in str(f)]
assert len(skill_files) > 0
modified_file = skill_files[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert not modified_file.exists(), (
"Modified global skill should be removed on teardown (standard behaviour)"
)
def test_modified_global_skill_removed_on_teardown(self, tmp_path, monkeypatch):
"""Override: Hermes global skills are removed on uninstall regardless
of the force flag, matching standard integration behaviour."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
# Pick a global skill file
skill_files = [f for f in created if "SKILL.md" in str(f)]
assert len(skill_files) > 0
modified_file = skill_files[0]
modified_file.write_text("user modified this", encoding="utf-8")
# Global skills are removed on teardown regardless of force flag
removed, skipped = i.teardown(tmp_path, m, force=False)
assert not modified_file.exists(), (
"Modified global skill should be removed on teardown (standard behaviour)"
)
def test_pre_existing_skills_not_removed(self, tmp_path, monkeypatch):
"""Pre-existing non-speckit global skills should survive Hermes uninstall."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
# Create a foreign skill in the global dir first
global_skills_dir = i._hermes_home_skills_dir()
foreign_dir = global_skills_dir / "other-tool"
foreign_dir.mkdir(parents=True, exist_ok=True)
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
# Run teardown to verify foreign skill survives uninstall
i.teardown(tmp_path, m)
assert (foreign_dir / "SKILL.md").exists(), (
"Foreign skill was removed by teardown"
)
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path, monkeypatch):
"""Override: Hermes skills live in global ~/.hermes/skills/."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
specify_skill = home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content, (
"speckit-specify should explain dotted hook command conversion"
)
assert content.count("replace dots") == content.count(
"- For each executable hook, output the following"
)
def test_complete_file_inventory_sh(self, tmp_path, monkeypatch):
"""Override: Hermes init produces no local SKILL.md files,
only the empty .hermes/skills/ marker."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = Path.cwd()
import os
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
# (extension-installed skills like agent-context-update may appear)
hermes_skill_files = [
f for f in actual
if f.startswith(".hermes/skills/speckit-")
and "agent-context" not in f
]
assert hermes_skill_files == [], (
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
)
# Ensure the marker exists (empty dir won't appear in file listing)
assert (project / ".hermes" / "skills").is_dir()
def test_complete_file_inventory_ps(self, tmp_path, monkeypatch):
"""Override: Same as sh variant but for PowerShell script type."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = Path.cwd()
import os
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY,
"--script", "ps", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()
)
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
# (extension-installed skills like agent-context-update may appear)
hermes_skill_files = [
f for f in actual
if f.startswith(".hermes/skills/speckit-")
and "agent-context" not in f
]
assert hermes_skill_files == [], (
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
)
assert (project / ".hermes" / "skills").is_dir()
def test_install_uninstall_cleanup(self, tmp_path, monkeypatch):
"""Verify global skills are cleaned and local marker is removed."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
# Verify global skills exist
global_skills = [
f for f in created
if "SKILL.md" in str(f)
and str(f).startswith(str(home / ".hermes"))
]
assert len(global_skills) > 0
for f in global_skills:
assert f.exists()
# Verify local marker exists
assert (tmp_path / ".hermes" / "skills").is_dir()
# Teardown — global skills removed without needing force=True
removed, skipped = i.teardown(tmp_path, m, force=False)
# Global skills removed
for f in global_skills:
assert not f.exists(), f"{f} should have been removed"
# Local marker removed
assert not (tmp_path / ".hermes" / "skills").exists(), (
"Local marker should be removed on teardown"
)
class TestHermesInitFlow:
"""--integration hermes creates expected files."""
def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch):
"""--integration hermes should create global skills and a local marker."""
home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home)
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, [
"init", str(target),
"--integration", "hermes",
"--ignore-agent-tools",
"--script", "sh",
])
assert result.exit_code == 0, f"init --integration hermes failed: {result.output}"
# Skills should be in global ~/.hermes/skills/
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
# Local marker should exist
assert (target / ".hermes" / "skills").is_dir()
# No core SKILL.md files in project-local dir
# (extension-installed skills like agent-context-update may appear)
local_skills = [
d for d in (target / ".hermes" / "skills").iterdir()
if "agent-context" not in d.name
]
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
class TestHermesBuildExecArgs:
"""CLI dispatch argv, including the operator extra-args env hook."""
def test_build_exec_args_default_shape(self):
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
]
def test_build_exec_args_honors_extra_args(self, monkeypatch):
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
devin; hermes previously skipped _apply_extra_args_env_var entirely).
"""
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
)
i = get_integration("hermes")
args = i.build_exec_args("/speckit-plan hi", output_json=True)
assert args == [
"hermes", "chat", "-Q", "--temperature", "0.2",
"--json", "-s", "speckit-plan", "-q", "hi",
]
# Injected before the canonical flags so it can't displace them.
assert args.index("--temperature") < args.index("--json")
assert args.index("--temperature") < args.index("-s")
def test_build_exec_args_honors_executable_override(self, monkeypatch):
monkeypatch.setenv(
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
)
i = get_integration("hermes")
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"
@@ -0,0 +1,10 @@
"""Tests for JunieIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestJunieIntegration(MarkdownIntegrationTests):
KEY = "junie"
FOLDER = ".junie/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".junie/commands"
@@ -0,0 +1,10 @@
"""Tests for KilocodeIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestKilocodeIntegration(MarkdownIntegrationTests):
KEY = "kilocode"
FOLDER = ".kilocode/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".kilocode/workflows"
+386
View File
@@ -0,0 +1,386 @@
"""Tests for KimiIntegration — skills integration with legacy migration."""
from pathlib import Path
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.kimi import (
_migrate_legacy_kimi_dotted_skills,
_migrate_legacy_kimi_skills_dir,
)
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
def _symlink_or_skip(
link: Path, target: Path, *, target_is_directory: bool = False
) -> None:
"""Create *link* pointing at *target*, skipping the test if unsupported.
Symlink creation fails on Windows without the create-symlink privilege and
in some restricted CI sandboxes. The symlink-safety tests below assert
behavior that only matters when symlinks exist, so skip (rather than error)
when the platform cannot create them.
"""
try:
link.symlink_to(target, target_is_directory=target_is_directory)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlinks unavailable: {exc}")
class TestKimiIntegration(SkillsIntegrationTests):
KEY = "kimi"
FOLDER = ".kimi-code/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".kimi-code/skills"
class TestKimiOptions:
"""Kimi declares --skills and --migrate-legacy options."""
def test_migrate_legacy_option(self):
i = get_integration("kimi")
opts = i.options()
migrate_opts = [o for o in opts if o.name == "--migrate-legacy"]
assert len(migrate_opts) == 1
assert migrate_opts[0].is_flag is True
assert migrate_opts[0].default is False
class TestKimiLegacyMigration:
"""Test Kimi dotted → hyphenated skill directory migration."""
def test_migrate_dotted_to_hyphenated(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Plan Skill\n")
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 1
assert removed == 0
assert not legacy.exists()
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
def test_skip_when_target_exists_different_content(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Old\n")
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text("# New (different)\n")
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 0
assert legacy.exists()
assert target.exists()
def test_remove_when_target_exists_same_content(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
content = "# Identical\n"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text(content)
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text(content)
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 1
assert not legacy.exists()
assert target.exists()
def test_preserve_legacy_with_extra_files(self, tmp_path):
skills_dir = tmp_path / ".kimi" / "skills"
content = "# Same\n"
legacy = skills_dir / "speckit.plan"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text(content)
(legacy / "extra.md").write_text("user file")
target = skills_dir / "speckit-plan"
target.mkdir(parents=True)
(target / "SKILL.md").write_text(content)
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
assert migrated == 0
assert removed == 0
assert legacy.exists()
def test_nonexistent_dir_returns_zeros(self, tmp_path):
migrated, removed = _migrate_legacy_kimi_dotted_skills(
tmp_path / ".kimi" / "skills"
)
assert migrated == 0
assert removed == 0
def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path):
"""--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills."""
i = get_integration("kimi")
old_skills_dir = tmp_path / ".kimi" / "skills"
new_skills_dir = tmp_path / ".kimi-code" / "skills"
legacy = old_skills_dir / "speckit-oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert not old_skills_dir.exists()
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
def test_setup_with_migrate_legacy_option(self, tmp_path):
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
i = get_integration("kimi")
old_skills_dir = tmp_path / ".kimi" / "skills"
new_skills_dir = tmp_path / ".kimi-code" / "skills"
legacy = old_skills_dir / "speckit.oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestKimiTeardownLegacyCleanup:
"""teardown() removes leftover legacy .kimi/skills/ directories."""
def test_teardown_removes_legacy_speckit_skills(self, tmp_path):
i = get_integration("kimi")
legacy_skill = tmp_path / ".kimi" / "skills" / "speckit-plan" / "SKILL.md"
legacy_skill.parent.mkdir(parents=True)
legacy_skill.write_text(
"---\n"
"name: \"speckit-plan\"\n"
"description: \"Plan workflow\"\n"
"metadata:\n"
" author: \"github-spec-kit\"\n"
" source: \"templates/commands/plan.md\"\n"
"---\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.teardown(tmp_path, m)
assert not legacy_skill.exists()
assert not (tmp_path / ".kimi" / "skills").exists()
def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path):
i = get_integration("kimi")
user_skill = tmp_path / ".kimi" / "skills" / "my-custom" / "SKILL.md"
user_skill.parent.mkdir(parents=True)
user_skill.write_text("# My custom skill\n")
m = IntegrationManifest("kimi", tmp_path)
i.teardown(tmp_path, m)
assert user_skill.exists()
class TestKimiCommandInvocation:
"""Kimi dispatch must use the native ``/skill:`` slash command."""
def test_build_command_invocation_uses_skill_prefix(self):
i = get_integration("kimi")
assert i.build_command_invocation("specify") == "/skill:speckit-specify"
assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan"
def test_build_command_invocation_dotted_extension(self):
i = get_integration("kimi")
assert (
i.build_command_invocation("speckit.git.commit")
== "/skill:speckit-git-commit"
)
def test_build_command_invocation_appends_args(self):
i = get_integration("kimi")
assert (
i.build_command_invocation("specify", "my feature")
== "/skill:speckit-specify my feature"
)
class TestKimiLegacySymlinkSafety:
"""Legacy migration/cleanup must not follow symlinks out of the project."""
def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path):
# An attacker-controlled directory outside the project root. Use a
# non-template skill name so a successful migration would be visible
# (the bundled templates never create "speckit-evillegacy").
outside = tmp_path / "outside"
(outside / "speckit-evillegacy").mkdir(parents=True)
(outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n")
project = tmp_path / "project"
(project / ".kimi").mkdir(parents=True)
# .kimi/skills is a symlink to the outside directory.
_symlink_or_skip(
project / ".kimi" / "skills", outside, target_is_directory=True
)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.setup(project, m, parsed_options={"migrate_legacy": True})
# Outside content must be untouched (not moved into .kimi-code).
assert (outside / "speckit-evillegacy" / "SKILL.md").exists()
assert not (
project / ".kimi-code" / "skills" / "speckit-evillegacy"
).exists()
def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path):
outside = tmp_path / "outside"
outside.mkdir()
keep = outside / "keep.txt"
keep.write_text("important\n")
project = tmp_path / "project"
(project / ".kimi").mkdir(parents=True)
_symlink_or_skip(
project / ".kimi" / "skills", outside, target_is_directory=True
)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.teardown(project, m)
# The symlink target and its contents must survive teardown.
assert keep.exists()
def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path):
# `.kimi` is itself a symlink to the project root, so `.kimi/skills`
# resolves to `./skills` — an unrelated in-tree directory. Even though
# the resolved path stays inside the project, migration must not
# operate on it because a path component is a symlink.
project = tmp_path / "project"
unrelated = project / "skills" / "speckit-evillegacy"
unrelated.mkdir(parents=True)
(unrelated / "SKILL.md").write_text("# unrelated\n")
# .kimi -> project root, so .kimi/skills == ./skills.
_symlink_or_skip(project / ".kimi", project, target_is_directory=True)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.setup(project, m, parsed_options={"migrate_legacy": True})
# The unrelated ./skills content must be untouched.
assert (unrelated / "SKILL.md").exists()
assert not (
project / ".kimi-code" / "skills" / "speckit-evillegacy"
).exists()
def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
# Looks Speckit-generated, so only the symlink check protects it.
unrelated = project / "skills" / "speckit-evillegacy"
unrelated.mkdir(parents=True)
(unrelated / "SKILL.md").write_text(
"---\nmetadata:\n author: github-spec-kit\n---\n# x\n"
)
_symlink_or_skip(project / ".kimi", project, target_is_directory=True)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.teardown(project, m)
# The unrelated ./skills content must survive teardown.
assert (unrelated / "SKILL.md").exists()
def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path):
# `.kimi-code` is a symlink to the project root, so the skills
# destination `.kimi-code/skills` resolves to `./skills` — an
# unintended in-tree location. base setup() only rejects a
# destination that escapes the project root, so without the
# pre-check it would write SKILL.md files into `./skills`. setup()
# must refuse before any write occurs.
project = tmp_path / "project"
project.mkdir()
_symlink_or_skip(project / ".kimi-code", project, target_is_directory=True)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
with pytest.raises(ValueError, match="symlinked"):
i.setup(project, m)
# Nothing was written into the unintended `./skills` location.
assert not (project / "skills").exists()
def test_migrate_skips_symlinked_target_dir(self, tmp_path):
# The destination `.kimi-code/skills/speckit-foo` already exists but is
# a symlink to a directory outside the project. Migration compares
# SKILL.md bytes to decide whether to drop the legacy copy; it must not
# follow the symlinked target dir to read SKILL.md from outside.
outside = tmp_path / "outside"
outside.mkdir()
(outside / "SKILL.md").write_text("# shared\n")
project = tmp_path / "project"
legacy = project / ".kimi" / "skills" / "speckit-foo"
legacy.mkdir(parents=True)
# Identical bytes: without the symlink guard the legacy dir would be
# removed after following the link out of the project.
(legacy / "SKILL.md").write_text("# shared\n")
target = project / ".kimi-code" / "skills" / "speckit-foo"
target.parent.mkdir(parents=True)
_symlink_or_skip(target, outside, target_is_directory=True)
_migrate_legacy_kimi_skills_dir(
project / ".kimi" / "skills", project / ".kimi-code" / "skills"
)
# Legacy copy is preserved (migration refused to follow the symlink),
# and the outside target is untouched.
assert (legacy / "SKILL.md").exists()
assert (outside / "SKILL.md").exists()
class TestKimiNextSteps:
"""CLI output tests for kimi next-steps display."""
def test_next_steps_show_skill_invocation(self, tmp_path):
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
import os
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "kimi-next-steps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "kimi",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "/skill:speckit-constitution" in result.output
assert "/speckit.constitution" not in result.output
assert "Optional skills that you can use for your specs" in result.output
@@ -0,0 +1,148 @@
"""Tests for KiroCliIntegration."""
import os
import re
from specify_cli.integrations import get_integration
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
# Regex shapes that indicate a value is a placeholder token, not prose.
# Covers Bash ($VAR, ${VAR}, ${VAR:-default}), Mustache/Handlebars/Jinja
# ({{var}}, {{{var}}}), Liquid/Jinja control ({% ... %}), Python str.format /
# .NET ({var}, {0}), angle-bracket (<var>), and Windows-style (%VAR%).
# Anchored to the FULL STRING so legitimate prose mentioning a placeholder
# (e.g. "the {{magic}} of placeholders") is not flagged. The Liquid pattern
# is anchored to the START so multi-tag templates fire while mid-sentence
# {%-quotation does not.
_PLACEHOLDER_TOKEN_PATTERNS = (
re.compile(r"^\$\w+$"), # $ARGUMENTS, $args
re.compile(r"^\$\{\w+(?:[:\-+?][^}]*)?\}$"), # ${ARGS}, ${ARGS:-default}
re.compile(r"^\{\{\{?\s*\w+(\s*[|.][^}]*)?\s*\}?\}\}$"), # {{var}} {{{var}}} {{x|y}}
re.compile(r"^\{%"), # {% if x %}{{ x }}{% endif %}
re.compile(r"^<\w+>$"), # <args>
re.compile(r"^%\w+%$"), # %USERNAME%
re.compile(r"^\{(?:\d+|[a-zA-Z_]\w*)(?:[.\[][^}]*)?(?:![rsa])?(?::[^}]*)?\}$"), # {0}, {var}, {0:>5}
)
def _looks_like_placeholder_token(value: str) -> bool:
"""Return True if *value* matches a known placeholder-token shape."""
if not value:
return False
return any(p.search(value) for p in _PLACEHOLDER_TOKEN_PATTERNS)
class TestKiroCliIntegration(MarkdownIntegrationTests):
KEY = "kiro-cli"
FOLDER = ".kiro/"
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".kiro/prompts"
def test_registrar_config(self):
"""Override base assertion: kiro-cli uses a prose fallback for args
because Kiro CLI file-based prompts do not natively substitute
``$ARGUMENTS`` (see issue #1926 / kirodotdev/Kiro#4141). The
regression-guard load is carried by the two layer tests below
(exact-fallback + placeholder-shape rejection)."""
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["extension"] == ".md"
def test_registrar_config_args_is_exact_prose_fallback(self):
"""Layer 1 — pin the exact fallback so wording drift requires a
deliberate paired commit (production constant + test update)."""
i = get_integration(self.KEY)
assert i.registrar_config["args"] == _KIRO_ARG_FALLBACK, (
f"args drifted from the pinned fallback constant. "
f"Got: {i.registrar_config['args']!r}; expected: {_KIRO_ARG_FALLBACK!r}. "
f"If the wording change is intentional, update _KIRO_ARG_FALLBACK and "
f"this test together."
)
def test_registrar_config_args_does_not_look_like_a_placeholder_token(self):
"""Layer 2 — independent regression guard: even if someone bypasses
layer-1 by changing both constant and test, the value still must not
look like ANY placeholder token shape ($X, ${X}, {{X}}, <X>, %X%, {0},
{% %}). Catches the class of regression Copilot called out: a swap
from $ARGUMENTS to $INPUT or {{userMessage}} would fail this test
even if it accidentally passed layer 1."""
i = get_integration(self.KEY)
args = i.registrar_config["args"]
assert not _looks_like_placeholder_token(args), (
f"registrar_config['args'] = {args!r} matches a known placeholder-"
f"token shape — Kiro CLI does not substitute placeholders so this "
f"would reach the model verbatim and break the prompt (issue #1926). "
f"Use a prose fallback instead."
)
def test_rendered_prompts_do_not_contain_raw_arguments(self, tmp_path):
"""Rendered Kiro prompt files must NOT contain the raw ``$ARGUMENTS``
token — Kiro CLI does not substitute it, so the literal would reach
the model and break the prompt (issue #1926)."""
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
prompts_dir = tmp_path / self.REGISTRAR_DIR
rendered = list(prompts_dir.glob("*.md"))
assert rendered, "expected at least one rendered prompt file"
offenders = [
p.name for p in rendered if "$ARGUMENTS" in p.read_text(encoding="utf-8")
]
assert offenders == [], (
f"these rendered prompts still contain the raw $ARGUMENTS token: {offenders}"
)
def test_rendered_prompts_contain_kiro_arg_placeholder(self, tmp_path):
"""The chosen kiro-cli args fallback string must end up in at least
one rendered prompt (proves substitution actually fired, not just
that $ARGUMENTS was removed). Imports the fallback constant directly
instead of reading the field back so the test stays independent of
the integration's own config — even if the registrar_config['args']
regresses, this test still verifies the FALLBACK STRING is in the
rendered output."""
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
expected = _KIRO_ARG_FALLBACK
prompts_dir = tmp_path / self.REGISTRAR_DIR
contents = "\n".join(
p.read_text(encoding="utf-8") for p in prompts_dir.glob("*.md")
)
assert expected in contents, (
f"none of the rendered prompts contain the configured args fallback "
f"({expected!r})"
)
class TestKiroIntegration:
"""--integration kiro-cli creates expected files."""
def test_integration_kiro_cli_creates_files(self, tmp_path):
"""--integration kiro-cli should create files in .kiro/prompts."""
from typer.testing import CliRunner
from specify_cli import app
target = tmp_path / "kiro-proj"
target.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(target)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "kiro-cli",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
@@ -0,0 +1,10 @@
"""Tests for LingmaIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestLingmaIntegration(SkillsIntegrationTests):
KEY = "lingma"
FOLDER = ".lingma/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".lingma/skills"
@@ -0,0 +1,30 @@
"""Tests for OmpIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestOmpIntegration(MarkdownIntegrationTests):
KEY = "omp"
FOLDER = ".omp/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".omp/commands"
def test_build_exec_args_uses_omp_json_mode(self):
i = get_integration(self.KEY)
args = i.build_exec_args(
"/speckit.specify Build auth",
model="gpt-5",
)
assert args == [
"omp",
"--print",
"--model",
"gpt-5",
"--mode",
"json",
"/speckit.specify Build auth",
]
@@ -0,0 +1,199 @@
"""Tests for OpencodeIntegration."""
import warnings
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestOpencodeIntegration(MarkdownIntegrationTests):
KEY = "opencode"
FOLDER = ".opencode/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".opencode/commands"
def test_build_exec_args_uses_run_command_dispatch(self):
integration = get_integration(self.KEY)
args = integration.build_exec_args(
"/speckit.specify build a login page",
output_json=False,
)
assert args == [
"opencode",
"run",
"--command",
"speckit.specify",
"build a login page",
]
assert "-p" not in args
assert "--output-format" not in args
def test_build_exec_args_maps_model_and_json_flags(self):
integration = get_integration(self.KEY)
args = integration.build_exec_args(
"/speckit.plan add OAuth",
model="anthropic/claude-sonnet-4",
output_json=True,
)
assert args == [
"opencode",
"run",
"--command",
"speckit.plan",
"-m",
"anthropic/claude-sonnet-4",
"--format",
"json",
"add OAuth",
]
def test_build_exec_args_keeps_plain_prompt_dispatch(self):
integration = get_integration(self.KEY)
args = integration.build_exec_args("explain this repository", output_json=False)
assert args == ["opencode", "run", "explain this repository"]
def test_registrar_config_has_legacy_dir(self):
integration = get_integration(self.KEY)
assert integration.registrar_config["legacy_dir"] == ".opencode/command"
def test_legacy_dir_extension_registration(self, tmp_path):
"""Extensions register in legacy .opencode/command/ with a warning."""
# Seed a legacy project with only .opencode/command/
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
(legacy_dir / "speckit.specify.md").write_text("# existing", encoding="utf-8")
# Create a source command file for the registrar
src_dir = tmp_path / "_ext_src"
src_dir.mkdir()
(src_dir / "myext.md").write_text(
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.myext", "file": "myext.md"}]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
results = registrar.register_commands_for_all_agents(
commands, "test-ext", src_dir, tmp_path,
)
# Should have registered in the legacy directory
assert "opencode" in results
assert (legacy_dir / "speckit.myext.md").exists()
# Canonical directory should NOT have been created
assert not (tmp_path / ".opencode" / "commands").exists()
# Should have emitted a deprecation warning
opencode_warnings = [
w for w in caught
if "legacy" in str(w.message) and "opencode" in str(w.message)
]
assert len(opencode_warnings) == 1, (
f"Expected exactly 1 legacy-dir warning, got {len(opencode_warnings)}"
)
assert "specify integration upgrade" in str(opencode_warnings[0].message)
def test_legacy_dir_unregister(self, tmp_path):
"""Unregister finds commands in legacy .opencode/command/ dir."""
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
cmd_file = legacy_dir / "speckit.myext.md"
cmd_file.write_text("# ext command", encoding="utf-8")
registrar = CommandRegistrar()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
registrar.unregister_commands(
{"opencode": ["speckit.myext"]}, tmp_path,
)
assert not cmd_file.exists()
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
"""Unregister removes files from legacy dir even when canonical exists."""
# Set up both canonical and legacy dirs
canonical_dir = tmp_path / ".opencode" / "commands"
canonical_dir.mkdir(parents=True)
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
# Place a command file in the legacy dir (orphaned after upgrade)
legacy_cmd = legacy_dir / "speckit.myext.md"
legacy_cmd.write_text("# orphaned ext command", encoding="utf-8")
# Place the same command in the canonical dir (current)
canonical_cmd = canonical_dir / "speckit.myext.md"
canonical_cmd.write_text("# ext command", encoding="utf-8")
registrar = CommandRegistrar()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
registrar.unregister_commands(
{"opencode": ["speckit.myext"]}, tmp_path,
)
# Both files should be removed
assert not canonical_cmd.exists(), (
"Command file in canonical dir should be removed"
)
assert not legacy_cmd.exists(), (
"Orphaned command file in legacy dir should also be removed"
)
def test_canonical_dir_preferred_over_legacy(self, tmp_path):
"""When both dirs exist, canonical .opencode/commands/ is used."""
legacy_dir = tmp_path / ".opencode" / "command"
legacy_dir.mkdir(parents=True)
canonical_dir = tmp_path / ".opencode" / "commands"
canonical_dir.mkdir(parents=True)
(canonical_dir / "speckit.specify.md").write_text("# cmd", encoding="utf-8")
# Create a source command file for the registrar
src_dir = tmp_path / "_ext_src"
src_dir.mkdir()
(src_dir / "myext.md").write_text(
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.myext", "file": "myext.md"}]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
results = registrar.register_commands_for_all_agents(
commands, "test-ext", src_dir, tmp_path,
)
# Should register in canonical dir, not legacy
assert "opencode" in results
assert (canonical_dir / "speckit.myext.md").exists()
assert not (legacy_dir / "speckit.myext.md").exists()
# No legacy warning when canonical dir exists
opencode_warnings = [
w for w in caught
if "legacy" in str(w.message) and "opencode" in str(w.message)
]
assert len(opencode_warnings) == 0
def test_setup_writes_to_canonical_dir(self, tmp_path):
"""New installs always write to .opencode/commands/ (plural)."""
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest)
canonical = tmp_path / ".opencode" / "commands"
legacy = tmp_path / ".opencode" / "command"
assert canonical.is_dir()
assert not legacy.exists()
assert any(canonical.glob("speckit.*.md"))
+10
View File
@@ -0,0 +1,10 @@
"""Tests for PiIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestPiIntegration(MarkdownIntegrationTests):
KEY = "pi"
FOLDER = ".pi/"
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".pi/prompts"
@@ -0,0 +1,10 @@
"""Tests for QodercliIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestQodercliIntegration(MarkdownIntegrationTests):
KEY = "qodercli"
FOLDER = ".qoder/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".qoder/commands"
@@ -0,0 +1,10 @@
"""Tests for QwenIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestQwenIntegration(MarkdownIntegrationTests):
KEY = "qwen"
FOLDER = ".qwen/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".qwen/commands"
@@ -0,0 +1,295 @@
"""Tests for RovodevIntegration."""
from __future__ import annotations
import os
import pytest
import yaml
from click.testing import Result
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
def _run_init(project, *flags: str) -> Result:
"""Run ``specify init --here`` in *project* with the given extra flags.
Centralises the cwd-management boilerplate so individual tests just
declare the flags they care about.
"""
old_cwd = os.getcwd()
try:
os.chdir(project)
return CliRunner().invoke(
app,
["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
@pytest.fixture
def rovodev_init_project(tmp_path):
"""Run ``specify init --integration rovodev`` once and return the project root.
Shared across the slow init-inventory tests so we pay the full-CLI cost
only once instead of three times.
"""
project = tmp_path / "rovodev-init"
project.mkdir()
result = _run_init(project, "--integration", "rovodev")
assert result.exit_code == 0, result.output
return project
class TestRovodevIntegration:
"""Rovodev-specific tests (not inherited from SkillsIntegrationTests because
rovodev's setup() emits prompt wrappers + prompts.yml in addition to skills,
which violates the base mixin's pure-skills assumptions)."""
KEY = "rovodev"
# -- ACLI dispatch -----------------------------------------------------
def test_build_exec_args(self):
impl = get_integration(self.KEY)
args = impl.build_exec_args("/speckit.plan add OAuth")
assert args[0:3] == ["acli", "rovodev", "run"]
assert args[3] == "/speckit.plan add OAuth"
assert "--output-schema" in args
def test_build_exec_args_without_json(self):
impl = get_integration(self.KEY)
args = impl.build_exec_args("/speckit.plan add OAuth", output_json=False)
assert args == ["acli", "rovodev", "run", "/speckit.plan add OAuth"]
def test_build_exec_args_executable_env_override(self, monkeypatch):
"""SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE overrides the binary path.
Lets operators pin a specific ``acli`` build or relocate the binary
without modifying the integration. Mirrors codex/devin/claude/etc.
"""
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", "/opt/atl/bin/acli")
impl = get_integration(self.KEY)
args = impl.build_exec_args("hello", output_json=False)
assert args == ["/opt/atl/bin/acli", "rovodev", "run", "hello"]
def test_build_exec_args_executable_env_blank_falls_back(self, monkeypatch):
"""Whitespace/empty env override is treated as unset → default ``acli``."""
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", " ")
impl = get_integration(self.KEY)
args = impl.build_exec_args("hello", output_json=False)
assert args[0] == "acli"
def test_build_exec_args_extra_args_env_injection(self, monkeypatch):
"""SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS injects extra CLI flags.
Useful for CI or non-interactive contexts that need to pass flags
the integration doesn't expose. Mirrors the contract on every other
CLI integration (claude, codex, devin, …).
"""
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS", "--quiet --no-color")
impl = get_integration(self.KEY)
args = impl.build_exec_args("hello", output_json=False)
assert args == [
"acli", "rovodev", "run", "hello", "--quiet", "--no-color",
]
# -- Setup-level: prompt wrappers + prompts.yml ------------------------
def test_setup_creates_prompts_and_manifest(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
created = impl.setup(tmp_path, manifest)
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
assert prompts_manifest in created
assert prompts_manifest.exists()
prompts_dir = tmp_path / ".rovodev" / "prompts"
skills_dir = tmp_path / ".rovodev" / "skills"
assert prompts_dir.is_dir()
assert skills_dir.is_dir()
templates = impl.list_command_templates()
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
skill_dirs = sorted(d for d in skills_dir.iterdir() if d.is_dir() and d.name.startswith("speckit-"))
assert len(prompt_files) == len(templates)
assert len(skill_dirs) == len(templates)
for skill_dir in skill_dirs:
assert (skill_dir / "SKILL.md").exists()
def test_prompts_manifest_entries_well_formed(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
impl.setup(tmp_path, manifest)
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
assert list(data) == ["prompts"]
entries = data["prompts"]
assert entries
for entry in entries:
assert entry["name"].startswith("speckit-")
assert entry["description"]
content_file = tmp_path / ".rovodev" / entry["content_file"]
assert content_file.exists(), f"Missing prompt file {content_file}"
def test_prompt_wrapper_format(self, tmp_path):
"""Every prompt wrapper delegates to its paired skill via 'use skill ...'."""
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
impl.setup(tmp_path, manifest)
prompts_dir = tmp_path / ".rovodev" / "prompts"
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
assert prompt_files
for prompt_file in prompt_files:
skill_name = prompt_file.name.removesuffix(".prompt.md")
content = prompt_file.read_text(encoding="utf-8")
assert content == f"use skill {skill_name} $ARGUMENTS\n", (
f"{prompt_file} has unexpected wrapper format"
)
def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
prompts_manifest.parent.mkdir(parents=True, exist_ok=True)
user_entry = {
"name": "my-custom-prompt",
"description": "User-added prompt",
"content_file": "prompts/my-custom-prompt.md",
}
prompts_manifest.write_text(
yaml.safe_dump({"prompts": [user_entry]}, sort_keys=False),
encoding="utf-8",
)
impl.setup(tmp_path, manifest)
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
names = {entry.get("name") for entry in data.get("prompts", [])}
assert "my-custom-prompt" in names
assert "speckit-plan" in names
def test_modified_prompts_yml_survives_uninstall(self, tmp_path):
impl = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
impl.install(tmp_path, manifest)
manifest.save()
modified = tmp_path / ".rovodev" / "prompts.yml"
modified.write_text("user modified this", encoding="utf-8")
_, skipped = impl.uninstall(tmp_path, manifest)
assert modified.exists()
assert modified in skipped
# -- Full-CLI init: skills + prompts integration with extensions -------
def test_init_inventory(self, rovodev_init_project):
"""Rovodev + extensions produce the expected skill / prompt set.
Contract:
- Rovodev.setup() emits one SKILL.md + one .prompt.md per core template.
- Extensions install additional SKILL.md directories with NO prompt wrapper.
"""
project = rovodev_init_project
impl = get_integration(self.KEY)
core_skill_names = {
f"speckit-{t.stem.replace('.', '-')}"
for t in impl.list_command_templates()
}
prompt_files = sorted((project / ".rovodev" / "prompts").glob("speckit-*.prompt.md"))
prompt_stems = {p.name.removesuffix(".prompt.md") for p in prompt_files}
skills_dir = project / ".rovodev" / "skills"
skill_names = {
d.name for d in skills_dir.iterdir()
if d.is_dir() and d.name.startswith("speckit-")
}
# Prompts: exactly the core template set.
assert prompt_stems == core_skill_names
# Skills: exactly the core template set (no extension auto-install).
assert skill_names == core_skill_names
# prompts.yml mirrors the prompt files exactly.
prompts_manifest = project / ".rovodev" / "prompts.yml"
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
assert {e["name"] for e in data["prompts"]} == core_skill_names
def test_init_skill_files_well_formed(self, rovodev_init_project):
"""Every speckit-* SKILL.md from full init has valid frontmatter +
processed body, including extension-installed skills."""
project = rovodev_init_project
skills_dir = project / ".rovodev" / "skills"
skill_dirs = sorted(
d for d in skills_dir.iterdir()
if d.is_dir() and d.name.startswith("speckit-")
)
assert skill_dirs
for skill_dir in skill_dirs:
skill_file = skill_dir / "SKILL.md"
assert skill_file.exists(), f"Missing {skill_file}"
content = skill_file.read_text(encoding="utf-8")
# Frontmatter delimited by leading '---\n' ... '\n---\n'
assert content.startswith("---\n"), f"{skill_file} missing frontmatter"
fm_end = content.find("\n---\n", 4)
assert fm_end != -1, f"{skill_file} has unterminated frontmatter"
fm = yaml.safe_load(content[4:fm_end])
body = content[fm_end + len("\n---\n"):]
assert fm.get("name") == skill_dir.name
assert fm.get("description")
assert body.strip(), f"{skill_file} has empty body"
for placeholder in ("{SCRIPT}", "__AGENT__", "__CONTEXT_FILE__", "__SPECKIT_COMMAND_"):
assert placeholder not in body, (
f"{skill_file} body contains unprocessed placeholder {placeholder!r}"
)
# Skills agents must use hyphen-style refs in body.
assert "/speckit." not in body, (
f"{skill_file} body contains dot-notation /speckit. reference"
)
# -- Full-CLI init: integration metadata -------------------------------
def test_init_writes_integration_manifest_and_options(self, rovodev_init_project):
"""Full init must produce an integration manifest and well-formed
init-options.json — used by extensions, presets, and uninstall."""
import json
project = rovodev_init_project
manifest_path = project / ".specify" / "integrations" / "rovodev.manifest.json"
speckit_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
assert manifest_path.exists(), "rovodev integration manifest missing"
assert speckit_manifest.exists(), "speckit shared manifest missing"
init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["integration"] == self.KEY
assert init_options["ai"] == self.KEY
# Rovodev is a SkillsIntegration, so ai_skills is auto-set.
assert init_options.get("ai_skills") is True
assert init_options.get("script") == "sh"
def test_integration_flag_creates_expected_files(self, tmp_path):
"""``--integration rovodev`` should create all expected rovodev files."""
project = tmp_path / "rovodev-int"
project.mkdir()
result = _run_init(project, "--integration", "rovodev")
assert result.exit_code == 0, result.output
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".rovodev" / "prompts.yml").exists()
assert (project / ".specify" / "integrations" / "rovodev.manifest.json").exists()
@@ -0,0 +1,238 @@
"""Tests for integration scaffolding commands."""
from pathlib import Path
import pytest
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integration_scaffold import scaffold_integration
from tests.conftest import strip_ansi
runner = CliRunner()
def _repo_root(tmp_path: Path) -> Path:
root = tmp_path / "spec-kit"
(root / "src" / "specify_cli" / "integrations").mkdir(parents=True)
(root / "tests" / "integrations").mkdir(parents=True)
(root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8")
(root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
(root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text(
"",
encoding="utf-8",
)
return root
def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "markdown",
], catch_exceptions=False)
output = strip_ansi(result.output)
integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
assert result.exit_code == 0
assert integration_file.exists()
assert test_file.exists()
assert "Created integration scaffold: my-agent" in output
assert "Register MyAgentIntegration" in output
content = integration_file.read_text(encoding="utf-8")
assert "class MyAgentIntegration(MarkdownIntegration):" in content
assert 'key = "my-agent"' in content
assert '"folder": ".my-agent/"' in content
assert '"extension": ".md"' in content
assert "multi_install_safe = False" in content
test_content = test_file.read_text(encoding="utf-8")
assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content
assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content
assert "assert integration.multi_install_safe is False" in test_content
@pytest.mark.parametrize(
("integration_type", "base_class", "commands_subdir", "args", "extension"),
[
("markdown", "MarkdownIntegration", "commands", "$ARGUMENTS", ".md"),
("toml", "TomlIntegration", "commands", "{{args}}", ".toml"),
("yaml", "YamlIntegration", "recipes", "{{args}}", ".yaml"),
("skills", "SkillsIntegration", "skills", "$ARGUMENTS", "/SKILL.md"),
],
)
def test_scaffold_type_templates(
tmp_path,
integration_type,
base_class,
commands_subdir,
args,
extension,
):
root = _repo_root(tmp_path)
result = scaffold_integration(root, f"{integration_type}-agent", integration_type)
content = result.integration_file.read_text(encoding="utf-8")
assert f"class {result.class_name}({base_class}):" in content
assert f'"commands_subdir": "{commands_subdir}"' in content
assert f'"args": "{args}"' in content
assert f'"extension": "{extension}"' in content
assert "multi_install_safe = False" in content
def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "xml",
])
output = strip_ansi(result.output)
assert result.exit_code == 2
assert "Invalid value for '--type'" in output
assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists()
def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
import specify_cli.integration_scaffold as scaffold_module
def boom(*args, **kwargs):
raise PermissionError("Permission denied: read-only checkout")
monkeypatch.setattr(scaffold_module, "scaffold_integration", boom)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "markdown",
], catch_exceptions=False)
output = strip_ansi(result.output)
assert result.exit_code == 1
assert "Error:" in output
assert "Permission denied" in output
def test_scaffold_refuses_invalid_key(tmp_path):
root = _repo_root(tmp_path)
with pytest.raises(ValueError, match="lowercase kebab-case"):
scaffold_integration(root, "Bad_Key", "markdown")
def test_scaffold_refuses_unknown_type(tmp_path):
root = _repo_root(tmp_path)
with pytest.raises(ValueError, match="Unsupported integration type 'xml'"):
scaffold_integration(root, "my-agent", " XML ")
def test_scaffold_refuses_overwrite(tmp_path):
root = _repo_root(tmp_path)
scaffold_integration(root, "my-agent", "markdown")
with pytest.raises(FileExistsError, match="Refusing to overwrite"):
scaffold_integration(root, "my-agent", "markdown")
def test_scaffold_rolls_back_partial_files_on_write_failure(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
integration_dir = root / "src" / "specify_cli" / "integrations" / "my_agent"
integration_file = integration_dir / "__init__.py"
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
original_write_text = Path.write_text
def fail_test_write(path, *args, **kwargs):
if path == test_file:
raise PermissionError("simulated test file write failure")
return original_write_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", fail_test_write)
with pytest.raises(PermissionError, match="simulated test file write failure"):
scaffold_integration(root, "my-agent", "markdown")
assert not integration_file.exists()
assert not integration_dir.exists()
assert not test_file.exists()
def test_scaffold_creates_only_leaf_integration_directory(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
original_mkdir = Path.mkdir
mkdir_calls = []
def record_mkdir(path, *args, **kwargs):
mkdir_calls.append((path, args, kwargs))
return original_mkdir(path, *args, **kwargs)
monkeypatch.setattr(Path, "mkdir", record_mkdir)
scaffold_integration(root, "my-agent", "markdown")
assert any(
path == root / "src" / "specify_cli" / "integrations" / "my_agent"
for path, _args, _kwargs in mkdir_calls
)
assert all(not kwargs.get("parents", False) for _path, _args, kwargs in mkdir_calls)
def test_scaffold_requires_repo_root(tmp_path):
with pytest.raises(ValueError, match="Spec Kit repository root"):
scaffold_integration(tmp_path, "my-agent", "markdown")
def test_scaffold_requires_integration_registry_file(tmp_path):
root = _repo_root(tmp_path)
(root / "src" / "specify_cli" / "integrations" / "__init__.py").unlink()
with pytest.raises(ValueError, match="Spec Kit repository root"):
scaffold_integration(root, "my-agent", "markdown")
def test_scaffold_refuses_symlinked_target_directory(tmp_path):
root = _repo_root(tmp_path)
# `outside` carries its own __init__.py so the repo-root heuristic still
# passes through the symlink, isolating the symlink guard under test.
outside = tmp_path / "outside"
outside.mkdir()
(outside / "__init__.py").write_text("", encoding="utf-8")
integrations = root / "src" / "specify_cli" / "integrations"
(integrations / "__init__.py").unlink()
integrations.rmdir()
try:
integrations.symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
with pytest.raises(ValueError, match="symlinked path"):
scaffold_integration(root, "my-agent", "markdown")
assert not (outside / "my_agent").exists()
def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch):
root = _repo_root(tmp_path)
monkeypatch.chdir(root)
result = runner.invoke(app, [
"integration", "scaffold", "my-agent",
"--type", "YAML",
], catch_exceptions=False)
assert result.exit_code == 0, strip_ansi(result.output)
content = (
root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
).read_text(encoding="utf-8")
assert "class MyAgentIntegration(YamlIntegration):" in content
@@ -0,0 +1,10 @@
"""Tests for ShaiIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestShaiIntegration(MarkdownIntegrationTests):
KEY = "shai"
FOLDER = ".shai/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".shai/commands"
@@ -0,0 +1,86 @@
"""Tests for integration state normalization helpers."""
import json
from specify_cli.integration_state import (
INTEGRATION_JSON,
default_integration_key,
integration_setting,
normalize_integration_state,
write_integration_json,
)
def test_normalize_integration_state_strips_default_key_without_duplicates():
state = normalize_integration_state(
{
"default_integration": " claude ",
"integration": " claude ",
"installed_integrations": ["claude"],
}
)
assert state["integration"] == "claude"
assert state["default_integration"] == "claude"
assert state["installed_integrations"] == ["claude"]
def test_normalize_integration_state_strips_legacy_key_fallback():
state = normalize_integration_state(
{
"integration": " codex ",
"installed_integrations": [],
}
)
assert state["integration"] == "codex"
assert state["default_integration"] == "codex"
assert state["installed_integrations"] == ["codex"]
def test_normalize_integration_state_preserves_newer_schema():
state = normalize_integration_state(
{
"integration_state_schema": 99,
"integration": "claude",
"installed_integrations": ["claude"],
"future_field": {"keep": True},
}
)
assert state["integration_state_schema"] == 99
assert state["future_field"] == {"keep": True}
def test_default_integration_key_strips_raw_state_values():
assert default_integration_key({"default_integration": " claude "}) == "claude"
assert default_integration_key({"integration": " codex "}) == "codex"
def test_integration_settings_strip_invoke_separator():
setting = integration_setting(
{
"integration_settings": {
"claude": {
"invoke_separator": " - ",
}
}
},
"claude",
)
assert setting["invoke_separator"] == "-"
def test_write_integration_json_strips_integration_key(tmp_path):
write_integration_json(
tmp_path,
version="1.2.3",
integration_key=" claude ",
installed_integrations=["claude"],
)
state = json.loads((tmp_path / INTEGRATION_JSON).read_text(encoding="utf-8"))
assert state["integration"] == "claude"
assert state["default_integration"] == "claude"
assert state["installed_integrations"] == ["claude"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
"""Tests for TabnineIntegration."""
from .test_integration_base_toml import TomlIntegrationTests
class TestTabnineIntegration(TomlIntegrationTests):
KEY = "tabnine"
FOLDER = ".tabnine/agent/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".tabnine/agent/commands"
@@ -0,0 +1,10 @@
"""Tests for TraeIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestTraeIntegration(SkillsIntegrationTests):
KEY = "trae"
FOLDER = ".trae/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".trae/skills"
@@ -0,0 +1,37 @@
"""Tests for VibeIntegration."""
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
class TestVibeIntegration(SkillsIntegrationTests):
KEY = "vibe"
FOLDER = ".vibe/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".vibe/skills"
class TestVibeUserInvocable:
def test_all_skills_have_user_invocable(self, tmp_path):
i = get_integration("vibe")
m = IntegrationManifest("vibe", tmp_path)
created = i.setup(tmp_path, m, script_type="sh")
skill_files = [f for f in created if f.name == "SKILL.md"]
assert skill_files
for f in skill_files:
content = f.read_text(encoding="utf-8")
assert content.startswith("---"), (
f"{f.parent.name}/SKILL.md is missing the opening frontmatter delimiter '---'"
)
parts = content.split("---", 2)
assert len(parts) >= 3, (
f"{f.parent.name}/SKILL.md has malformed frontmatter; expected a '--- ... ---' block"
)
parsed = yaml.safe_load(parts[1])
assert parsed.get("user-invocable") is True, (
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
)
@@ -0,0 +1,37 @@
"""Tests for ZcodeIntegration — skills-based integration (Z.AI)."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestZcodeIntegration(SkillsIntegrationTests):
KEY = "zcode"
FOLDER = ".zcode/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".zcode/skills"
class TestZcodeInvocation:
"""ZCode renders $speckit-* chat invocations (like Codex)."""
def test_next_steps_show_dollar_skill_invocation(self, tmp_path):
"""ZCode next-steps guidance should display $speckit-* usage."""
import os
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "zcode-next-steps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "zcode",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "$speckit-constitution" in result.output
assert "/speckit.constitution" not in result.output
+163
View File
@@ -0,0 +1,163 @@
"""Tests for ZedIntegration."""
import json
import pytest
from specify_cli.integrations import get_integration
from .test_integration_base_skills import SkillsIntegrationTests
class TestZedIntegration(SkillsIntegrationTests):
KEY = "zed"
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
def test_options_include_skills_flag(self):
"""Not applicable to Zed — Zed is always skills-based with no --skills flag."""
pytest.skip("Zed is always skills-based and does not expose a --skills option")
def test_options_do_not_include_skills_flag(self):
"""Zed is always skills-based; no --skills option is exposed."""
i = get_integration(self.KEY)
assert i is not None
opts = i.options()
skills_opts = [o for o in opts if o.name == "--skills"]
assert len(skills_opts) == 0, (
"Zed is always skills-based and should not expose a --skills option"
)
def test_requires_cli_is_false(self):
"""Zed is IDE-based; requires_cli must remain False."""
i = get_integration(self.KEY)
assert i is not None
assert i.config is not None
assert i.config["requires_cli"] is False
class TestZedHookInvocations:
"""Zed hook messages should reference slash-invokable skills."""
def test_hooks_render_skill_invocation(self, tmp_path):
"""Zed is always skills-based: renders /speckit-plan even with ai_skills=False."""
from specify_cli.extensions import HookExecutor
project = tmp_path / "zed-hooks"
project.mkdir()
init_options = project / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "zed", "ai_skills": False}))
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
def test_init_persists_ai_skills_for_zed(self, tmp_path, monkeypatch):
"""specify init --integration zed must persist ai_skills: true,
so HookExecutor renders slash-skill invocations without manual
init-options manipulation."""
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.extensions import HookExecutor
project = tmp_path / "zed-init-test"
project.mkdir()
monkeypatch.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"zed",
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init failed: {result.output}"
opts_path = project / ".specify" / "init-options.json"
assert opts_path.exists()
opts = json.loads(opts_path.read_text(encoding="utf-8"))
assert opts.get("ai") == "zed"
assert opts.get("ai_skills") is True, (
f"init must persist ai_skills=true for Zed, got: {opts.get('ai_skills')}"
)
hook_executor = HookExecutor(project)
message = hook_executor.format_hook_message(
"before_plan",
[
{
"extension": "test-ext",
"command": "speckit.plan",
"optional": False,
}
],
)
assert "Executing: `/speckit-plan`" in message, (
"Hook rendering must produce /speckit-plan for Zed without hint injection"
)
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
class TestSlashSkillsSets:
"""Parameterized coverage for ALWAYS_SLASH_AGENTS / CONDITIONAL_SLASH_AGENTS."""
@staticmethod
def _render_invocation(project_path, ai: str, ai_skills: bool) -> str:
"""Return the rendered invocation for ``speckit.plan`` via HookExecutor."""
from specify_cli.extensions import HookExecutor
init_options = project_path / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": ai, "ai_skills": ai_skills}))
hook_executor = HookExecutor(project_path)
result = hook_executor.execute_hook(
{"extension": "test-ext", "command": "speckit.plan", "optional": False}
)
return result.get("invocation", "")
@pytest.mark.parametrize(
("ai", "ai_skills", "expected"),
[
# ALWAYS_SLASH_AGENTS — unconditional on ai_skills
("devin", True, "/speckit-plan"),
("devin", False, "/speckit-plan"),
("trae", True, "/speckit-plan"),
("trae", False, "/speckit-plan"),
("zed", True, "/speckit-plan"),
("zed", False, "/speckit-plan"),
# CONDITIONAL_SLASH_AGENTS — only when ai_skills is enabled
("agy", True, "/speckit-plan"),
("agy", False, "/speckit.plan"),
("claude", True, "/speckit-plan"),
("claude", False, "/speckit.plan"),
("copilot", True, "/speckit-plan"),
("copilot", False, "/speckit.plan"),
("cursor-agent", True, "/speckit-plan"),
("cursor-agent", False, "/speckit.plan"),
],
)
def test_hook_invocation_format(self, tmp_path, ai, ai_skills, expected):
result = self._render_invocation(tmp_path, ai, ai_skills)
assert result == expected, (
f"{ai} (ai_skills={ai_skills}): expected {expected!r}, got {result!r}"
)
+520
View File
@@ -0,0 +1,520 @@
"""Tests for IntegrationManifest — record, hash, save, load, uninstall, modified detection."""
import hashlib
import json
import sys
import pytest
from specify_cli.integrations.manifest import IntegrationManifest, _sha256
class TestManifestRecordFile:
def test_record_file_writes_and_hashes(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
content = "hello world"
abs_path = m.record_file("a/b.txt", content)
assert abs_path == tmp_path / "a" / "b.txt"
assert abs_path.read_text(encoding="utf-8") == content
expected_hash = hashlib.sha256(content.encode()).hexdigest()
assert m.files["a/b.txt"] == expected_hash
def test_record_file_bytes(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
data = b"\x00\x01\x02"
abs_path = m.record_file("bin.dat", data)
assert abs_path.read_bytes() == data
assert m.files["bin.dat"] == hashlib.sha256(data).hexdigest()
def test_record_existing(self, tmp_path):
f = tmp_path / "existing.txt"
f.write_text("content", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("existing.txt")
assert m.files["existing.txt"] == _sha256(f)
class TestManifestRecordExistingErrors:
"""Error-case coverage for ``record_existing`` symlink + non-file guards.
Added in #2483 — Copilot review flagged these as un-tested regressions
after the ``is_symlink``/``is_file`` guards were introduced.
"""
def test_rejects_symlink_target(self, tmp_path):
target = tmp_path / "target.txt"
target.write_text("target content", encoding="utf-8")
link = tmp_path / "link.txt"
link.symlink_to(target)
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="symlinked"):
m.record_existing("link.txt")
def test_rejects_dangling_symlink(self, tmp_path):
# A symlink pointing nowhere should still be rejected before the
# ``is_file()`` check (which would itself be False on a dangler).
link = tmp_path / "dangler.txt"
link.symlink_to(tmp_path / "no-such-target.txt")
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="symlinked"):
m.record_existing("dangler.txt")
def test_rejects_directory_path(self, tmp_path):
(tmp_path / "a_dir").mkdir()
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="not a regular file"):
m.record_existing("a_dir")
def test_rejects_missing_path(self, tmp_path):
# ``is_file()`` is False for non-existent paths too; the same error
# surface keeps callers from having to distinguish "missing" from
# "wrong kind" — both mean "cannot hash this".
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="not a regular file"):
m.record_existing("never-existed.txt")
def test_lexical_prevalidation_for_absolute_path(self, tmp_path):
# ``record_existing`` must reject absolute paths via the lexical
# pre-check, NOT via the filesystem-touching ``is_symlink()`` call.
# Verified by passing an absolute path that points to a directory
# outside the project root — the canonical "Absolute paths" error
# must surface before any stat on the absolute path.
m = IntegrationManifest("test", tmp_path)
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
with pytest.raises(ValueError, match="Absolute paths"):
m.record_existing(abs_path)
class TestManifestPathTraversal:
def test_record_file_rejects_parent_traversal(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="outside"):
m.record_file("../escape.txt", "bad")
def test_record_file_rejects_absolute_path(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
with pytest.raises(ValueError, match="Absolute paths"):
m.record_file(abs_path, "bad")
def test_record_existing_rejects_parent_traversal(self, tmp_path):
escape = tmp_path.parent / "escape.txt"
escape.write_text("evil", encoding="utf-8")
try:
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="outside"):
m.record_existing("../escape.txt")
finally:
escape.unlink(missing_ok=True)
def test_uninstall_skips_traversal_paths(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("safe.txt", "good")
m._files["../outside.txt"] = "fakehash"
m.save()
removed, skipped = m.uninstall()
assert len(removed) == 1
assert removed[0].name == "safe.txt"
def test_remove_drops_entry_and_is_noop_second_time(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert "f.txt" in m.files
assert m.remove("f.txt") is True
assert "f.txt" not in m.files
assert m.remove("f.txt") is False # already gone → no-op
def test_remove_rejects_absolute_path(self, tmp_path):
# Matches record_existing/is_recovered: an absolute key can never be a
# canonical manifest key, so remove() rejects it lexically and leaves
# the tracked entry untouched.
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
import sys
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
assert m.remove(abs_input) is False
assert "f.txt" in m.files
def test_remove_rejects_parent_traversal(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert m.remove("../f.txt") is False
assert "f.txt" in m.files
class TestManifestCheckModified:
def test_unmodified_file(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
assert m.check_modified() == []
def test_modified_file(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
(tmp_path / "f.txt").write_text("changed", encoding="utf-8")
assert m.check_modified() == ["f.txt"]
def test_deleted_file_not_reported(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
(tmp_path / "f.txt").unlink()
assert m.check_modified() == []
def test_symlink_treated_as_modified(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
target = tmp_path / "target.txt"
target.write_text("target", encoding="utf-8")
(tmp_path / "f.txt").unlink()
(tmp_path / "f.txt").symlink_to(target)
assert m.check_modified() == ["f.txt"]
class TestManifestUninstall:
def test_removes_unmodified(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("d/f.txt", "content")
m.save()
removed, skipped = m.uninstall()
assert len(removed) == 1
assert not (tmp_path / "d" / "f.txt").exists()
assert not (tmp_path / "d").exists()
assert skipped == []
def test_skips_modified(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
m.save()
(tmp_path / "f.txt").write_text("modified", encoding="utf-8")
removed, skipped = m.uninstall()
assert removed == []
assert len(skipped) == 1
assert (tmp_path / "f.txt").exists()
def test_force_removes_modified(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
m.save()
(tmp_path / "f.txt").write_text("modified", encoding="utf-8")
removed, skipped = m.uninstall(force=True)
assert len(removed) == 1
assert skipped == []
def test_already_deleted_file(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "content")
m.save()
(tmp_path / "f.txt").unlink()
removed, skipped = m.uninstall()
assert removed == []
assert skipped == []
def test_removes_manifest_file(self, tmp_path):
m = IntegrationManifest("test", tmp_path, version="1.0")
m.record_file("f.txt", "content")
m.save()
assert m.manifest_path.exists()
m.uninstall()
assert not m.manifest_path.exists()
def test_cleans_empty_parent_dirs(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("a/b/c/f.txt", "content")
m.save()
m.uninstall()
assert not (tmp_path / "a").exists()
def test_preserves_nonempty_parent_dirs(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("a/b/tracked.txt", "content")
(tmp_path / "a" / "b" / "other.txt").write_text("keep", encoding="utf-8")
m.save()
m.uninstall()
assert not (tmp_path / "a" / "b" / "tracked.txt").exists()
assert (tmp_path / "a" / "b" / "other.txt").exists()
def test_symlink_skipped_without_force(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
m.save()
target = tmp_path / "target.txt"
target.write_text("target", encoding="utf-8")
(tmp_path / "f.txt").unlink()
(tmp_path / "f.txt").symlink_to(target)
removed, skipped = m.uninstall()
assert removed == []
assert len(skipped) == 1
def test_symlink_removed_with_force(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "original")
m.save()
target = tmp_path / "target.txt"
target.write_text("target", encoding="utf-8")
(tmp_path / "f.txt").unlink()
(tmp_path / "f.txt").symlink_to(target)
removed, skipped = m.uninstall(force=True)
assert len(removed) == 1
assert target.exists()
class TestManifestPersistence:
def test_save_and_load_roundtrip(self, tmp_path):
m = IntegrationManifest("myagent", tmp_path, version="2.0.1")
m.record_file("dir/file.md", "# Hello")
m.save()
loaded = IntegrationManifest.load("myagent", tmp_path)
assert loaded.key == "myagent"
assert loaded.version == "2.0.1"
assert loaded.files == m.files
def test_manifest_path(self, tmp_path):
m = IntegrationManifest("copilot", tmp_path)
assert m.manifest_path == tmp_path / ".specify" / "integrations" / "copilot.manifest.json"
def test_load_missing_raises(self, tmp_path):
with pytest.raises(FileNotFoundError):
IntegrationManifest.load("nonexistent", tmp_path)
def test_save_creates_directories(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "content")
path = m.save()
assert path.exists()
data = json.loads(path.read_text(encoding="utf-8"))
assert data["integration"] == "test"
def test_save_preserves_installed_at(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "content")
m.save()
first_ts = m._installed_at
m.save()
assert m._installed_at == first_ts
class TestManifestLoadValidation:
def test_load_non_dict_raises(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_text('"just a string"', encoding="utf-8")
with pytest.raises(ValueError, match="JSON object"):
IntegrationManifest.load("bad", tmp_path)
def test_load_bad_files_type_raises(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"files": ["not", "a", "dict"]}), encoding="utf-8")
with pytest.raises(ValueError, match="mapping"):
IntegrationManifest.load("bad", tmp_path)
def test_load_bad_files_values_raises(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8")
with pytest.raises(ValueError, match="mapping"):
IntegrationManifest.load("bad", tmp_path)
def test_load_invalid_json_raises(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_text("{not valid json", encoding="utf-8")
with pytest.raises(ValueError, match="invalid JSON"):
IntegrationManifest.load("bad", tmp_path)
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
# Finding B (Round-9): a recovered_files entry referencing a path
# not present in files indicates an internally-inconsistent manifest
# (e.g. external edit). load() filters those entries silently so the
# manifest self-heals on next save(); is_recovered then returns the
# truthful False for the orphan.
path = tmp_path / ".specify" / "integrations" / "test.manifest.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps({
"integration": "test",
"files": {"kept.txt": "abc123"},
"recovered_files": ["kept.txt", "orphan.txt"],
}), encoding="utf-8")
m = IntegrationManifest.load("test", tmp_path)
assert m.recovered_files == {"kept.txt"}
assert m.is_recovered("kept.txt") is True
assert m.is_recovered("orphan.txt") is False
class TestManifestRecoveredFiles:
"""Coverage for the ``recovered_files`` channel added in #2483.
When ``shared_infra`` skips an existing file (because the user already has
it on disk) it now records the file with ``recovered=True``. The path
appears in ``manifest.recovered_files`` and ``is_recovered(path)`` returns
True. ``refresh_managed`` (out of scope for this PR) consults this list
before treating the recorded hash as a managed baseline, defending against
silent overwrite of user customizations after manifest loss.
"""
def test_record_existing_default_is_not_recovered(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert m.is_recovered("f.txt") is False
assert m.recovered_files == set()
def test_record_existing_with_recovered_flag(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt", recovered=True)
assert m.is_recovered("f.txt") is True
assert m.recovered_files == {"f.txt"}
# File still hashed normally so check_modified/uninstall keep working
assert m.files["f.txt"] == _sha256(tmp_path / "f.txt")
def test_recovered_files_round_trips_through_save_load(self, tmp_path):
(tmp_path / "a.txt").write_text("aaa", encoding="utf-8")
(tmp_path / "b.txt").write_text("bbb", encoding="utf-8")
m = IntegrationManifest("test", tmp_path, version="9.9")
m.record_existing("a.txt", recovered=True)
m.record_existing("b.txt") # not recovered
m.save()
loaded = IntegrationManifest.load("test", tmp_path)
assert loaded.is_recovered("a.txt") is True
assert loaded.is_recovered("b.txt") is False
assert loaded.recovered_files == {"a.txt"}
def test_save_omits_empty_recovered_files(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("f.txt", "x")
path = m.save()
data = json.loads(path.read_text(encoding="utf-8"))
assert "recovered_files" not in data
def test_load_rejects_non_list_recovered_files(self, tmp_path):
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"files": {}, "recovered_files": "not-a-list"}),
encoding="utf-8",
)
with pytest.raises(ValueError, match="recovered_files"):
IntegrationManifest.load("bad", tmp_path)
def test_is_recovered_absolute_path_returns_false(self, tmp_path):
# Copilot round-5 finding: passing an absolute path silently returned
# False because the stored keys are relative POSIX strings. Round-7
# made this explicit: ``is_recovered`` now rejects absolute paths
# up front via a lexical ``rel.is_absolute()`` guard and returns
# False without calling ``_validate_rel_path`` at all — matching
# ``record_existing``'s canonical-key guard so the two methods
# agree on which inputs can ever be stored keys.
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt", recovered=True)
import sys
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
assert m.is_recovered(abs_input) is False
def test_is_recovered_escaping_path_returns_false(self, tmp_path):
# A relative path containing ``..`` segments cannot be a stored key:
# Round-7 added the same lexical ``".." in rel.parts`` guard to
# ``is_recovered`` that ``record_existing`` already enforces, so the
# method returns False immediately without reaching
# ``_validate_rel_path``. The try/except around ``_validate_rel_path``
# remains as defense-in-depth for paths that pass the lexical guard
# but still resolve outside the project root via a symlinked
# ancestor.
m = IntegrationManifest("test", tmp_path)
# Don't record anything — the path is impossible to record anyway.
assert m.is_recovered("../escape.txt") is False
def test_record_existing_clears_recovered_when_false(self, tmp_path):
# Finding A: re-recording the same path with recovered=False must
# drop the prior recovered marker (transition to managed baseline).
f = tmp_path / "x.txt"
f.write_text("v1", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("x.txt", recovered=True)
assert m.is_recovered("x.txt") is True
m.record_existing("x.txt", recovered=False)
assert m.is_recovered("x.txt") is False
def test_record_file_clears_recovered(self, tmp_path):
# Finding A: record_file writes produced content; the path can no
# longer be considered "merely observed" once we wrote bytes.
(tmp_path / "y.txt").write_text("observed", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("y.txt", recovered=True)
assert m.is_recovered("y.txt") is True
m.record_file("y.txt", "produced")
assert m.is_recovered("y.txt") is False
def test_is_recovered_rejects_dotdot_segment(self, tmp_path):
# Finding B: record_existing rejects ``..`` segments via the lexical
# pre-check; is_recovered must match that behavior and return False
# without raising, mirroring the canonicalization guard.
(tmp_path / "z.txt").write_text("v1", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("z.txt", recovered=True)
# Same file via dotdot-normalizing path — must be False, not raise.
assert m.is_recovered("subdir/../z.txt") is False
class TestRecordExistingNewGuards:
"""Coverage for the two new guards added by Copilot's 2026-05-18 review."""
def test_rejects_symlinked_ancestor(self, tmp_path):
real_dir = tmp_path / "real_dir"
real_dir.mkdir()
(real_dir / "file.txt").write_text("payload", encoding="utf-8")
(tmp_path / "linked_dir").symlink_to(real_dir, target_is_directory=True)
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match="symlinked"):
m.record_existing("linked_dir/file.txt")
def test_rejects_inside_root_dotdot_with_explicit_message(self, tmp_path):
# ``dir/../file.txt`` normalizes inside root, so the old "escapes
# project root" message was misleading. The new message names the
# actual reason: canonicalization.
(tmp_path / "dir").mkdir()
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
m.record_existing("dir/../file.txt")
class TestManifestUnreadableFile:
"""A managed file that is unreadable (e.g. PermissionError) must not crash
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
def _mk(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
m.record_file("sub/f.md", "content")
return m
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
m = self._mk(tmp_path)
def raise_perm(_path):
raise PermissionError("unreadable")
monkeypatch.setattr(
"specify_cli.integrations.manifest._sha256", raise_perm
)
# Before the fix this raised PermissionError.
assert m.check_modified() == ["sub/f.md"]
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
m = self._mk(tmp_path)
def raise_perm(_path):
raise PermissionError("unreadable")
monkeypatch.setattr(
"specify_cli.integrations.manifest._sha256", raise_perm
)
removed, skipped = m.uninstall(force=False)
# Can't verify ownership => preserve, don't crash and don't delete.
assert removed == []
assert (tmp_path / "sub" / "f.md") in skipped
assert (tmp_path / "sub" / "f.md").exists()
+296
View File
@@ -0,0 +1,296 @@
"""Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment."""
import json
import os
from pathlib import PurePosixPath
import pytest
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integrations import (
INTEGRATION_REGISTRY,
_register,
get_integration,
)
from specify_cli.integrations.base import MarkdownIntegration
from .conftest import StubIntegration
# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5).
ALL_INTEGRATION_KEYS = [
"copilot",
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "kiro-cli", "vibe", "cursor-agent", "firebender",
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations
"codex", "kimi", "agy", "zed", "generic",
]
def _multi_install_safe_keys() -> list[str]:
return sorted(
key
for key, integration in INTEGRATION_REGISTRY.items()
if integration.multi_install_safe
)
def _multi_install_safe_pairs() -> list[tuple[str, str]]:
safe_keys = _multi_install_safe_keys()
return [
(safe_keys[left], safe_keys[right])
for left in range(len(safe_keys))
for right in range(left + 1, len(safe_keys))
]
def _multi_install_safe_orders() -> list[list[str]]:
safe_keys = _multi_install_safe_keys()
if len(safe_keys) < 2:
return [safe_keys]
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
if not ordered_keys:
return "no-safe-integrations"
return f"init-{ordered_keys[0]}"
def _posix_path(value: str | None) -> str | None:
if not value:
return None
return PurePosixPath(value).as_posix()
def _integration_root_dir(key: str) -> str | None:
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config if isinstance(integration.config, dict) else {}
return _posix_path(cfg.get("folder"))
def _integration_commands_dir(key: str) -> str | None:
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config if isinstance(integration.config, dict) else {}
folder = cfg.get("folder")
if not folder:
return None
subdir = cfg.get("commands_subdir", "commands")
return (PurePosixPath(folder) / subdir).as_posix()
def _paths_overlap(first: str | None, second: str | None) -> bool:
if not first or not second:
return False
left = PurePosixPath(first)
right = PurePosixPath(second)
try:
left.relative_to(right)
return True
except ValueError:
pass
try:
right.relative_to(left)
return True
except ValueError:
return False
class TestRegistry:
def test_registry_is_dict(self):
assert isinstance(INTEGRATION_REGISTRY, dict)
def test_register_and_get(self):
stub = StubIntegration()
_register(stub)
try:
assert get_integration("stub") is stub
finally:
INTEGRATION_REGISTRY.pop("stub", None)
def test_get_missing_returns_none(self):
assert get_integration("nonexistent-xyz") is None
def test_register_empty_key_raises(self):
class EmptyKey(MarkdownIntegration):
key = ""
with pytest.raises(ValueError, match="empty key"):
_register(EmptyKey())
def test_register_duplicate_raises(self):
stub = StubIntegration()
_register(stub)
try:
with pytest.raises(KeyError, match="already registered"):
_register(StubIntegration())
finally:
INTEGRATION_REGISTRY.pop("stub", None)
class TestRegistryCompleteness:
"""Every expected integration must be registered."""
@pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS)
def test_key_registered(self, key):
assert key in INTEGRATION_REGISTRY, f"{key} missing from registry"
class TestRegistrarKeyAlignment:
"""Every integration key must have a matching AGENT_CONFIGS entry.
``generic`` is excluded because it has no fixed directory — its
output path comes from ``--commands-dir`` at runtime.
"""
@pytest.mark.parametrize(
"key",
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
)
def test_integration_key_in_registrar(self, key):
from specify_cli.agents import CommandRegistrar
assert key in CommandRegistrar.AGENT_CONFIGS, (
f"Integration '{key}' is registered but has no AGENT_CONFIGS entry"
)
def test_no_stale_cursor_shorthand(self):
"""The old 'cursor' shorthand must not appear in AGENT_CONFIGS."""
from specify_cli.agents import CommandRegistrar
assert "cursor" not in CommandRegistrar.AGENT_CONFIGS
class TestMultiInstallSafeContracts:
"""Declared safe integrations must stay isolated from each other."""
def test_safe_install_orders_rotate_each_integration_through_init(self):
safe_keys = _multi_install_safe_keys()
orders = _multi_install_safe_orders()
assert len(safe_keys) >= 2
assert [order[0] for order in orders] == safe_keys
assert len({tuple(order) for order in orders}) == len(safe_keys)
assert all(sorted(order) == safe_keys for order in orders)
@pytest.mark.parametrize("key", _multi_install_safe_keys())
def test_safe_integrations_have_static_isolated_paths(self, key):
assert _integration_root_dir(key), (
f"{key} is declared multi-install safe but has no static root directory"
)
assert _integration_commands_dir(key), (
f"{key} is declared multi-install safe but has no static commands directory"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_distinct_agent_roots(self, first, second):
assert not _paths_overlap(_integration_root_dir(first), _integration_root_dir(second)), (
f"{first} and {second} are declared multi-install safe but have "
f"overlapping agent roots {_integration_root_dir(first)!r} and "
f"{_integration_root_dir(second)!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_distinct_command_dirs(self, first, second):
assert not _paths_overlap(_integration_commands_dir(first), _integration_commands_dir(second)), (
f"{first} and {second} are declared multi-install safe but have "
f"overlapping command directories {_integration_commands_dir(first)!r} and "
f"{_integration_commands_dir(second)!r}"
)
@pytest.mark.parametrize(
"ordered_keys",
_multi_install_safe_orders(),
ids=_multi_install_safe_order_id,
)
def test_safe_integrations_have_disjoint_manifests(
self,
tmp_path,
ordered_keys,
):
# The pairwise disjointness contract is only meaningful with at least
# two safe integrations. Guard so a shrunken registry fails loudly here
# rather than passing vacuously (or tripping over ordered_keys[0] below).
assert len(ordered_keys) >= 2, (
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
)
project_root = tmp_path / "project"
project_root.mkdir()
runner = CliRunner()
# Install every safe integration once into a single project, then assert
# pairwise manifest isolation. Each safe integration writes only to its
# own (disjoint) directories and always records what it writes, so a
# manifest's contents are independent of install order and of which other
# integrations are co-installed. The parametrized rotations keep the
# aggregate setup while placing each safe integration first once, so each
# one still exercises the `specify init --integration ...` path.
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
ordered_keys[0],
"--script",
"sh",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
for key in ordered_keys[1:]:
install_result = runner.invoke(
app,
["integration", "install", key, "--script", "sh"],
catch_exceptions=False,
)
assert install_result.exit_code == 0, install_result.output
finally:
os.chdir(original_cwd)
integrations_dir = project_root / ".specify" / "integrations"
manifests = {}
for key in ordered_keys:
manifest = json.loads(
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
)
files = manifest.get("files", {})
assert isinstance(files, dict), f"{key} manifest files must be an object"
manifests[key] = set(files.keys())
for first, second in _multi_install_safe_pairs():
overlap = manifests[first] & manifests[second]
assert not overlap, (
f"{first} and {second} are declared multi-install safe but both manage "
f"these files: {sorted(overlap)}"
)
class TestCatalogParity:
"""The discovery catalog must list every registered integration."""
def test_every_registered_integration_is_in_catalog(self):
"""``integrations/catalog.json`` must cover every registry key.
The catalog is the discovery manifest; an integration that is
registered, registrar-aligned and registry-tested but missing from
the catalog is undiscoverable through it. ``generic`` is exempt —
it is the no-fixed-directory fallback, not a catalogued agent.
"""
from pathlib import Path
repo_root = Path(__file__).resolve().parents[2]
catalog = json.loads(
(repo_root / "integrations" / "catalog.json").read_text(encoding="utf-8")
)
catalogued = set(catalog["integrations"])
registered = set(INTEGRATION_REGISTRY) - {"generic"}
missing = sorted(registered - catalogued)
assert not missing, f"integrations missing from catalog.json: {missing}"
@@ -0,0 +1,111 @@
"""Regression tests for SKILL.md frontmatter quoting (#3391).
The skills setup path builds SKILL.md frontmatter by hand with
double-quoted values. A double-quoted YAML scalar cannot carry a raw
newline (the parser folds it to a space) or a control character (the
reader rejects the document), so descriptions taken from template
frontmatter must be escaped by the YAML emitter.
"""
from pathlib import Path
import yaml
from specify_cli.integrations import get_integration
from specify_cli.integrations.base import yaml_quote
from specify_cli.integrations.manifest import IntegrationManifest
MULTILINE = "first line\nsecond line\n"
CONTROL = "ding\aling"
HOSTILE_TEMPLATE = """---
description: |
first line
second line
---
Body of the command.
"""
CONTROL_TEMPLATE = """---
description: "ding\\aling"
---
Body of the command.
"""
def _parse_frontmatter(skill_file: Path) -> dict:
content = skill_file.read_text(encoding="utf-8")
assert content.startswith("---\n")
return yaml.safe_load(content.split("---", 2)[1])
def _fake_templates(tmp_path: Path, body: str) -> Path:
templates = tmp_path / "templates"
templates.mkdir(exist_ok=True)
(templates / "plan.md").write_text(body, encoding="utf-8")
return templates
class TestYamlQuote:
def test_simple_value_keeps_plain_double_quoted_form(self):
assert yaml_quote("speckit-plan") == '"speckit-plan"'
assert yaml_quote('say "hi"') == '"say \\"hi\\""'
assert yaml_quote("back\\slash") == '"back\\\\slash"'
def test_multiline_value_round_trips(self):
quoted = yaml_quote(MULTILINE)
assert "\n" not in quoted
assert yaml.safe_load(quoted) == MULTILINE
def test_control_character_round_trips(self):
quoted = yaml_quote(CONTROL)
assert "\a" not in quoted
assert yaml.safe_load(quoted) == CONTROL
class TestSkillFrontmatterQuoting:
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
integration = get_integration("agy")
monkeypatch.setattr(
integration,
"shared_commands_dir",
lambda: _fake_templates(tmp_path, template),
)
manifest = IntegrationManifest("agy", tmp_path)
created = integration.setup(tmp_path, manifest)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) == 1
return skill_files[0]
def test_multiline_description_survives(self, tmp_path, monkeypatch):
skill_file = self._generate(tmp_path, monkeypatch, HOSTILE_TEMPLATE)
fm = _parse_frontmatter(skill_file)
assert fm["description"] == MULTILINE
def test_control_character_description_parses(self, tmp_path, monkeypatch):
skill_file = self._generate(tmp_path, monkeypatch, CONTROL_TEMPLATE)
fm = _parse_frontmatter(skill_file)
assert fm["description"] == CONTROL
class TestHermesSkillFrontmatterQuoting:
def test_multiline_description_survives(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir(exist_ok=True)
monkeypatch.setattr(Path, "home", lambda: home)
integration = get_integration("hermes")
monkeypatch.setattr(
integration,
"shared_commands_dir",
lambda: _fake_templates(tmp_path, HOSTILE_TEMPLATE),
)
manifest = IntegrationManifest("hermes", tmp_path)
created = integration.setup(tmp_path, manifest)
skill_files = [f for f in created if f.name == "SKILL.md"]
assert len(skill_files) == 1
fm = _parse_frontmatter(skill_files[0])
assert fm["description"] == MULTILINE
+64
View File
@@ -0,0 +1,64 @@
"""Shared fixtures and helpers for `specify self upgrade` tests.
These helpers patch subprocess, PATH lookup, and release-tag resolution so
the focused test modules stay isolated from the real environment.
"""
import os
import subprocess
import pytest
from typer.testing import CliRunner
from specify_cli._version import (
_InstallMethod,
_UpgradePlan,
_assemble_installer_argv,
_detect_install_method,
_verify_upgrade,
)
from tests.conftest import strip_ansi
from tests.http_helpers import mock_urlopen_response
__all__ = (
"SENTINEL_GH_TOKEN",
"SENTINEL_GITHUB_TOKEN",
"_InstallMethod",
"_UpgradePlan",
"_assemble_installer_argv",
"_completed_process",
"_detect_install_method",
"_verify_upgrade",
"mock_urlopen_response",
"requires_posix",
"runner",
"strip_ansi",
)
runner = CliRunner()
# Some installer error-path tests create a relative `./uv` fixture, `chdir`
# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK).
# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the
# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the
# fixtures raise PermissionError during teardown. Skip these on Windows — the
# realistic absolute-path and bare-PATH-command branches stay covered there.
requires_posix = pytest.mark.skipif(
os.name == "nt",
reason="relative-path / executable-bit semantics are POSIX-only",
)
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
def _completed_process(
returncode: int, stdout: str = "", stderr: str = ""
) -> subprocess.CompletedProcess:
"""Build a subprocess.CompletedProcess for installer / verification calls."""
return subprocess.CompletedProcess(
args=["mocked"],
returncode=returncode,
stdout=stdout,
stderr=stderr,
)
+427
View File
@@ -0,0 +1,427 @@
"""Consistency checks for agent configuration across runtime surfaces."""
import re
from pathlib import Path
import yaml
from specify_cli import AGENT_CONFIG
from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
ISSUE_TEMPLATE_AGENT_KEYS = [
"amp",
"agy",
"auggie",
"claude",
"cline",
"codebuddy",
"codex",
"cursor-agent",
"devin",
"firebender",
"forge",
"gemini",
"copilot",
"goose",
"hermes",
"bob",
"junie",
"kilocode",
"kimi",
"kiro-cli",
"lingma",
"vibe",
"omp",
"opencode",
"pi",
"qodercli",
"qwen",
"rovodev",
"shai",
"tabnine",
"trae",
"zcode",
"zed",
]
def _issue_template(path: str) -> dict:
return yaml.safe_load((REPO_ROOT / path).read_text(encoding="utf-8"))
def _body_item_by_id(template: dict, item_id: str) -> dict:
for item in template["body"]:
if item.get("id") == item_id:
return item
raise AssertionError(f"Expected issue template body item {item_id!r}")
def _dropdown_options(path: str, item_id: str) -> list[str]:
item = _body_item_by_id(_issue_template(path), item_id)
return item["attributes"]["options"]
def _normalized_markdown(text: str) -> str:
return " ".join(text.split())
def _markdown_value_containing(path: str, marker: str) -> str:
template = _issue_template(path)
normalized_marker = _normalized_markdown(marker)
for item in template["body"]:
if item.get("type") != "markdown":
continue
value = item["attributes"]["value"]
if normalized_marker in _normalized_markdown(value):
return value
raise AssertionError(f"Expected issue template markdown containing {marker!r}")
def _markdown_paragraph_containing(path: str, marker: str) -> str:
value = _markdown_value_containing(path, marker)
normalized_marker = _normalized_markdown(marker)
for paragraph in re.split(r"\n\s*\n", value):
if normalized_marker in _normalized_markdown(paragraph):
return paragraph
raise AssertionError(f"Expected issue template paragraph containing {marker!r}")
def _supported_agent_names_from_agent_request_template() -> list[str]:
marker = "**Currently supported agents**:"
paragraph = _markdown_paragraph_containing(
".github/ISSUE_TEMPLATE/agent_request.yml",
marker,
)
supported_agents_text = _normalized_markdown(paragraph).split(marker, 1)[1].strip()
return [agent.strip() for agent in supported_agents_text.split(",")]
class TestAgentConfigConsistency:
"""Ensure agent configuration stays synchronized across key surfaces."""
def test_issue_template_agent_lists_match_runtime_integrations(self):
"""GitHub issue templates should list all concrete built-in agents."""
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}
issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS)
missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)
unexpected_agent_keys = sorted(issue_template_agent_keys - concrete_agent_keys)
duplicate_agent_keys = sorted(
key
for key in issue_template_agent_keys
if ISSUE_TEMPLATE_AGENT_KEYS.count(key) > 1
)
assert not missing_agent_keys, (
"Issue template agent list is missing AGENT_CONFIG keys: "
f"{missing_agent_keys}"
)
assert not unexpected_agent_keys, (
"Issue template agent list includes unknown AGENT_CONFIG keys: "
f"{unexpected_agent_keys}"
)
assert not duplicate_agent_keys, (
"Issue template agent list contains duplicate keys: "
f"{duplicate_agent_keys}"
)
issue_template_agent_names = [
AGENT_CONFIG[key]["name"] for key in ISSUE_TEMPLATE_AGENT_KEYS
]
assert "Generic (bring your own agent)" not in issue_template_agent_names
bug_options = _dropdown_options(
".github/ISSUE_TEMPLATE/bug_report.yml",
"ai-agent",
)
assert bug_options == issue_template_agent_names + ["Not applicable"]
feature_options = _dropdown_options(
".github/ISSUE_TEMPLATE/feature_request.yml",
"ai-agent",
)
assert feature_options == [
"All agents",
*issue_template_agent_names,
"Not applicable",
]
assert (
_supported_agent_names_from_agent_request_template()
== issue_template_agent_names
)
def test_runtime_config_uses_kiro_cli_and_removes_q(self):
"""AGENT_CONFIG should include kiro-cli and exclude legacy q."""
assert "kiro-cli" in AGENT_CONFIG
assert AGENT_CONFIG["kiro-cli"]["folder"] == ".kiro/"
assert AGENT_CONFIG["kiro-cli"]["commands_subdir"] == "prompts"
assert "q" not in AGENT_CONFIG
def test_extension_registrar_uses_kiro_cli_and_removes_q(self):
"""Extension command registrar should target .kiro/prompts."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "kiro-cli" in cfg
assert cfg["kiro-cli"]["dir"] == ".kiro/prompts"
assert "q" not in cfg
def test_extension_registrar_includes_codex(self):
"""Extension command registrar should include codex targeting .agents/skills."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "codex" in cfg
assert cfg["codex"]["dir"] == ".agents/skills"
assert cfg["codex"]["extension"] == "/SKILL.md"
def test_runtime_codex_uses_native_skills(self):
"""Codex runtime config should point at .agents/skills."""
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
encoding="utf-8"
)
assert (
'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"'
in post_create_text
)
assert "sha256sum -c -" in post_create_text
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
# --- Tabnine CLI consistency checks ---
def test_runtime_config_includes_tabnine(self):
"""AGENT_CONFIG should include tabnine with correct folder and subdir."""
assert "tabnine" in AGENT_CONFIG
assert AGENT_CONFIG["tabnine"]["folder"] == ".tabnine/agent/"
assert AGENT_CONFIG["tabnine"]["commands_subdir"] == "commands"
assert AGENT_CONFIG["tabnine"]["requires_cli"] is True
assert AGENT_CONFIG["tabnine"]["install_url"] is not None
def test_extension_registrar_includes_tabnine(self):
"""CommandRegistrar.AGENT_CONFIGS should include tabnine with correct TOML config."""
from specify_cli.extensions import CommandRegistrar
assert "tabnine" in CommandRegistrar.AGENT_CONFIGS
cfg = CommandRegistrar.AGENT_CONFIGS["tabnine"]
assert cfg["dir"] == ".tabnine/agent/commands"
assert cfg["format"] == "toml"
assert cfg["args"] == "{{args}}"
assert cfg["extension"] == ".toml"
def test_agent_config_includes_tabnine(self):
"""AGENT_CONFIG should include tabnine."""
assert "tabnine" in AGENT_CONFIG
# --- Kimi Code CLI consistency checks ---
def test_kimi_in_agent_config(self):
"""AGENT_CONFIG should include kimi with correct folder and commands_subdir."""
assert "kimi" in AGENT_CONFIG
assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/"
assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills"
assert AGENT_CONFIG["kimi"]["requires_cli"] is True
def test_kimi_in_extension_registrar(self):
"""Extension command registrar should include kimi using .kimi-code/skills and SKILL.md."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "kimi" in cfg
kimi_cfg = cfg["kimi"]
assert kimi_cfg["dir"] == ".kimi-code/skills"
assert kimi_cfg["extension"] == "/SKILL.md"
def test_agent_config_includes_kimi(self):
"""AGENT_CONFIG should include kimi."""
assert "kimi" in AGENT_CONFIG
# --- Trae IDE consistency checks ---
def test_trae_in_agent_config(self):
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
assert "trae" in AGENT_CONFIG
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
assert AGENT_CONFIG["trae"]["commands_subdir"] == "skills"
assert AGENT_CONFIG["trae"]["requires_cli"] is False
assert AGENT_CONFIG["trae"]["install_url"] is None
def test_trae_in_extension_registrar(self):
"""Extension command registrar should include trae using .trae/rules and markdown, if present."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "trae" in cfg
trae_cfg = cfg["trae"]
assert trae_cfg["format"] == "markdown"
assert trae_cfg["args"] == "$ARGUMENTS"
assert trae_cfg["extension"] == "/SKILL.md"
def test_agent_config_includes_trae(self):
"""AGENT_CONFIG should include trae."""
assert "trae" in AGENT_CONFIG
# --- Pi Coding Agent consistency checks ---
def test_pi_in_agent_config(self):
"""AGENT_CONFIG should include pi with correct folder and commands_subdir."""
assert "pi" in AGENT_CONFIG
assert AGENT_CONFIG["pi"]["folder"] == ".pi/"
assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts"
assert AGENT_CONFIG["pi"]["requires_cli"] is True
assert AGENT_CONFIG["pi"]["install_url"] is not None
def test_pi_in_extension_registrar(self):
"""Extension command registrar should include pi using .pi/prompts."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "pi" in cfg
pi_cfg = cfg["pi"]
assert pi_cfg["dir"] == ".pi/prompts"
assert pi_cfg["format"] == "markdown"
assert pi_cfg["args"] == "$ARGUMENTS"
assert pi_cfg["extension"] == ".md"
def test_agent_config_includes_pi(self):
"""AGENT_CONFIG should include pi."""
assert "pi" in AGENT_CONFIG
# --- Goose consistency checks ---
def test_goose_in_agent_config(self):
"""AGENT_CONFIG should include goose with correct folder and commands_subdir."""
assert "goose" in AGENT_CONFIG
assert AGENT_CONFIG["goose"]["folder"] == ".goose/"
assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes"
assert AGENT_CONFIG["goose"]["requires_cli"] is True
def test_goose_in_extension_registrar(self):
"""Extension command registrar should include goose targeting .goose/recipes."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "goose" in cfg
assert cfg["goose"]["dir"] == ".goose/recipes"
assert cfg["goose"]["format"] == "yaml"
assert cfg["goose"]["args"] == "{{args}}"
def test_agent_config_includes_goose(self):
"""AGENT_CONFIG should include goose."""
assert "goose" in AGENT_CONFIG
# --- invoke_separator propagation checks ---
def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self):
"""Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS.
SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute,
but individual skills integrations (claude, codex, …) do not repeat it in
their ``registrar_config`` dicts. ``_build_agent_configs()`` must
propagate the class attribute so that ``register_commands()`` resolves
``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator.
"""
cfg = CommandRegistrar.AGENT_CONFIGS
skills_agents = [
key for key, c in cfg.items() if c.get("extension") == "/SKILL.md"
]
assert skills_agents, (
"Expected at least one skills-based agent in AGENT_CONFIGS"
)
for agent in skills_agents:
assert cfg[agent].get("invoke_separator") == "-", (
f"Skills agent '{agent}' has invoke_separator="
f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; "
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
)
def test_codex_dev_no_symlink_policy_in_agent_config(self):
"""Codex dev installs must expose the no-symlink policy as metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert cfg["codex"].get("dev_no_symlink") is True
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
when registered for a skills-based agent (e.g. claude).
Regression guard: before the fix, _build_agent_configs() did not
propagate invoke_separator from the integration class, so
register_commands() fell back to '.' and emitted /speckit.specify instead
of /speckit-specify for skills agents.
"""
import re
from pathlib import Path
from specify_cli.agents import CommandRegistrar
repo_root = Path(__file__).resolve().parent.parent
ext_dir = repo_root / "extensions" / "git"
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
assert cmd_source.exists(), (
f"Git extension command source not found at {cmd_source}"
)
assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text(
encoding="utf-8"
), (
"Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; "
"check that the file uses the token rather than a hard-coded ref."
)
registrar = CommandRegistrar()
commands = [
{"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"}
]
registered = registrar.register_commands(
"claude",
commands,
"git",
ext_dir,
tmp_path,
)
assert "speckit.git.feature" in registered
skill_file = (
tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
)
assert skill_file.exists(), (
f"Expected Claude skill file not found at {skill_file}"
)
content = skill_file.read_text(encoding="utf-8")
assert "/speckit-specify" in content, (
"Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; "
"__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator."
)
# Negative lookbehind (?<![a-zA-Z0-9_]) excludes file-path occurrences
# such as 'source: git:commands/speckit.git.feature.md' in frontmatter,
# where the '/' is a path separator preceded by a word character.
assert not re.search(r"(?<![a-zA-Z0-9_])/speckit\.[a-z]", content), (
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
"Skills agents must use hyphen notation."
)
# --- RovoDev consistency checks ---
def test_rovodev_in_agent_config(self):
"""AGENT_CONFIG should include rovodev with skills-based scaffold metadata."""
assert "rovodev" in AGENT_CONFIG
assert AGENT_CONFIG["rovodev"]["folder"] == ".rovodev/"
assert AGENT_CONFIG["rovodev"]["commands_subdir"] == "skills"
assert AGENT_CONFIG["rovodev"]["requires_cli"] is True
def test_rovodev_in_extension_registrar(self):
"""CommandRegistrar.AGENT_CONFIGS should include rovodev skill scaffold metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "rovodev" in cfg
rovodev_cfg = cfg["rovodev"]
assert rovodev_cfg["dir"] == ".rovodev/skills"
assert rovodev_cfg["format"] == "markdown"
assert rovodev_cfg["args"] == "$ARGUMENTS"
assert rovodev_cfg["extension"] == "/SKILL.md"
def test_agent_config_includes_rovodev(self):
"""AGENT_CONFIG should include rovodev."""
assert "rovodev" in AGENT_CONFIG
+974
View File
@@ -0,0 +1,974 @@
"""Tests for the authentication provider registry and config-driven HTTP helpers.
Covers:
- Config loading (auth.json parsing, validation, permission warning)
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
- GitHubAuth — bearer headers
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
- Host matching (find_entries_for_url)
- open_url — config-driven auth with fallthrough and redirect stripping
- build_request — single-shot request construction
- _fetch_latest_release_tag() delegation
"""
from __future__ import annotations
import base64
import json
import os
import pytest
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
from specify_cli.authentication.base import AuthProvider
from specify_cli.authentication.config import (
AuthConfigEntry,
find_entries_for_url,
load_auth_config,
)
from specify_cli.authentication.github import GitHubAuth
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
"""Build a standard GitHub config entry."""
return AuthConfigEntry(
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
provider="github",
auth="bearer",
token=token,
token_env=token_env if token is None else None,
)
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
"""Build an ADO basic-pat config entry."""
return AuthConfigEntry(
hosts=("dev.azure.com",),
provider="azure-devops",
auth="basic-pat",
token_env=token_env,
)
class _StubProvider(AuthProvider):
"""Minimal concrete provider for registry mechanics tests."""
key = "stub-provider"
supported_auth_schemes = ("bearer",)
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
class TestLoadAuthConfig:
def test_missing_file_returns_empty(self, tmp_path):
assert load_auth_config(tmp_path / "nonexistent.json") == []
def test_valid_github_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
assert entries[0].provider == "github"
assert entries[0].auth == "bearer"
assert entries[0].token_env == "GH_TOKEN"
def test_valid_ado_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
assert entries[0].provider == "azure-devops"
assert entries[0].auth == "basic-pat"
def test_inline_token(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token": "ghp_inline_token",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].token == "ghp_inline_token"
def test_azure_ad_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "tid",
"client_id": "cid",
"client_secret_env": "SECRET",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].auth == "azure-ad"
assert entries[0].tenant_id == "tid"
def test_azure_cli_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-cli",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].auth == "azure-cli"
def test_multiple_entries(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
]
}))
entries = load_auth_config(cfg)
assert len(entries) == 2
# -- Negative: validation errors --
def test_invalid_json_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("not json")
with pytest.raises(json.JSONDecodeError):
load_auth_config(cfg)
def test_not_object_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text("[]")
with pytest.raises(ValueError, match="JSON object"):
load_auth_config(cfg)
def test_missing_providers_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({"foo": "bar"}))
with pytest.raises(ValueError, match="providers"):
load_auth_config(cfg)
def test_empty_hosts_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="non-empty"):
load_auth_config(cfg)
def test_missing_provider_key_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="provider"):
load_auth_config(cfg)
def test_unsupported_auth_scheme_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="does not support"):
load_auth_config(cfg)
def test_bearer_without_token_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
}))
with pytest.raises(ValueError, match="token"):
load_auth_config(cfg)
def test_azure_ad_missing_fields_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "tid",
}]
}))
with pytest.raises(ValueError, match="azure-ad"):
load_auth_config(cfg)
def test_unknown_provider_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="unknown provider"):
load_auth_config(cfg)
def test_incompatible_provider_scheme_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "basic-pat",
"token_env": "X",
}]
}))
with pytest.raises(ValueError, match="does not support"):
load_auth_config(cfg)
def test_dangerous_wildcard_host_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)
def test_multi_wildcard_host_raises(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
}))
with pytest.raises(ValueError, match="invalid host pattern"):
load_auth_config(cfg)
def test_valid_star_dot_host_accepted(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
}))
entries = load_auth_config(cfg)
assert entries[0].hosts == ("*.visualstudio.com",)
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
def test_world_readable_warns(self, tmp_path):
import stat
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
}))
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
with pytest.warns(UserWarning, match="readable by group"):
load_auth_config(cfg)
# ---------------------------------------------------------------------------
# Host matching
# ---------------------------------------------------------------------------
class TestFindEntriesForUrl:
def test_exact_match(self):
entry = _github_entry()
result = find_entries_for_url("https://github.com/org/repo", [entry])
assert result == [entry]
def test_wildcard_match(self):
entry = AuthConfigEntry(
hosts=("*.visualstudio.com",),
provider="azure-devops",
auth="basic-pat",
token_env="ADO_PAT",
)
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
assert result == [entry]
def test_no_match_returns_empty(self):
entry = _github_entry()
result = find_entries_for_url("https://evil.example.com/file", [entry])
assert result == []
def test_no_match_for_lookalike_host(self):
entry = _github_entry()
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
assert result == []
def test_empty_url_returns_empty(self):
assert find_entries_for_url("", [_github_entry()]) == []
@pytest.mark.parametrize(
"url",
[
"https://[::1", # unterminated ipv6 bracket
"https://[not-an-ip]/file", # bracketed non-ip host
],
)
def test_malformed_url_returns_empty(self, url):
# A malformed authority makes urlparse/hostname raise ValueError.
# Since no entry can match such a URL, this must return no matches
# (like a host-less URL) rather than leaking a raw ValueError out of
# the shared HTTP client.
assert find_entries_for_url(url, [_github_entry()]) == []
def test_empty_entries_returns_empty(self):
assert find_entries_for_url("https://github.com/org/repo", []) == []
def test_multiple_matches_returned(self):
e1 = _github_entry(token_env="GH_TOKEN")
e2 = _github_entry(token_env="GITHUB_TOKEN")
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
assert len(result) == 2
# ---------------------------------------------------------------------------
# Registry mechanics
# ---------------------------------------------------------------------------
class TestAuthRegistry:
def test_github_registered(self):
assert "github" in AUTH_REGISTRY
def test_azure_devops_registered(self):
assert "azure-devops" in AUTH_REGISTRY
def test_get_provider_returns_github(self):
assert isinstance(get_provider("github"), GitHubAuth)
def test_get_provider_returns_azure_devops(self):
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
def test_get_provider_unknown_returns_none(self):
assert get_provider("does-not-exist") is None
def test_register_duplicate_raises_key_error(self):
class _UniqueStub(_StubProvider):
key = "__test_duplicate__"
try:
_register(_UniqueStub())
with pytest.raises(KeyError, match="already registered"):
_register(_UniqueStub())
finally:
AUTH_REGISTRY.pop("__test_duplicate__", None)
def test_register_empty_key_raises_value_error(self):
class _EmptyKey(_StubProvider):
key = ""
with pytest.raises(ValueError, match="empty key"):
_register(_EmptyKey())
# ---------------------------------------------------------------------------
# GitHubAuth
# ---------------------------------------------------------------------------
class TestGitHubAuth:
def test_bearer_headers(self):
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
def test_unsupported_scheme_raises(self):
with pytest.raises(ValueError, match="basic-pat"):
GitHubAuth().auth_headers("tok", "basic-pat")
def test_resolve_token_from_env(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", "env-token")
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
def test_resolve_token_inline(self):
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
def test_resolve_token_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " my-token ")
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
monkeypatch.setenv("GH_TOKEN", " ")
assert GitHubAuth().resolve_token(_github_entry()) is None
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
monkeypatch.delenv("GH_TOKEN", raising=False)
assert GitHubAuth().resolve_token(_github_entry()) is None
def test_key(self):
assert GitHubAuth.key == "github"
def test_supported_schemes(self):
assert GitHubAuth.supported_auth_schemes == ("bearer",)
# ---------------------------------------------------------------------------
# AzureDevOpsAuth
# ---------------------------------------------------------------------------
class TestAzureDevOpsAuth:
def test_basic_pat_headers(self):
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
encoded = base64.b64encode(b":my-pat").decode("ascii")
assert headers == {"Authorization": f"Basic {encoded}"}
def test_basic_pat_format(self):
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
assert raw == ":test-pat"
def test_bearer_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
def test_azure_cli_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
def test_azure_ad_headers(self):
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
def test_unsupported_scheme_raises(self):
with pytest.raises(ValueError):
AzureDevOpsAuth().auth_headers("tok", "ntlm")
def test_resolve_token_basic_pat(self, monkeypatch):
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
def test_resolve_token_strips_whitespace(self, monkeypatch):
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
def test_resolve_token_missing_returns_none(self, monkeypatch):
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
def test_key(self):
assert AzureDevOpsAuth.key == "azure-devops"
def test_supported_schemes(self):
schemes = AzureDevOpsAuth.supported_auth_schemes
assert "basic-pat" in schemes
assert "bearer" in schemes
assert "azure-cli" in schemes
assert "azure-ad" in schemes
def test_resolve_token_azure_cli_success(self):
"""azure-cli acquires token via az CLI."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 0
result.stdout = '{"accessToken": "cli-acquired-token"}'
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
def test_resolve_token_azure_cli_failure_returns_none(self):
"""azure-cli returns None when az CLI fails."""
from unittest.mock import patch, MagicMock
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
result = MagicMock()
result.returncode = 1
result.stdout = ""
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_cli_not_installed_returns_none(self):
"""azure-cli returns None when az is not installed."""
from unittest.mock import patch
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
)
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_success(self, monkeypatch):
"""azure-ad acquires token via OAuth2 client credentials."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp):
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
"""azure-ad returns None when client secret env var is missing."""
monkeypatch.delenv("MY_SECRET", raising=False)
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
assert AzureDevOpsAuth().resolve_token(entry) is None
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
"""azure-ad returns None on network errors."""
import urllib.error
from unittest.mock import patch
monkeypatch.setenv("MY_SECRET", "secret-value")
entry = AuthConfigEntry(
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
)
with patch("urllib.request.urlopen",
side_effect=urllib.error.URLError("connection refused")):
assert AzureDevOpsAuth().resolve_token(entry) is None
# ---------------------------------------------------------------------------
# open_url / build_request — positive tests
# ---------------------------------------------------------------------------
class TestAuthenticatedHttp:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://github.com/org/repo")
assert req.get_header("Authorization") == "Bearer my-token"
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://evil.example.com/file")
assert "Authorization" not in req.headers
def test_build_request_no_auth_when_no_config(self, monkeypatch):
from specify_cli.authentication.http import build_request
self._set_config(monkeypatch, [])
req = build_request("https://github.com/org/repo")
assert "Authorization" not in req.headers
def test_build_request_extra_headers(self, monkeypatch):
from specify_cli.authentication.http import build_request
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
assert req.get_header("Accept") == "application/json"
assert req.get_header("Authorization") == "Bearer my-token"
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener.open.side_effect = fake_open
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
open_url("https://github.com/org/repo/catalog.json")
assert captured["req"].get_header("Authorization") == "Bearer my-token"
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "my-token")
self._set_config(monkeypatch, [_github_entry()])
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://example.com/file.json")
assert captured["req"].get_header("Authorization") is None
def test_open_url_no_auth_when_no_config(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
captured = {}
def fake_urlopen(req, timeout=None):
captured["req"] = req
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
open_url("https://github.com/org/repo")
assert captured["req"].get_header("Authorization") is None
def test_open_url_falls_through_on_401(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "bad-token")
self._set_config(monkeypatch, [_github_entry()])
call_count = 0
def fake_side_effect(req, timeout=None):
nonlocal call_count
call_count += 1
if call_count == 1:
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
mock_opener = MagicMock()
mock_opener.open.side_effect = fake_side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
open_url("https://github.com/org/repo")
assert call_count == 2
# ---------------------------------------------------------------------------
# open_url — negative tests
# ---------------------------------------------------------------------------
class TestAuthenticatedHttpNegative:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def test_500_raises_immediately(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "tok")
self._set_config(monkeypatch, [_github_entry()])
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.HTTPError, match="500"):
open_url("https://github.com/org/repo")
def test_404_raises_immediately(self, monkeypatch):
import urllib.error
from unittest.mock import MagicMock, patch
from specify_cli.authentication.http import open_url
monkeypatch.setenv("GH_TOKEN", "tok")
self._set_config(monkeypatch, [_github_entry()])
mock_opener = MagicMock()
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
with pytest.raises(urllib.error.HTTPError, match="404"):
open_url("https://github.com/org/repo")
def test_urlerror_propagates(self, monkeypatch):
import urllib.error
from unittest.mock import patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=urllib.error.URLError("refused")):
with pytest.raises(urllib.error.URLError):
open_url("https://example.com/file")
def test_timeout_propagates(self, monkeypatch):
import socket
from unittest.mock import patch
from specify_cli.authentication.http import open_url
self._set_config(monkeypatch, [])
with patch("specify_cli.authentication.http.urllib.request.urlopen",
side_effect=socket.timeout("timed out")):
with pytest.raises(socket.timeout):
open_url("https://example.com/file")
# ---------------------------------------------------------------------------
# _load_config caching
# ---------------------------------------------------------------------------
class TestLoadConfigCaching:
def test_config_cached_after_first_load(self, monkeypatch):
"""_load_config() should call load_auth_config only once per process."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
# Allow the real load path (no override)
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
entry = _github_entry()
call_count = 0
def fake_load(path=None):
nonlocal call_count
call_count += 1
return [entry]
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
_mod._load_config()
_mod._load_config()
_mod._load_config()
assert call_count == 1
def test_cache_bypassed_by_override(self, monkeypatch):
"""When _config_override is set, the cache is ignored entirely."""
from specify_cli.authentication import http as _mod
sentinel = [_github_entry()]
monkeypatch.setattr(_mod, "_config_override", sentinel)
monkeypatch.setattr(_mod, "_config_cache", None)
result = _mod._load_config()
assert result is sentinel
# Cache must not have been populated when override is active
assert _mod._config_cache is None
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
from unittest.mock import patch
from specify_cli.authentication import http as _mod
import warnings as _warnings
monkeypatch.setattr(_mod, "_config_override", None)
monkeypatch.setattr(_mod, "_config_cache", None)
call_count = 0
def fail_load(path=None):
nonlocal call_count
call_count += 1
raise ValueError("bad config")
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
with _warnings.catch_warnings(record=True) as w:
_warnings.simplefilter("always")
result1 = _mod._load_config()
result2 = _mod._load_config()
result3 = _mod._load_config()
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
assert len(user_warnings) == 1, "Expected exactly one warning"
# Loader called only once — subsequent calls used cache
assert call_count == 1
# All calls returned the cached empty list
assert result1 == result2 == result3 == []
# ---------------------------------------------------------------------------
# Redirect stripping
# ---------------------------------------------------------------------------
class TestRedirectStripping:
def test_redirect_within_hosts_preserves_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://codeload.github.com/org/repo/zip")
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer tok"
def test_redirect_outside_hosts_strips_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://objects.githubusercontent.com/asset")
assert new_req is not None
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_https_to_http_same_host_redirect_strips_auth(self):
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
assert new_req is not None
assert new_req.headers.get("Authorization") is None
assert new_req.unredirected_hdrs.get("Authorization") is None
def test_redirect_validator_can_reject_before_following_redirect(self):
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
def reject_http(old_url, new_url):
if new_url.startswith("http://"):
raise urllib.error.URLError("scheme downgrade")
handler = _StripAuthOnRedirect(("github.com",), reject_http)
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"http://github.com/org/repo")
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
"""Auth survives a multi-hop redirect chain within allowed hosts."""
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
handler = _StripAuthOnRedirect(hosts)
# First hop: github.com → codeload.github.com
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
"https://codeload.github.com/org/repo/zip")
assert req2 is not None
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
assert auth2 == "Bearer tok"
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
"https://objects-origin.githubusercontent.com/asset")
assert req3 is not None
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
assert auth3 == "Bearer tok"
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
as URLError, which download paths already handle, rather than an
unhandled ValueError traceback."""
import urllib.error
from specify_cli.authentication.http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect(("github.com",))
req = Request("https://github.com/org/repo")
with pytest.raises(urllib.error.URLError):
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
"https://[::1/asset")
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag delegation
# ---------------------------------------------------------------------------
class TestFetchLatestReleaseTagDelegation:
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _mod
monkeypatch.setattr(_mod, "_config_override", entries)
def _capture_request(self):
import json as _json
from unittest.mock import MagicMock
captured: dict = {}
def side_effect(req, timeout=None):
captured["request"] = req
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
resp = MagicMock()
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
return captured, side_effect
def test_gh_token_forwarded_when_configured(self, monkeypatch):
from unittest.mock import MagicMock, patch
from specify_cli._version import _fetch_latest_release_tag
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
self._set_config(monkeypatch, [_github_entry()])
captured, side_effect = self._capture_request()
mock_opener = MagicMock()
mock_opener.open.side_effect = side_effect
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
def test_no_config_means_no_auth(self, monkeypatch):
from unittest.mock import patch
from specify_cli._version import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Authorization") is None
def test_accept_header_present(self, monkeypatch):
from unittest.mock import patch
from specify_cli._version import _fetch_latest_release_tag
self._set_config(monkeypatch, [])
captured, side_effect = self._capture_request()
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
# ---------------------------------------------------------------------------
# github_provider_hosts
# ---------------------------------------------------------------------------
class TestGithubProviderHosts:
"""Tests for github_provider_hosts() — the GHES host allowlist source."""
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", entries)
def test_returns_hosts_from_github_entries(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [
AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"),
provider="github", auth="bearer", token="t"),
])
assert github_provider_hosts() == ("ghes.example", "raw.ghes.example")
def test_empty_when_no_config(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [])
assert github_provider_hosts() == ()
def test_ignores_non_github_providers(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [
AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops",
auth="basic-pat", token="t"),
])
assert github_provider_hosts() == ()
def test_unions_multiple_github_entries(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"),
])
assert github_provider_hosts() == ("ghes.example", "github.com")
+24
View File
@@ -0,0 +1,24 @@
"""
Unit tests verifying --branch-numbering removal (v0.10.0).
Branch numbering is now managed entirely by the git extension's config.
The --branch-numbering flag was removed from `specify init`.
"""
from pathlib import Path
class TestBranchNumberingFlagRemoved:
"""--branch-numbering flag was removed in v0.10.0."""
def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "proj"), "--integration", "claude",
"--branch-numbering", "sequential", "--ignore-agent-tools",
])
assert result.exit_code != 0, "--branch-numbering should be rejected"
assert "No such option" in result.output or "no such option" in result.output.lower()
@@ -0,0 +1,485 @@
"""Tests for check-prerequisites --paths-only skipping branch validation (#2653)."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(CHECK_PREREQS_SH, d / "check-prerequisites.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def prereq_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ── Bash tests ────────────────────────────────────────────────────────────
@requires_bash
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "REPO_ROOT" in data
assert "BRANCH" in data
assert "FEATURE_DIR" in data
@requires_bash
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "FEATURE_DIR" in data
assert "001-my-feature" in data.get("BRANCH", "")
@requires_bash
@pytest.mark.parametrize(
("use_env_var", "specify_feature", "expected_branch"),
[
(False, None, "001-my-feature"),
(True, None, "001-my-feature"),
(False, "my-explicit-branch", "my-explicit-branch"),
],
ids=["feature_json", "env_var", "explicit_feature"],
)
def test_current_branch_falls_back_to_feature_dir_basename(
prereq_repo: Path, use_env_var: bool, specify_feature: str | None, expected_branch: str
) -> None:
"""With no SPECIFY_FEATURE, BRANCH falls back to the feature directory
basename (from feature.json or SPECIFY_FEATURE_DIRECTORY) instead of being
emitted empty. If SPECIFY_FEATURE is set, it remains authoritative (#3026)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
env = _clean_env()
if specify_feature:
env["SPECIFY_FEATURE"] = specify_feature
if use_env_var:
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-my-feature"
else:
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH"] == expected_branch
@requires_bash
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only without --json must return text paths from feature.json."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
assert "REPO_ROOT:" in result.stdout
assert "FEATURE_DIR:" in result.stdout
@requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, feature directory validation must still fail on main.
The error must go to stderr and stdout must stay clean, so a caller that
parses stdout as JSON is not handed the error string instead (#3122).
"""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
@requires_bash
def test_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
"""--paths-only must not rewrite feature.json even when the env override
differs from the pinned value (#3025).
Path resolution is read-only, so it must never dirty the working tree or
overwrite the persisted feature directory.
"""
pinned = "specs/001-my-feature"
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
(prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo, pinned)
fj = prereq_repo / ".specify" / "feature.json"
before = fj.read_text(encoding="utf-8")
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
result = subprocess.run(
["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
# The override is honored in the output...
data = json.loads(result.stdout)
assert "002-other" in data["FEATURE_DIR"]
# ...but the pinned file on disk is untouched.
assert fj.read_text(encoding="utf-8") == before
@requires_bash
def test_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None:
"""Without --paths-only, the env override is still persisted to feature.json,
so the --no-persist opt-out does not regress normal write behavior (#3025)."""
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
feat = prereq_repo / "specs" / "002-other"
feat.mkdir(parents=True, exist_ok=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, "specs/001-my-feature")
fj = prereq_repo / ".specify" / "feature.json"
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other"
# ── PowerShell tests ──────────────────────────────────────────────────────
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "REPO_ROOT" in data
assert "BRANCH" in data
assert "FEATURE_DIR" in data
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.parametrize(
("use_env_var", "specify_feature", "expected_branch"),
[
(False, None, "001-my-feature"),
(True, None, "001-my-feature"),
(False, "my-explicit-branch", "my-explicit-branch"),
],
ids=["feature_json", "env_var", "explicit_feature"],
)
def test_ps_current_branch_falls_back_to_feature_dir_basename(
prereq_repo: Path, use_env_var: bool, specify_feature: str | None, expected_branch: str
) -> None:
"""With no SPECIFY_FEATURE, BRANCH falls back to the feature directory
basename (from feature.json or SPECIFY_FEATURE_DIRECTORY) instead of being
emitted empty. If SPECIFY_FEATURE is set, it remains authoritative (#3026)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
env = _clean_env()
if specify_feature:
env["SPECIFY_FEATURE"] = specify_feature
if use_env_var:
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-my-feature"
else:
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["BRANCH"] == expected_branch
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo,
check=True,
)
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "FEATURE_DIR" in data
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, feature directory validation must still fail on main.
The error must land on stderr only, leaving stdout clean for -Json
callers that parse it as JSON (#3122).
"""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None:
"""A missing plan.md must report on stderr, not stdout (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "plan.md not found" in result.stderr
assert "plan.md not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
"""With -RequireTasks, a missing tasks.md must report on stderr only (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks.md not found" in result.stderr
assert "tasks.md not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
"""-PathsOnly must not rewrite feature.json even when the env override
differs from the pinned value (#3025)."""
pinned = "specs/001-my-feature"
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
(prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo, pinned)
fj = prereq_repo / ".specify" / "feature.json"
before = fj.read_text(encoding="utf-8")
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert "002-other" in data["FEATURE_DIR"]
assert fj.read_text(encoding="utf-8") == before
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None:
"""Without -PathsOnly, the env override is still persisted to feature.json,
so the -NoPersist opt-out does not regress normal write behavior (#3025).
Symmetric to the bash test_normal_mode_still_persists_feature_json guard:
asserts the default path still persists and that -NoPersist is not passed
unconditionally.
"""
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
feat = prereq_repo / "specs" / "002-other"
feat.mkdir(parents=True, exist_ok=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, "specs/001-my-feature")
fj = prereq_repo / ".specify" / "feature.json"
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other"
@@ -0,0 +1,339 @@
"""Parity tests for the Python check-prerequisites PoC."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (
shutil.which("powershell.exe") or shutil.which("powershell")
) if os.name == "nt" else None
def _install_scripts(repo: Path) -> None:
bash_dir = repo / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, bash_dir / "common.sh")
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
ps_dir = repo / ".specify" / "scripts" / "powershell"
ps_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
py_dir = repo / ".specify" / "scripts" / "python"
py_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
@pytest.fixture
def prereq_repo(tmp_path: Path) -> Path:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
return repo
def _py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
script = repo / "scripts" / "python" / "check_prerequisites.py"
return [sys.executable, str(script), *args]
def _bash_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
return ["bash", str(script), *args]
def _ps_cmd(repo: Path, *args: str) -> list[str]:
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return [exe, "-NoProfile", "-File", str(script), *args]
def _run(
cmd: list[str], repo: Path, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=repo,
capture_output=True,
text=True,
check=False,
env=env or _clean_env(),
)
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)
def _normalize_status_text(text: str) -> str:
return (
text.replace("", " [OK] ")
.replace("", " [FAIL] ")
.replace("\r\n", "\n")
)
def _normalize_help_text(text: str) -> str:
normalized = text.replace("\r\n", "\n").replace(
"check-prerequisites.sh", "check_prerequisites.py"
)
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
@requires_bash
@pytest.mark.parametrize(
"args",
[
("--json",),
("--json", "--include-tasks"),
("--json", "--require-tasks", "--include-tasks"),
("--json", "--paths-only"),
],
)
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _json_stdout(py) == _json_stdout(bash)
@requires_bash
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "contracts").mkdir()
_write_feature_json(prereq_repo)
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
@requires_bash
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
@requires_bash
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
@pytest.mark.parametrize(
("py_args", "ps_args"),
[
(("--json",), ("-Json",)),
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
(
("--json", "--require-tasks", "--include-tasks"),
("-Json", "-RequireTasks", "-IncludeTasks"),
),
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
],
ids=[
"json",
"json_include_tasks",
"json_require_tasks_include_tasks",
"json_paths_only",
],
)
def test_python_json_output_matches_powershell(
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
) -> None:
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
(feat / "research.md").write_text("# research\n", encoding="utf-8")
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feat / "contracts" / "v1").mkdir(parents=True)
_write_feature_json(prereq_repo)
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
assert py.returncode == ps.returncode == 0
assert py.stderr == ps.stderr == ""
assert _json_stdout(py) == _json_stdout(ps)
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
repo = tmp_path / "proj"
outside = tmp_path / "outside"
repo.mkdir()
outside.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_write_feature_json(repo)
(repo / "specs" / "001-my-feature").mkdir(parents=True)
py_dir = repo / "scripts" / "python"
py_dir.mkdir(parents=True)
shutil.copy(COMMON_PY, py_dir / "common.py")
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
assert py.returncode == 0, py.stderr
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
_write_feature_json(prereq_repo, "specs/001-my-feature")
feature_json = prereq_repo / ".specify" / "feature.json"
before = feature_json.read_text(encoding="utf-8")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
assert feature_json.read_text(encoding="utf-8") == before
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
feat = prereq_repo / "specs" / "002-other"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo, "specs/001-my-feature")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
data = json.loads(
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
)
assert data["feature_directory"] == "specs/002-other"
@pytest.mark.parametrize(
("args", "expected"),
[
(("--json",), "Feature directory not found"),
(("--json",), "plan.md not found"),
(("--json", "--require-tasks"), "tasks.md not found"),
],
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
)
def test_python_negative_errors_are_stderr_only(
tmp_path: Path, args: tuple[str, ...], expected: str
) -> None:
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
(repo / ".specify").mkdir()
_install_scripts(repo)
if expected in {"plan.md not found", "tasks.md not found"}:
feat = repo / "specs" / "001-my-feature"
feat.mkdir(parents=True)
_write_feature_json(repo)
if expected == "tasks.md not found":
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
py = _run(_py_cmd(repo, *args), repo)
assert py.returncode != 0
assert expected in py.stderr
assert expected not in py.stdout
assert py.stdout.strip() == ""
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
_write_feature_json(prereq_repo)
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
assert py.returncode == 0, py.stderr
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
+149
View File
@@ -0,0 +1,149 @@
"""Tests for check_tool() — Claude Code CLI detection across install methods.
Covers issue https://github.com/github/spec-kit/issues/550:
`specify check` reports "Claude Code CLI (not found)" even when claude is
installed via npm-local (the default `claude` installer path).
"""
from unittest.mock import patch, MagicMock
from typer.testing import CliRunner
from specify_cli import app, check_tool
from tests.conftest import strip_ansi
runner = CliRunner()
class TestCheckToolClaude:
"""Claude CLI detection must work for all install methods."""
def test_detected_via_migrate_installer_path(self, tmp_path):
"""claude migrate-installer puts binary at ~/.claude/local/claude."""
fake_claude = tmp_path / "claude"
fake_claude.touch()
# Ensure npm-local path is missing so we only exercise migrate-installer path
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value=None):
assert check_tool("claude") is True
def test_detected_via_npm_local_path(self, tmp_path):
"""npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude."""
fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
fake_npm_claude.parent.mkdir(parents=True)
fake_npm_claude.touch()
# Neither the migrate-installer path nor PATH has claude
fake_migrate = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_migrate), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("shutil.which", return_value=None):
assert check_tool("claude") is True
def test_detected_via_path(self, tmp_path):
"""claude on PATH (global npm install) should still work."""
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value="/usr/local/bin/claude"):
assert check_tool("claude") is True
def test_not_found_when_nowhere(self, tmp_path):
"""Should return False when claude is genuinely not installed."""
fake_missing = tmp_path / "nonexistent" / "claude"
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
patch("shutil.which", return_value=None):
assert check_tool("claude") is False
def test_tracker_updated_on_npm_local_detection(self, tmp_path):
"""StepTracker should be marked 'available' for npm-local installs."""
fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
fake_npm_claude.parent.mkdir(parents=True)
fake_npm_claude.touch()
fake_missing = tmp_path / "nonexistent" / "claude"
tracker = MagicMock()
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
patch("shutil.which", return_value=None):
result = check_tool("claude", tracker=tracker)
assert result is True
tracker.complete.assert_called_once_with("claude", "available")
class TestCheckToolOther:
"""Non-Claude tools should be unaffected by the fix."""
def test_git_detected_via_path(self):
with patch("shutil.which", return_value="/usr/bin/git"):
assert check_tool("git") is True
def test_missing_tool(self):
with patch("shutil.which", return_value=None):
assert check_tool("nonexistent-tool") is False
def test_kiro_fallback(self):
"""kiro-cli detection should try both kiro-cli and kiro."""
def fake_which(name):
return "/usr/bin/kiro" if name == "kiro" else None
with patch("shutil.which", side_effect=fake_which):
assert check_tool("kiro-cli") is True
def test_rovodev_uses_acli_executable(self):
"""rovodev should resolve through the shared acli executable."""
def fake_which(name):
return "/usr/bin/acli" if name == "acli" else None
with patch("shutil.which", side_effect=fake_which):
assert check_tool("rovodev") is True
class TestCheckTip:
"""`specify check` should point users to the existing version check."""
def test_check_shows_self_check_tip(self):
with patch("specify_cli.check_tool", return_value=True):
result = runner.invoke(app, ["check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert (
"Tip: Run 'specify self check' to verify you have the latest CLI version"
in output
)
def test_check_tip_does_not_fetch_latest_release(self):
with (
patch("specify_cli.check_tool", return_value=True),
patch(
"specify_cli._version._fetch_latest_release_tag",
side_effect=AssertionError("latest release lookup should not run"),
) as fetch_latest,
):
result = runner.invoke(app, ["check"])
assert result.exit_code == 0
fetch_latest.assert_not_called()
+79
View File
@@ -0,0 +1,79 @@
"""Tests for CLI version reporting."""
import json
from unittest.mock import patch
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
class TestVersionFlag:
"""Test --version / -V flag on the root command."""
def test_version_long_flag(self):
"""specify --version prints version and exits 0."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert "specify 1.2.3" in result.output
def test_version_short_flag(self):
"""specify -V prints version and exits 0."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
result = runner.invoke(app, ["-V"])
assert result.exit_code == 0
assert "specify 1.2.3" in result.output
def test_version_flag_takes_precedence_over_subcommand(self):
"""--version should work even when a subcommand follows."""
with patch("specify_cli.get_speckit_version", return_value="0.7.2"):
result = runner.invoke(app, ["--version", "init"])
assert result.exit_code == 0
assert "specify 0.7.2" in result.output
class TestVersionCommand:
"""Test the `specify version` subcommand."""
def test_version_features_text(self):
"""specify version --features prints local capability flags."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
result = runner.invoke(app, ["version", "--features"])
assert result.exit_code == 0
assert "Spec Kit CLI: 1.2.3" in result.output
assert "Features:" in result.output
assert "- controlled multi install integrations: yes" in result.output
assert "- integration use command: yes" in result.output
assert "- self check command: yes" in result.output
def test_version_features_json(self):
"""specify version --features --json prints machine-readable capabilities."""
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
result = runner.invoke(app, ["version", "--features", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload == {
"version": "1.2.3",
"features": {
"controlled_multi_install_integrations": True,
"integration_use_command": True,
"multi_install_safe_registry_metadata": True,
"integration_upgrade_command": True,
"self_check_command": True,
"workflow_catalog": True,
"bundled_templates": True,
},
}
def test_version_json_requires_features(self):
"""specify version --json is rejected until a JSON surface exists."""
result = runner.invoke(app, ["version", "--json"])
assert result.exit_code != 0
assert "--json requires --features" in result.output
+107
View File
@@ -0,0 +1,107 @@
"""Command templates with a py: script line must render for --script py.
Covers #3283: ``py:`` lines in the ``scripts:`` frontmatter of
``templates/commands/*.md`` reference Python scripts that exist in the repo,
and ``process_template`` turns them into a valid Python invocation
(interpreter-prefixed, path rewritten to the ``.specify`` tree).
``plan.md`` and ``tasks.md`` gain their ``py:`` lines together with
``setup_plan.py``/``setup_tasks.py`` in the core-scripts port (#3280); the
existence check below enforces that ordering.
"""
import re
from pathlib import Path
import pytest
from specify_cli.integrations.base import IntegrationBase
REPO_ROOT = Path(__file__).parent.parent
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
_PY_LINE = re.compile(r"^\s*py: (scripts/python/\S+\.py)", re.MULTILINE)
def _py_script(name: str) -> str | None:
match = _PY_LINE.search((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
return match.group(1) if match else None
PY_TEMPLATES = sorted(
p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name)
)
@pytest.fixture(autouse=True)
def _pin_interpreter(monkeypatch):
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
# On Windows, ``resolve_python_interpreter`` guards the ``which`` result
# with a real ``_interpreter_runs`` subprocess probe (#3304). The mocked
# ``/usr/bin/python3`` path does not exist on a Windows runner, so the
# probe would fail and the resolver would fall back to ``sys.executable``
# (a ``...python.exe`` path), breaking the ``python3``-anchored assertion.
# Pin the probe to True so the interpreter token stays ``python3`` on all
# platforms.
monkeypatch.setattr(
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
staticmethod(lambda path: True),
)
def test_py_templates_discovered():
# Guard: the glob must find the known py-scripted templates, otherwise
# the parametrized tests below would silently pass on an empty set.
assert "implement.md" in PY_TEMPLATES
assert "clarify.md" in PY_TEMPLATES
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_referenced_python_script_exists(name: str):
# A py: line must never point at a script the repo does not ship —
# rendering would produce a broken invocation at runtime.
script = _py_script(name)
assert (REPO_ROOT / script).is_file(), f"{name} references missing {script}"
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_template_renders_python_invocation(name: str):
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
result = IntegrationBase.process_template(content, "agent", "py")
assert "{SCRIPT}" not in result
assert re.search(
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
), f"{name} did not render a Python invocation"
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_sh_rendering_unchanged(name: str):
# Negative: adding py: lines must not leak into sh rendering.
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
result = IntegrationBase.process_template(content, "agent", "sh")
assert "{SCRIPT}" not in result
assert "scripts/python" not in result
def test_install_shared_infra_copies_python_scripts(tmp_path):
# --script py must install scripts/python/ into .specify/scripts/python/
# so the rendered invocations point at files that exist.
from rich.console import Console
from specify_cli.shared_infra import install_shared_infra
install_shared_infra(
tmp_path,
"py",
version="0.0.0",
core_pack=None,
repo_root=REPO_ROOT,
console=Console(quiet=True),
force=False,
)
dest = tmp_path / ".specify" / "scripts" / "python"
assert (dest / "check_prerequisites.py").is_file()
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
+52
View File
@@ -0,0 +1,52 @@
"""Tests for the commands/ package structure."""
import importlib
def test_commands_package_importable():
mod = importlib.import_module("specify_cli.commands")
assert mod is not None
def test_commands_init_importable():
mod = importlib.import_module("specify_cli.commands.init")
assert hasattr(mod, "register")
assert callable(mod.register)
def test_agent_config_importable():
from specify_cli._agent_config import (
AGENT_CONFIG,
DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES,
)
assert isinstance(AGENT_CONFIG, dict)
assert DEFAULT_INIT_INTEGRATION == "copilot"
assert "sh" in SCRIPT_TYPE_CHOICES
def test_script_type_choices_includes_python():
from specify_cli._agent_config import SCRIPT_TYPE_CHOICES
assert SCRIPT_TYPE_CHOICES.get("py") == "Python"
# The three supported variants are sh, ps, and py.
assert {"sh", "ps", "py"} <= set(SCRIPT_TYPE_CHOICES)
def test_workflow_init_valid_script_types_includes_python():
from specify_cli.workflows.steps.init import VALID_SCRIPT_TYPES
assert "py" in VALID_SCRIPT_TYPES
# Negative: an unknown variant is not accepted.
assert "rb" not in VALID_SCRIPT_TYPES
def test_agent_config_re_exported_from_init():
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
assert isinstance(AGENT_CONFIG, dict)
assert "sh" in SCRIPT_TYPE_CHOICES
def test_init_command_registered():
from specify_cli import app
callback_names = [
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
]
assert "init" in callback_names
+41
View File
@@ -0,0 +1,41 @@
"""Regression guard: console symbols must remain importable from specify_cli."""
from specify_cli import (
console,
StepTracker,
select_with_arrows,
)
def test_console_symbols_importable():
from rich.console import Console
assert isinstance(console, Console)
def test_console_symbols_available_from_star_import():
namespace = {}
exec("from specify_cli import *", namespace)
for symbol in (
"console",
"StepTracker",
"get_key",
"select_with_arrows",
"BannerGroup",
"show_banner",
"BANNER",
"TAGLINE",
):
assert symbol in namespace
def test_step_tracker_instantiable():
tracker = StepTracker("test")
tracker.add("step1", "Step One")
tracker.complete("step1", "done")
assert tracker.steps[0]["status"] == "done"
def test_select_with_arrows_raises_on_empty_options():
import pytest
with pytest.raises(ValueError, match="at least one option"):
select_with_arrows({})
+497
View File
@@ -0,0 +1,497 @@
import pytest
import yaml
from specify_cli.extensions import HookExecutor, ExtensionManifest
@pytest.fixture
def project_dir(tmp_path):
"""Create a mock spec-kit project directory."""
proj_dir = tmp_path / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
return proj_dir
class TestExtensionRegistration:
"""Tests for the 'installed' list management in HookExecutor."""
def test_register_extension_new(self, project_dir):
"""Standard registration: Adding an extension should add it to the list."""
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["test-ext"]
def test_register_extension_sorting(self, project_dir):
"""Order Stability: Extensions should be stored in alphabetical order."""
executor = HookExecutor(project_dir)
executor.register_extension("zebra-ext")
executor.register_extension("apple-ext")
executor.register_extension("middle-ext")
config = executor.get_project_config()
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
def test_register_extension_idempotency(self, project_dir):
"""Idempotency: Adding the same extension twice should not result in duplicates."""
executor = HookExecutor(project_dir)
executor.register_extension("test-ext")
executor.register_extension("test-ext")
config = executor.get_project_config()
assert config["installed"] == ["test-ext"]
assert len(config["installed"]) == 1
def test_unregister_extension(self, project_dir):
"""Standard unregistration: Removing an extension should prune it from the list."""
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
executor.register_extension("ext-2")
executor.unregister_extension("ext-1")
config = executor.get_project_config()
assert config["installed"] == ["ext-2"]
def test_unregister_extension_not_present(self, project_dir):
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
executor = HookExecutor(project_dir)
executor.register_extension("ext-1")
# Should not raise or change the list
executor.unregister_extension("ext-nonexistent")
config = executor.get_project_config()
assert config["installed"] == ["ext-1"]
def test_register_hooks_triggers_registration(self, project_dir, tmp_path):
"""Full Workflow: register_hooks should automatically register the extension."""
# Create a mock manifest
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hook-ext",
"name": "Hook Ext",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.hook-ext.run"}
}
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# This should call register_extension internally
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
def test_missing_installed_key_initialization(self, project_dir):
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
executor = HookExecutor(project_dir)
# Manually create a config without 'installed'
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
# This should detect the missing key and initialize it
executor.register_extension("new-ext")
config = executor.get_project_config()
assert "installed" in config
assert config["installed"] == ["new-ext"]
def test_unregister_hooks_full_workflow(self, project_dir, tmp_path):
"""Full Workflow: unregister_hooks should remove hooks and prune installed list."""
# Create a manifest with hooks
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "hook-ext",
"name": "Hook Ext",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.hook-ext.run"}
}
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
# Register hooks first
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "hook-ext" in config["installed"]
assert "after_tasks" in config["hooks"]
# Now unregister hooks
executor.unregister_hooks("hook-ext")
config = executor.get_project_config()
assert "hook-ext" not in config["installed"]
# unregister_hooks() removes the empty hook array entirely, so the key is absent
assert "after_tasks" not in config["hooks"]
def test_unregister_hooks_no_hooks_key(self, project_dir):
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
executor = HookExecutor(project_dir)
# Register extension without hooks
executor.register_extension("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" in config["installed"]
# Unregister should not crash even if no hooks key exists
executor.unregister_hooks("ext-no-hooks")
config = executor.get_project_config()
assert "ext-no-hooks" not in config["installed"]
def test_unregister_hooks_corrupted_config(self, project_dir):
"""Resilience: unregister_hooks should gracefully handle corrupted config."""
# Create a corrupted config (root is a list)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(["corrupted", "list"]))
executor = HookExecutor(project_dir)
# Should not raise even with corrupted config
executor.unregister_hooks("non-existent")
# Config should remain as-is or be handled gracefully
config = executor.get_project_config()
# If it's corrupted, it's returned as-is or handled by defensive logic
assert config is not None
def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path):
"""Multiple Extensions: unregister_hooks should only remove target extension's hooks."""
# Create two manifests
manifest_data_1 = {
"schema_version": "1.0",
"extension": {
"id": "ext-1",
"name": "Ext 1",
"version": "1.0.0",
"description": "Test 1",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.ext-1.run"}
}
}
manifest_data_2 = {
"schema_version": "1.0",
"extension": {
"id": "ext-2",
"name": "Ext 2",
"version": "1.0.0",
"description": "Test 2",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "speckit.ext-2.run"}
}
}
manifest_path_1 = tmp_path / "extension1.yml"
manifest_path_2 = tmp_path / "extension2.yml"
with open(manifest_path_1, "w") as f:
yaml.dump(manifest_data_1, f)
with open(manifest_path_2, "w") as f:
yaml.dump(manifest_data_2, f)
manifest1 = ExtensionManifest(manifest_path_1)
manifest2 = ExtensionManifest(manifest_path_2)
executor = HookExecutor(project_dir)
# Register both extensions
executor.register_hooks(manifest1)
executor.register_hooks(manifest2)
config = executor.get_project_config()
assert "ext-1" in config["installed"]
assert "ext-2" in config["installed"]
assert len(config["hooks"]["after_tasks"]) == 2
# Unregister first extension
executor.unregister_hooks("ext-1")
config = executor.get_project_config()
assert "ext-1" not in config["installed"]
assert "ext-2" in config["installed"]
# ext-2's hook should still be there
assert len(config["hooks"]["after_tasks"]) == 1
assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2"
def test_register_hooks_no_hooks_still_registers(self, project_dir, tmp_path):
"""Commands-only manifest: register_hooks() must still update installed even with no hooks."""
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "commands-only-ext",
"name": "Commands Only",
"version": "1.0.0",
"description": "No hooks, only commands",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": [{"name": "speckit.commands-only-ext.run", "file": "commands/run.md"}]},
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
executor = HookExecutor(project_dir)
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "commands-only-ext" in config["installed"]
def test_register_extension_mixed_type_installed(self, project_dir):
"""Regression: installed list with non-string entries must not crash on sort."""
executor = HookExecutor(project_dir)
# Manually write a corrupted installed list with non-string entries
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"installed": [1, True, "existing-ext"]}))
# Should not raise TypeError on sort
executor.register_extension("new-ext")
config = executor.get_project_config()
# Non-string entries are dropped; valid strings are preserved
assert "existing-ext" in config["installed"]
assert "new-ext" in config["installed"]
assert 1 not in config["installed"]
assert True not in config["installed"]
def test_unregister_hooks_null_hook_values(self, project_dir):
"""Regression: hooks: {after_tasks: null} must not crash in unregister_hooks()."""
executor = HookExecutor(project_dir)
# Manually write a config with null hook event value
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["broken-ext"],
"hooks": {"after_tasks": None}
}))
# Should not raise TypeError when iterating None
executor.unregister_hooks("broken-ext")
config = executor.get_project_config()
assert "broken-ext" not in config["installed"]
def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path):
"""Regression: register_hooks() must handle non-list hook event values in config."""
executor = HookExecutor(project_dir)
# Manually write a config with null hook event value
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": None}
}))
# Create a manifest with a hook for the same event
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "new-ext",
"name": "New Ext",
"version": "1.0.0",
"description": "Test",
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {"after_tasks": {"command": "speckit.new-ext.run"}}
}
manifest_path = tmp_path / "extension.yml"
with open(manifest_path, "w") as f:
yaml.dump(manifest_data, f)
manifest = ExtensionManifest(manifest_path)
# Should not raise TypeError when trying to append to None
executor.register_hooks(manifest)
config = executor.get_project_config()
assert "new-ext" in config["installed"]
assert isinstance(config["hooks"]["after_tasks"], list)
assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"])
def test_register_extension_already_present_in_corrupted_list(self, project_dir):
"""Regression: if extension is already present but list has non-strings, it must still be sanitized."""
executor = HookExecutor(project_dir)
# Extension is present, but list has garbage
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"installed": [1, "test-ext", True]}))
# This should trigger sanitization and save, even though "test-ext" is already there
executor.register_extension("test-ext")
config = executor.get_project_config()
assert config["installed"] == ["test-ext"]
# Verify it was actually saved to disk
raw_config = yaml.safe_load(config_path.read_text())
assert raw_config["installed"] == ["test-ext"]
def test_register_extension_with_dict_entry(self, project_dir):
"""Review Feedback: register_extension should support and preserve dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Setup config with a pinned extension (dict)
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "string-ext"]
}))
# Register a new extension
executor.register_extension("new-ext")
config = executor.get_project_config()
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
def test_unregister_extension_with_dict_entry(self, project_dir):
"""Review Feedback: unregister_extension should support removing matching dict entries."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
config_path.write_text(yaml.dump({
"installed": [pinned_ext, "other-ext"]
}))
# Unregister by ID
executor.unregister_extension("to-remove")
config = executor.get_project_config()
assert config["installed"] == ["other-ext"]
def test_unregister_extension_corrupted_installed(self, project_dir):
"""Hardening: unregister_extension should handle non-list installed key."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": "not-a-list"
}))
# Should not crash and should normalize to []
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert config["installed"] == []
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
"""Regression: register_hooks() must sanitize hook event lists by dropping non-dicts."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": [1, "corrupted", {"extension": "other", "command": "cmd"}]}
}))
manifest_path = tmp_path / "extension.yml"
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "new-ext",
"name": "New Ext",
"version": "1.0.0",
"description": "Test",
"author": "Test author"
},
"requires": {
"speckit_version": ">=0.1.0",
"commands": []
},
"provides": {"commands": []},
"hooks": {
"after_tasks": {"command": "new-cmd"}
}
}
manifest_path.write_text(yaml.dump(manifest_data))
manifest = ExtensionManifest(manifest_path)
executor.register_hooks(manifest)
config = executor.get_project_config()
hooks = config["hooks"]["after_tasks"]
# Should have 2 valid dict hooks, and 0 non-dict items
assert len(hooks) == 2
assert all(isinstance(h, dict) for h in hooks)
assert any(h.get("extension") == "other" for h in hooks)
assert any(h.get("extension") == "new-ext" for h in hooks)
def test_unregister_extension_scalar_root(self, project_dir):
"""Hardening: unregister_extension should handle scalar root config."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Should not crash and should normalize to {}
executor.unregister_extension("any-ext")
config = executor.get_project_config()
assert isinstance(config, dict)
assert config["installed"] == []
def test_unregister_hooks_scalar_hook_values(self, project_dir):
"""Regression: unregister_hooks() must handle scalar hook event values."""
executor = HookExecutor(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["some-ext"],
"hooks": {"after_tasks": 123}
}))
# Should not raise TypeError when iterating
executor.unregister_hooks("some-ext")
config = executor.get_project_config()
assert "some-ext" not in config["installed"]
assert "after_tasks" not in config["hooks"]
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog
import pytest
import yaml
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
@pytest.fixture
def project_dir(tmp_path):
"""Create a mock spec-kit project directory."""
proj_dir = tmp_path / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
# Create required files for a project
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
return proj_dir
def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
# chdir into project_dir so _require_specify_project() succeeds
monkeypatch.chdir(project_dir)
# Corrupt extensions.yml
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump(123))
# Mock ExtensionManager to return an installed extension for resolution
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
# Mock confirmation to true
monkeypatch.setattr("typer.confirm", lambda _: True)
# Run update
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
assert not isinstance(result.exception, AttributeError)
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({
"installed": ["test-ext"],
"hooks": ["not", "a", "dict"]
}))
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Use tmp_path-scoped zip so the test is hermetic and returns a Path for zip_path.exists()
mock_zip = project_dir / "mock.zip"
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# extension_update() catches exceptions internally and exits with code 1 on failure.
assert result.exit_code == 1
assert "AttributeError" not in result.output
assert not isinstance(result.exception, AttributeError)
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
"""Regression: extension update rollback must handle corrupted extensions.yml."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
# Write config with hooks: null; get_project_config() normalizes this to {}
# so the backup captures {} and the restored config will have hooks: {}.
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
# Mock update process to fail after backup
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
# Force failure in download_extension to trigger rollback
def mock_download_fail(*args, **kwargs):
# Corrupt the config BEFORE rollback is triggered
config_path.write_text(yaml.dump("CORRUPTED"))
raise Exception("Download failed")
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
# Should handle Exception and NOT crash with AttributeError during rollback
assert result.exit_code == 1
assert "Download failed" in result.output
assert not isinstance(result.exception, AttributeError)
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
restored_config = yaml.safe_load(config_path.read_text())
assert isinstance(restored_config, dict)
assert "hooks" in restored_config
assert restored_config["hooks"] == {}
def test_extension_update_skills_backup_no_collision(project_dir, monkeypatch):
"""Regression: skills agents name every command file SKILL.md (one per
command subdirectory). Backup must keep the per-command path so rollback
restores each skill's own content instead of overwriting them onto a
single backup path."""
monkeypatch.chdir(project_dir)
config_path = project_dir / ".specify" / "extensions.yml"
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": {}}))
# Two skill command files with DISTINCT content, mirroring the claude
# skills layout (.claude/skills/<name>/SKILL.md).
skills_root = project_dir / ".claude" / "skills"
plan_file = skills_root / "speckit-plan" / "SKILL.md"
tasks_file = skills_root / "speckit-tasks" / "SKILL.md"
plan_file.parent.mkdir(parents=True)
tasks_file.parent.mkdir(parents=True)
plan_file.write_text("PLAN CONTENT")
tasks_file.write_text("TASKS CONTENT")
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {
"version": "1.0.0",
"enabled": True,
"registered_commands": {"claude": ["speckit.plan", "speckit.tasks"]},
})
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
# Fail at download (step 5, after the command backup in step 3). Delete the
# originals first to simulate an install clobbering them, forcing rollback
# to rely entirely on the backups.
def mock_download_fail(self, ext_id):
plan_file.unlink()
tasks_file.unlink()
raise Exception("Download failed")
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
monkeypatch.setattr("typer.confirm", lambda _: True)
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
assert result.exit_code == 1
# Rollback must restore EACH skill's own content, not a single collided copy.
assert plan_file.exists() and tasks_file.exists()
assert plan_file.read_text() == "PLAN CONTENT"
assert tasks_file.read_text() == "TASKS CONTENT"
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
"""Tests for GitHub-authenticated HTTP request helpers."""
import json
import os
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from specify_cli._github_http import (
build_github_request,
resolve_github_release_asset_api_url,
)
class TestBuildGitHubRequest:
"""Tests for build_github_request() URL validation and auth handling."""
# --- URL Validation Tests ---
def test_empty_url_raises_value_error(self):
"""build_github_request() must reject an empty string URL."""
with pytest.raises(ValueError, match="url must not be empty"):
build_github_request("")
def test_whitespace_url_raises_value_error(self):
"""build_github_request() must reject a whitespace-only URL."""
with pytest.raises(ValueError, match="url must not be empty"):
build_github_request(" ")
def test_non_http_url_raises_value_error(self):
"""build_github_request() must reject URLs without http/https scheme."""
with pytest.raises(ValueError, match="url must start with http"):
build_github_request("not-a-url")
def test_ftp_url_raises_value_error(self):
"""build_github_request() must reject ftp:// URLs."""
with pytest.raises(ValueError, match="url must start with http"):
build_github_request("ftp://github.com/file.zip")
# --- Valid URL Tests ---
def test_valid_https_url_returns_request(self):
"""build_github_request() must return a Request for a valid https URL."""
req = build_github_request("https://github.com/github/spec-kit")
assert req.full_url == "https://github.com/github/spec-kit"
def test_valid_http_url_returns_request(self):
"""build_github_request() must accept http:// URLs."""
req = build_github_request("http://example.com/file")
assert req.full_url == "http://example.com/file"
# --- Auth Header Tests ---
def test_github_token_added_for_github_host(self):
"""Authorization header is set when GITHUB_TOKEN is present."""
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}):
req = build_github_request("https://github.com/github/spec-kit")
assert req.get_header("Authorization") == "Bearer test-token"
def test_gh_token_used_as_fallback(self):
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}):
req = build_github_request("https://github.com/github/spec-kit")
assert req.get_header("Authorization") == "Bearer fallback-token"
def test_no_auth_header_for_non_github_host(self):
"""Authorization header must NOT be set for non-GitHub URLs."""
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}):
req = build_github_request("https://example.com/file")
assert req.get_header("Authorization") is None
def test_no_auth_header_when_no_token(self):
"""No Authorization header when no token is set in environment."""
with patch.dict(os.environ, {}, clear=True):
req = build_github_request("https://github.com/github/spec-kit")
assert req.get_header("Authorization") is None
def test_missing_hostname_raises_value_error(self):
"""build_github_request() must reject URLs with valid scheme but no hostname."""
with pytest.raises(ValueError, match="url must include a hostname"):
build_github_request("http://")
class TestResolveGitHubReleaseAssetApiUrl:
"""Tests for resolve_github_release_asset_api_url()."""
def _make_open_url_fn(self, release_json):
"""Create a fake open_url_fn that returns release JSON."""
@contextmanager
def fake_open(url, timeout=None, extra_headers=None):
resp = MagicMock()
resp.read.return_value = json.dumps(release_json).encode()
yield resp
return fake_open
def test_returns_none_for_non_github_url(self):
"""Non-GitHub URLs should return None."""
result = resolve_github_release_asset_api_url(
"https://example.com/file.zip", lambda *a, **kw: None
)
assert result is None
def test_returns_none_for_non_release_github_url(self):
"""GitHub URLs that aren't release downloads return None."""
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/archive/refs/tags/v1.zip",
lambda *a, **kw: None,
)
assert result is None
def test_passthrough_for_existing_api_asset_url(self):
"""Already-resolved REST API asset URLs are returned as-is."""
url = "https://api.github.com/repos/org/repo/releases/assets/12345"
result = resolve_github_release_asset_api_url(url, lambda *a, **kw: None)
assert result == url
def test_resolves_browser_url_to_api_url(self):
"""Browser release URL resolves to REST API asset URL."""
release_json = {
"assets": [
{"name": "pack.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/99"}
]
}
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
self._make_open_url_fn(release_json),
)
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
def test_returns_none_when_asset_not_found(self):
"""Returns None when the release exists but asset name doesn't match."""
release_json = {"assets": [{"name": "other.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/1"}]}
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1/missing.zip",
self._make_open_url_fn(release_json),
)
assert result is None
def test_returns_none_on_network_error(self):
"""Returns None when the API request fails."""
import urllib.error
@contextmanager
def failing_open(url, timeout=None, extra_headers=None):
raise urllib.error.URLError("network error")
yield # noqa: unreachable
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1/pack.zip",
failing_open,
)
assert result is None
def test_tag_with_special_characters_is_url_encoded(self):
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
captured_urls = []
@contextmanager
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
yield resp
resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/feature%2Fv1/pack.zip",
capturing_open,
)
# The tag "feature/v1" (decoded from %2F) must be re-encoded as "feature%2Fv1"
assert len(captured_urls) == 1
assert "releases/tags/feature%2Fv1" in captured_urls[0]
def test_tag_with_hash_is_url_encoded(self):
"""Tags with '#' character are properly encoded."""
captured_urls = []
@contextmanager
def capturing_open(url, timeout=None, extra_headers=None):
captured_urls.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
yield resp
resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1%23beta/pack.zip",
capturing_open,
)
assert len(captured_urls) == 1
assert "releases/tags/v1%23beta" in captured_urls[0]
# --- GHES (GitHub Enterprise Server) ---
def test_resolves_ghes_browser_url_to_api_url(self):
"""A GHES browser release URL resolves to the /api/v3 asset URL."""
release_json = {
"assets": [
{"name": "ext.zip",
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}
]
}
result = resolve_github_release_asset_api_url(
"https://ghes.example/o/r/releases/download/v1/ext.zip",
self._make_open_url_fn(release_json),
github_hosts=("ghes.example",),
)
assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
def test_passthrough_for_existing_ghes_api_asset_url(self):
"""An already-resolved GHES /api/v3 asset URL is returned as-is."""
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
result = resolve_github_release_asset_api_url(
url, lambda *a, **kw: None, github_hosts=("ghes.example",)
)
assert result == url
def test_returns_none_for_ghes_host_not_in_allowlist(self):
"""Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF)."""
called = []
@contextmanager
def recording_open(url, timeout=None, extra_headers=None):
called.append(url)
resp = MagicMock()
resp.read.return_value = b"{}"
yield resp
result = resolve_github_release_asset_api_url(
"https://ghes.example/o/r/releases/download/v1/ext.zip",
recording_open,
github_hosts=("other.example",),
)
assert result is None
assert called == []
def test_returns_none_on_malformed_ghes_port(self):
"""A malformed port on an allowlisted GHES host returns None, not a
ValueError (contract: resolve or return None, never raise)."""
called = []
def open_never(url, timeout=None, extra_headers=None):
called.append(url)
raise AssertionError("open_url_fn must not be called")
result = resolve_github_release_asset_api_url(
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
open_never,
github_hosts=("ghes.example",),
)
assert result is None
assert called == []
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
"""A direct GHES /api/v3 asset URL passes through even when the host is
not allowlisted: passthrough issues no API request, and the download
helper gates the token independently, so octet-stream resolution must
not be withheld."""
called = []
@contextmanager
def recording_open(url, timeout=None, extra_headers=None):
called.append(url)
resp = MagicMock()
resp.read.return_value = b"{}"
yield resp
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
result = resolve_github_release_asset_api_url(
url, recording_open, github_hosts=("other.example",)
)
assert result == url
assert called == []
def test_ghes_api_base_preserves_scheme_and_port(self):
"""The GHES API base mirrors the URL scheme and keeps a non-standard port."""
captured = []
@contextmanager
def capturing_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({"assets": []}).encode()
yield resp
resolve_github_release_asset_api_url(
"http://localhost:8000/o/r/releases/download/v1/ext.zip",
capturing_open,
github_hosts=("localhost",),
)
assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"]
def test_ghes_wildcard_does_not_match_bare_host(self):
"""A '*.suffix' pattern does not match the bare host (must list it explicitly)."""
result = resolve_github_release_asset_api_url(
"https://ghes.example/o/r/releases/download/v1/ext.zip",
lambda *a, **kw: None,
github_hosts=("*.ghes.example",),
)
assert result is None
def test_public_github_url_unaffected_by_github_hosts(self):
"""Public github.com still resolves via api.github.com even with github_hosts set."""
captured = []
@contextmanager
def capturing_open(url, timeout=None, extra_headers=None):
captured.append(url)
resp = MagicMock()
resp.read.return_value = json.dumps({
"assets": [{"name": "pack.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
}).encode()
yield resp
result = resolve_github_release_asset_api_url(
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
capturing_open,
github_hosts=("ghes.example",),
)
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
+41
View File
@@ -0,0 +1,41 @@
"""Static checks for repository GitHub Actions workflows."""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
# Match both the dedicated-step form (` uses: x@sha`) and the
# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml.
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<ref>\S+)", re.MULTILINE)
PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE)
def test_github_actions_are_pinned_to_full_commit_shas():
unpinned_refs = []
workflows = sorted(
list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml"))
)
assert workflows
for workflow in workflows:
workflow_text = workflow.read_text(encoding="utf-8")
for match in USES_RE.finditer(workflow_text):
uses_ref = match.group("ref")
if uses_ref.startswith(("./", "../")):
continue
if PINNED_SHA_RE.search(uses_ref):
continue
unpinned_refs.append(f"{workflow.relative_to(REPO_ROOT)}: {uses_ref}")
assert unpinned_refs == []
def test_pinned_action_ref_accepts_uppercase_hex_sha():
assert PINNED_SHA_RE.search(
"actions/example@0123456789ABCDEF0123456789ABCDEF01234567"
)
+467
View File
@@ -0,0 +1,467 @@
"""Tests for the SPECIFY_INIT_DIR project-root override.
SPECIFY_INIT_DIR lets a non-interactive / CI caller target a member project from
outside its directory (e.g. a monorepo root) without `cd`. It names the project
root — the directory *containing* `.specify/` — and is strict: it must exist and
contain `.specify/`, otherwise the resolver hard-errors with no silent fallback to
cwd or the git toplevel.
See proposals/monorepo-support and github/spec-kit discussion #2834.
"""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
GIT_CREATE_FEATURE_SH = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
)
HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL
def _clean_env() -> dict[str, str]:
"""Inherited env minus all SPECIFY_* vars, so a developer/CI override
(SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, …) cannot leak into the
subprocess and make these resolution tests flaky."""
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _make_project(root: Path, name: str) -> Path:
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
proj = root / name
(proj / ".specify").mkdir(parents=True)
return proj
def _bash(func_call: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
"""Source the real common.sh and run a function, from a given cwd/env."""
return subprocess.run(
["bash", "-c", f'source "{COMMON_SH}" && {func_call}'],
cwd=cwd,
capture_output=True,
text=True,
check=False,
env=env,
)
def _ps(script: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
"""Dot-source the real common.ps1 and run PowerShell, from a given cwd/env."""
return subprocess.run(
[_PS_EXE, "-NoProfile", "-Command", f'. "{COMMON_PS}"; {script}'],
cwd=cwd,
capture_output=True,
text=True,
check=False,
env=env,
)
def _feature_dir_line(stdout: str) -> str | None:
for line in stdout.splitlines():
if line.startswith("FEATURE_DIR="):
return line.split("=", 1)[1].strip("'\"")
return None
def _bash_path(path: Path) -> str:
"""Return the path format emitted by Bash `pwd`.
Git-for-Windows Bash reports absolute paths as /c/... while pathlib reports
them as C:\\..., so Bash stdout comparisons need an expected value in Bash's
own path shape.
"""
if os.name != "nt":
return str(path)
resolved = path.resolve()
path_str = str(resolved).replace("\\", "/")
if resolved.drive.endswith(":"):
return f"/{resolved.drive[0].lower()}{path_str[len(resolved.drive):]}"
return path_str
requires_pwsh = pytest.mark.skipif(
not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available"
)
# ── Bash: positive cases ────────────────────────────────────────────────────
@requires_bash
def test_valid_path_resolves_from_outside(tmp_path: Path) -> None:
"""P1: a valid project path resolves correctly when run from elsewhere."""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _bash("get_repo_root", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
"""P2: a relative SPECIFY_INIT_DIR is resolved against the current directory."""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
result = _bash("get_repo_root", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_trailing_slash_tolerated(tmp_path: Path) -> None:
"""P3: a trailing slash is collapsed by normalization."""
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
result = _bash("get_repo_root", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_precedence_over_cwd_project(tmp_path: Path) -> None:
"""P4: feature resolution happens inside the *target* project, not cwd.
cwd is itself a valid Spec Kit project; SPECIFY_INIT_DIR must redirect
resolution to the target project, so a relative SPECIFY_FEATURE_DIRECTORY
normalizes under the target root, not cwd.
"""
cwd_proj = _make_project(tmp_path, "cwd_proj")
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
}
result = _bash("get_feature_paths", cwd=cwd_proj, env=env)
assert result.returncode == 0, result.stderr
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "001-demo")
assert _bash_path(cwd_proj) not in result.stdout
@requires_bash
def test_composes_with_feature_directory_override(tmp_path: Path) -> None:
"""P5: SPECIFY_INIT_DIR (project axis) composes with SPECIFY_FEATURE_DIRECTORY
(feature axis); a relative feature dir normalizes under the *target* root."""
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
}
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "003-x")
@requires_bash
def test_composes_with_target_feature_json(tmp_path: Path) -> None:
"""P6: the target project's .specify/feature.json is honored."""
web = _make_project(tmp_path, "web")
(web / ".specify" / "feature.json").write_text(
'{"feature_directory": "specs/004-fj"}'
)
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "004-fj")
# ── Bash: negative / contract cases ─────────────────────────────────────────
@requires_bash
def test_unset_preserves_cwd_walk(tmp_path: Path) -> None:
"""N1: with SPECIFY_INIT_DIR unset, resolution walks up from cwd as before."""
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
result = _bash("get_repo_root", cwd=sub, env=_clean_env())
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_empty_string_treated_as_unset(tmp_path: Path) -> None:
"""N2: an empty SPECIFY_INIT_DIR behaves as unset (not as ".").
Run from a deep subdirectory so the two interpretations diverge:
empty-as-unset walks up to the project root; empty-as-"." would resolve to
the cwd (which has no .specify/) and error. Asserting the walk-up result
genuinely guards against a regression to "." semantics.
"""
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
result = _bash("get_repo_root", cwd=sub, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == _bash_path(web)
@requires_bash
def test_invalid_init_dir_fails_feature_paths_chain(tmp_path: Path) -> None:
"""N5: an invalid SPECIFY_INIT_DIR hard-fails the load-bearing call site
(get_feature_paths), not just get_repo_root — this is what the decl/assign
split guards against (a `local x=$(get_repo_root)` would mask the failure
and emit a FEATURE_DIR under the wrong root). SPECIFY_FEATURE_DIRECTORY is
set so a feature dir *is* resolvable — only the propagation stops a
wrong-root FEATURE_DIR, so a revert to the masked form fails this test."""
web = _make_project(tmp_path, "web") # valid project at cwd
missing = tmp_path / "does_not_exist"
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(missing),
"SPECIFY_FEATURE_DIRECTORY": "specs/001-x",
}
result = _bash("get_feature_paths", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert "FEATURE_DIR=" not in result.stdout
@requires_bash
def test_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
"""N3: a non-existent path hard-errors — even from inside a valid project,
proving there is no silent fallback to the cwd walk-up or git root."""
web = _make_project(tmp_path, "web") # valid project at cwd
missing = tmp_path / "does_not_exist"
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
result = _bash("get_repo_root", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert _bash_path(web) not in result.stdout
@requires_bash
def test_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
"""N4: a path that exists but lacks .specify/ hard-errors, no fallback."""
web = _make_project(tmp_path, "web") # valid project at cwd
nodot = tmp_path / "nodot"
nodot.mkdir()
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
result = _bash("get_repo_root", cwd=web, env=env)
assert result.returncode != 0
assert "not a Spec Kit project" in result.stderr
assert _bash_path(web) not in result.stdout
@requires_bash
def test_file_path_errors_no_fallback(tmp_path: Path) -> None:
"""N4b: a path that exists but is a file (not a directory) hard-errors with
the existing-directory message, with no fallback."""
web = _make_project(tmp_path, "web") # valid project at cwd
a_file = tmp_path / "afile"
a_file.write_text("x")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
result = _bash("get_repo_root", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert _bash_path(web) not in result.stdout
# ── Bash: bundled Git extension entrypoint ──────────────────────────────────
def _bash_git_create(
args: list[str], cwd: Path, env: dict[str, str]
) -> subprocess.CompletedProcess:
"""Run the bundled git extension's create-new-feature-branch.sh (the real
/speckit.specify before_specify entrypoint)."""
return subprocess.run(
["bash", str(GIT_CREATE_FEATURE_SH), *args],
cwd=cwd,
capture_output=True,
text=True,
check=False,
env=env,
)
def _json_line(stdout: str) -> dict | None:
for line in stdout.splitlines():
line = line.strip()
if line.startswith("{"):
return json.loads(line)
return None
@requires_bash
def test_git_ext_create_feature_numbers_from_target(tmp_path: Path) -> None:
"""P8: the git extension's feature creation numbers from the SPECIFY_INIT_DIR
project, not the cwd project."""
(tmp_path / "specs" / "008-cwd").mkdir(parents=True) # cwd project's specs
web = _make_project(tmp_path, "web")
(web / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
(web / ".specify" / "templates" / "spec-template.md").write_text("# Spec: [FEATURE]\n")
(web / "specs" / "005-existing").mkdir(parents=True)
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _bash_git_create(["--json", "next thing"], cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
data = _json_line(result.stdout)
assert data is not None and data["FEATURE_NUM"] == "006" # 005 in web → 006, not 009
@requires_bash
def test_git_ext_create_feature_invalid_init_dir_errors(tmp_path: Path) -> None:
"""N7: the git extension hard-errors on an invalid SPECIFY_INIT_DIR with no
fallback to the cwd/git-toplevel project."""
web = _make_project(tmp_path, "web") # valid project at cwd
(web / "specs" / "001-cwd").mkdir(parents=True)
missing = tmp_path / "does_not_exist"
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
result = _bash_git_create(["--json", "x"], cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
assert _json_line(result.stdout) is None
# ── PowerShell mirror (skipped only when no PowerShell is installed; the CI
# ubuntu/windows runners ship pwsh, so these DO run there) ─────────────────
@requires_pwsh
def test_ps_valid_path_resolves_from_outside(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_trailing_slash_tolerated(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_unset_preserves_cwd_walk(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
result = _ps("Get-RepoRoot", cwd=sub, env=_clean_env())
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_precedence_over_cwd_project(tmp_path: Path) -> None:
cwd_proj = _make_project(tmp_path, "cwd_proj")
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
}
result = _ps(
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
cwd=cwd_proj,
env=env,
)
assert result.returncode == 0, result.stderr
# PowerShell Join-Path keeps the embedded "/" of the relative feature dir
# while pathlib uses the platform separator; compare separator-insensitively
# so the Windows CI runner (where pwsh runs) matches.
feature_dir = _feature_dir_line(result.stdout)
assert feature_dir is not None, result.stdout
assert feature_dir.replace("\\", "/") == (web / "specs" / "001-demo").as_posix()
assert str(cwd_proj) not in result.stdout
@requires_pwsh
def test_ps_composes_with_feature_directory_override(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
env = {
**_clean_env(),
"SPECIFY_INIT_DIR": str(web),
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
}
result = _ps(
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
cwd=tmp_path,
env=env,
)
assert result.returncode == 0, result.stderr
# Separator-insensitive: PowerShell Join-Path keeps the embedded "/".
feature_dir = _feature_dir_line(result.stdout)
assert feature_dir is not None, result.stdout
assert feature_dir.replace("\\", "/") == (web / "specs" / "003-x").as_posix()
@requires_pwsh
def test_ps_empty_string_treated_as_unset(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
sub = web / "src" / "deep"
sub.mkdir(parents=True)
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
result = _ps("Get-RepoRoot", cwd=sub, env=env)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(web)
@requires_pwsh
def test_ps_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
missing = tmp_path / "does_not_exist"
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
@requires_pwsh
def test_ps_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
web = _make_project(tmp_path, "web")
nodot = tmp_path / "nodot"
nodot.mkdir()
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "not a Spec Kit project" in result.stderr
@requires_pwsh
def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None:
"""A file path resolves via Resolve-Path but is not a directory; the resolver
must reject it with the existing-directory message, not not-a-project."""
web = _make_project(tmp_path, "web")
a_file = tmp_path / "afile"
a_file.write_text("x")
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
result = _ps("Get-RepoRoot", cwd=web, env=env)
assert result.returncode != 0
assert "does not point to an existing directory" in result.stderr
+294
View File
@@ -0,0 +1,294 @@
"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`).
PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor
SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project
from a monorepo root. This extends the same validation rules to the Python CLI's
project resolution — `_require_specify_project()` (the chokepoint for every
project-scoped subcommand) and the `workflow run <file>` standalone-YAML path —
so those can target a member project without `cd` too.
The contract mirrors `tests/test_init_dir.py` (the shell side): the value names
the project root (the directory *containing* `.specify/`), relative paths
resolve against cwd, and an invalid value hard-errors with no silent fallback to
cwd. See proposals/monorepo-support and github/spec-kit discussion #2834.
SPECIFY_* vars are stripped from the environment for every test by the autouse
`_strip_specify_env` fixture in conftest.py; tests that want an override set it
explicitly via monkeypatch.
"""
import pytest
import yaml
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
def _make_project(root, name):
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
proj = root / name
(proj / ".specify").mkdir(parents=True)
return proj
def _workflow_yaml(wf_id):
"""A minimal valid standalone workflow YAML with a single no-op shell step."""
return yaml.dump(
{
"schema_version": "1.0",
"workflow": {
"id": wf_id,
"name": wf_id,
"version": "1.0.0",
"description": f"standalone workflow {wf_id}",
},
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
}
)
# ── chokepoint: _require_specify_project() via `workflow list` ───────────────
# `workflow list` is the lightest subcommand routed through the chokepoint: it
# resolves the project, then reads <project>/.specify/workflows/. An empty
# project prints "No workflows installed"; a failed resolution prints the error
# and exits non-zero.
def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch):
"""A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a
project — without the override this would error 'Not a Spec Kit project'."""
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
web = _make_project(tmp_path, "web")
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code == 0, result.output
assert "No workflows installed" in result.output
def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch):
web = _make_project(tmp_path, "web")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("SPECIFY_INIT_DIR", "web")
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code == 0, result.output
assert "No workflows installed" in result.output
assert web.exists()
def test_override_trailing_slash_tolerated(tmp_path, monkeypatch):
_make_project(tmp_path, "web")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("SPECIFY_INIT_DIR", "web/")
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code == 0, result.output
assert "No workflows installed" in result.output
def test_override_redirects_bundle_commands(tmp_path, monkeypatch):
web = _make_project(tmp_path, "web")
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
result = runner.invoke(app, ["bundle", "list"])
assert result.exit_code == 0, result.output
assert "No bundles installed" in result.output
def test_unset_override_uses_cwd(tmp_path, monkeypatch):
"""With SPECIFY_INIT_DIR unset, the project is the current directory."""
cwd_proj = _make_project(tmp_path, "cwd")
monkeypatch.chdir(cwd_proj)
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code == 0, result.output
assert "No workflows installed" in result.output
def test_empty_override_treated_as_unset(tmp_path, monkeypatch):
"""An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as
'.' — which from a deep non-project cwd would otherwise diverge."""
cwd_proj = _make_project(tmp_path, "cwd")
monkeypatch.chdir(cwd_proj)
monkeypatch.setenv("SPECIFY_INIT_DIR", "")
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code == 0, result.output
assert "No workflows installed" in result.output
def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch):
"""A non-existent path hard-errors even from inside a valid project, proving
there is no silent fallback to the cwd project."""
cwd_proj = _make_project(tmp_path, "cwd")
monkeypatch.chdir(cwd_proj)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code != 0
assert "does not point to an existing directory" in result.output
assert "No workflows installed" not in result.output # no fallback to cwd
def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch):
"""Bundle commands also honor the strict override contract."""
cwd_proj = _make_project(tmp_path, "cwd")
monkeypatch.chdir(cwd_proj)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
result = runner.invoke(app, ["bundle", "list"])
assert result.exit_code != 0
assert "does not point to an existing directory" in result.output
assert "No bundles installed" not in result.output
def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch):
"""Invalid override errors must not contaminate JSON stdout."""
cwd_proj = _make_project(tmp_path, "cwd")
monkeypatch.chdir(cwd_proj)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
result = runner.invoke(app, ["bundle", "list", "--json"])
assert result.exit_code != 0
assert result.stdout == ""
assert "does not point to an existing directory" in result.stderr
def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch):
"""A symlinked override .specify must not make bundle init fall back to cwd."""
web = tmp_path / "web"
web.mkdir()
real = tmp_path / "real-specify"
real.mkdir()
try:
(web / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Symlinks are not available in this environment")
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
result = runner.invoke(app, ["bundle", "init", "--offline"])
assert result.exit_code != 0
assert "symlinked .specify" in result.output
assert not (elsewhere / ".specify").exists()
def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch):
"""A path that exists but lacks .specify/ hard-errors, no fallback."""
cwd_proj = _make_project(tmp_path, "cwd")
nodot = tmp_path / "nodot"
nodot.mkdir()
monkeypatch.chdir(cwd_proj)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot))
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code != 0
assert "not a Spec Kit project" in result.output
assert "No workflows installed" not in result.output
def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch):
"""A path that is a file (not a directory) hard-errors with the
existing-directory message."""
cwd_proj = _make_project(tmp_path, "cwd")
a_file = tmp_path / "afile"
a_file.write_text("x")
monkeypatch.chdir(cwd_proj)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file))
result = runner.invoke(app, ["workflow", "list"])
assert result.exit_code != 0
assert "does not point to an existing directory" in result.output
# ── bypass: `workflow run <file>` ────────────────────────────────────────────
def test_override_redirects_workflow_run_file(tmp_path, monkeypatch):
"""Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the
project root: run artifacts land under the target, not cwd."""
web = _make_project(tmp_path, "web")
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
workflow_file = elsewhere / "wf.yml"
workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8")
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert (web / ".specify" / "workflows" / "runs").is_dir()
assert not (elsewhere / ".specify").exists() # cwd was not used as the project
def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch):
"""An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to
cwd's standalone-YAML behavior."""
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
workflow_file = elsewhere / "wf.yml"
workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8")
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
assert result.exit_code != 0
assert "does not point to an existing directory" in result.output
def test_override_rejects_symlinked_specify(tmp_path, monkeypatch):
"""`workflow run <file>` refuses a symlinked .specify under the override
target, matching the guard the cwd path applies (the override resolver's
is_dir() check follows symlinks, so this is re-checked on the override path)."""
web = tmp_path / "web"
web.mkdir()
real = tmp_path / "real-specify"
real.mkdir()
try:
(web / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Symlinks are not available in this environment")
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
workflow_file = elsewhere / "wf.yml"
workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8")
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
assert result.exit_code != 0
assert "Refusing to use symlinked .specify path" in result.output
def test_override_rejects_symlinked_specify_json_error_stays_off_stdout(tmp_path, monkeypatch):
"""`workflow run --json <file>` must keep this hard error off stdout."""
web = tmp_path / "web"
web.mkdir()
real = tmp_path / "real-specify"
real.mkdir()
try:
(web / ".specify").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Symlinks are not available in this environment")
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
workflow_file = elsewhere / "wf.yml"
workflow_file.write_text(_workflow_yaml("symlink-json-run"), encoding="utf-8")
monkeypatch.chdir(elsewhere)
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
result = runner.invoke(app, ["workflow", "run", str(workflow_file), "--json"])
assert result.exit_code != 0
assert result.stdout == ""
assert "Refusing to use symlinked .specify path" in result.stderr
+90
View File
@@ -0,0 +1,90 @@
"""Tests for Rich Live transient=False on Windows (GitHub issue #2927).
PowerShell 5.1's legacy console host does not support VT escape sequences
reliably. Rich's ``Live(transient=True)`` attempts cursor restoration on
exit, which hangs indefinitely on that console. The fix disables transient
mode when ``sys.platform == "win32"``.
These tests patch ``sys.platform`` and intercept the ``Live`` constructor
to verify the correct ``transient`` value reaches Rich.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# _console.py — Live in the select_with_arrows helper
# ---------------------------------------------------------------------------
def _invoke_select_with_arrows(platform: str) -> bool:
"""Patch sys.platform and Live, invoke select_with_arrows, return transient kwarg."""
captured = {}
mock_live_instance = MagicMock()
mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance)
mock_live_instance.__exit__ = MagicMock(return_value=False)
def fake_live(*args, **kwargs):
captured.update(kwargs)
return mock_live_instance
# Patch readchar so the loop immediately returns "enter"
import readchar
with (
patch("sys.platform", platform),
patch("specify_cli._console.Live", side_effect=fake_live),
patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER),
):
from specify_cli._console import select_with_arrows
select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a")
return captured["transient"]
class TestSelectWithArrowsLiveTransient:
"""Verify that select_with_arrows passes transient=False on Windows."""
def test_transient_false_on_windows(self):
assert _invoke_select_with_arrows("win32") is False
def test_transient_true_on_linux(self):
assert _invoke_select_with_arrows("linux") is True
def test_transient_true_on_macos(self):
assert _invoke_select_with_arrows("darwin") is True
# ---------------------------------------------------------------------------
# init.py — verify source contains the platform guard (regression check)
# ---------------------------------------------------------------------------
class TestSourceContainsPlatformGuard:
"""Ensure the platform guard feeds into the Live() transient kwarg."""
# Single DOTALL regex: _transient assigned from win32 check, then used in Live()
_GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient"
def test_init_has_win32_guard(self):
"""init.py must assign _transient from platform check and pass it to Live."""
import re
init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py"
content = init_src.read_text(encoding="utf-8")
assert re.search(self._GUARD_RE, content, re.DOTALL)
def test_console_has_win32_guard(self):
"""_console.py must assign _transient from platform check and pass it to Live."""
import re
console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py"
content = console_src.read_text(encoding="utf-8")
assert re.search(self._GUARD_RE, content, re.DOTALL)
assert re.search(r"transient\s*=\s*_transient", content)
assert "transient=_transient" in content
+190
View File
@@ -0,0 +1,190 @@
import stat
from specify_cli import merge_json_files
from specify_cli import handle_vscode_settings
# --- Dimension 2: Polite Deep Merge Strategy ---
def test_merge_json_files_type_mismatch_preservation(tmp_path):
"""If user has a string but template wants a dict, PRESERVE user's string."""
existing_file = tmp_path / "settings.json"
# User might have overridden a setting with a simple string or different type
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
# Template might expect a dict for the same key (hypothetically)
new_settings = {
"chat.editor.fontFamily": {"font": "TemplateFont"}
}
merged = merge_json_files(existing_file, new_settings)
# Result is None because user settings were preserved and nothing else changed
assert merged is None
def test_merge_json_files_deep_nesting(tmp_path):
"""Verify deep recursive merging of new keys."""
existing_file = tmp_path / "settings.json"
existing_file.write_text("""
{
"a": {
"b": {
"c": 1
}
}
}
""")
new_settings = {
"a": {
"b": {
"d": 2 # New nested key
},
"e": 3 # New mid-level key
}
}
merged = merge_json_files(existing_file, new_settings)
assert merged["a"]["b"]["c"] == 1
assert merged["a"]["b"]["d"] == 2
assert merged["a"]["e"] == 3
def test_merge_json_files_empty_existing(tmp_path):
"""Merging into an empty/new file."""
existing_file = tmp_path / "empty.json"
existing_file.write_text("{}")
new_settings = {"a": 1}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1}
# --- Dimension 3: Real-world Simulation ---
def test_merge_vscode_realistic_scenario(tmp_path):
"""A realistic VSCode settings.json with many existing preferences, comments, and trailing commas."""
existing_file = tmp_path / "vscode_settings.json"
existing_file.write_text("""
{
"editor.fontSize": 12,
"editor.formatOnSave": true, /* block comment */
"files.exclude": {
"**/.git": true,
"**/node_modules": true,
},
"chat.promptFilesRecommendations": {
"existing.tool": true,
} // User comment
}
""")
template_settings = {
"chat.promptFilesRecommendations": {
"speckit.specify": True,
"speckit.plan": True
},
"chat.tools.terminal.autoApprove": {
".specify/scripts/bash/": True
}
}
merged = merge_json_files(existing_file, template_settings)
# Check preservation
assert merged["editor.fontSize"] == 12
assert merged["files.exclude"]["**/.git"] is True
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
# Check additions
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
# --- Dimension 4: Error Handling & Robustness ---
def test_merge_json_files_with_bom(tmp_path):
"""Test files with UTF-8 BOM (sometimes created on Windows)."""
existing_file = tmp_path / "bom.json"
content = '{"a": 1}'
# Prepend UTF-8 BOM
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
new_settings = {"b": 2}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1, "b": 2}
def test_merge_json_files_not_a_dictionary_template(tmp_path):
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
existing_file = tmp_path / "ok.json"
existing_file.write_text('{"a": 1}')
# Secure fallback: return None to skip writing and avoid clobbering
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
def test_merge_json_files_unparseable_existing(tmp_path):
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
bad_file = tmp_path / "bad.json"
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
assert merge_json_files(bad_file, {"b": 2}) is None
def test_merge_json_files_list_preservation(tmp_path):
"""Verify that existing list values are preserved and NOT merged or overwritten."""
existing_file = tmp_path / "list.json"
existing_file.write_text('{"my.list": ["user_item"]}')
template_settings = {
"my.list": ["template_item"]
}
merged = merge_json_files(existing_file, template_settings)
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
# Since nothing changed, it returns None.
assert merged is None
def test_merge_json_files_no_changes(tmp_path):
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
existing_file = tmp_path / "no_change.json"
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
template_settings = {
"a": 1, # Already exists
"b": {"c": 2} # Already exists nested
}
# Should return None because result == existing
assert merge_json_files(existing_file, template_settings) is None
def test_merge_json_files_type_mismatch_no_op(tmp_path):
"""If a key exists with different type and we preserve it, it might still result in no change."""
existing_file = tmp_path / "mismatch_no_op.json"
existing_file.write_text('{"a": "user_string"}')
template_settings = {
"a": {"key": "template_dict"} # Mismatch, will be ignored
}
# Should return None because we preserved the user's string and nothing else changed
assert merge_json_files(existing_file, template_settings) is None
def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
"""Atomic rewrite should preserve existing file mode bits."""
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir()
dest_file = vscode_dir / "settings.json"
template_file = tmp_path / "template_settings.json"
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
dest_file.chmod(0o640)
before_mode = stat.S_IMODE(dest_file.stat().st_mode)
template_file.write_text('{"b": 2}\n', encoding="utf-8")
handle_vscode_settings(
template_file,
dest_file,
"settings.json",
verbose=False,
tracker=None,
)
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
assert after_mode == before_mode
+245
View File
@@ -0,0 +1,245 @@
"""Tests for post_process_command_content() hook on IntegrationBase.
Verifies that the generalized post-processing hook:
- Runs for non-skills format types (Markdown, TOML, YAML)
- Does NOT run for skills-format agents
- Default no-op returns content unchanged
- Exceptions propagate to caller
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations.base import IntegrationBase
@pytest.fixture
def registrar():
return CommandRegistrar()
@pytest.fixture
def ext_dir(tmp_path):
"""Create a mock extension with a simple command template."""
ext = tmp_path / "extension"
ext.mkdir()
cmd_dir = ext / "commands"
cmd_dir.mkdir()
return ext, cmd_dir
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
cmd_file = cmd_dir / name
cmd_file.write_text(
f"---\ndescription: Test command\n---\n\n{body}",
encoding="utf-8",
)
return cmd_file
class TestDefaultNoOp:
def test_returns_content_unchanged(self):
base = IntegrationBase()
content = "Some command content\nwith multiple lines."
assert base.post_process_command_content(content) == content
def test_empty_string(self):
base = IntegrationBase()
assert base.post_process_command_content("") == ""
class TestMarkdownAgentPostProcess:
def test_opencode_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
marker = "<!-- POST_PROCESSED -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestTomlAgentPostProcess:
def test_gemini_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
gemini = get_integration("gemini")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
gemini.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"gemini", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestYamlAgentPostProcess:
def test_goose_post_process_applied(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
goose = get_integration("goose")
marker = "# POST_PROCESSED"
def _inject_marker(self, content):
return content + f"\n{marker}\n"
monkeypatch.setattr(
goose.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"goose", commands, "test-ext", ext, tmp_path
)
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
assert cmd_output.exists()
content = cmd_output.read_text(encoding="utf-8")
assert marker in content
class TestSkillsAgentExcluded:
def test_claude_post_process_not_called(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
marker = "<!-- SHOULD_NOT_APPEAR -->"
def _inject_marker(self, content):
return content + marker
monkeypatch.setattr(
claude.__class__, "post_process_command_content", _inject_marker
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
skill_file = (
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
)
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert marker not in content
def test_skills_agent_method_never_called(
self, tmp_path, registrar, ext_dir
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
claude = get_integration("claude")
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with patch.object(
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
) as mock_method:
registrar.register_commands(
"claude", commands, "test-ext", ext, tmp_path
)
mock_method.assert_not_called()
class TestExceptionPropagation:
def test_hook_exception_propagates(
self, tmp_path, registrar, ext_dir, monkeypatch
):
ext, cmd_dir = ext_dir
_write_cmd(cmd_dir)
from specify_cli.integrations import get_integration
opencode = get_integration("opencode")
def _raise(self, content):
raise RuntimeError("Hook failed")
monkeypatch.setattr(
opencode.__class__, "post_process_command_content", _raise
)
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
with pytest.raises(RuntimeError, match="Hook failed"):
registrar.register_commands(
"opencode", commands, "test-ext", ext, tmp_path
)
class TestRegressionPlainTemplate:
@pytest.mark.parametrize(
"agent,path_pattern",
[
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
("opencode", ".opencode/commands/speckit.test.plain.md"),
],
ids=["skills", "markdown"],
)
def test_plain_template_unchanged(
self, tmp_path, registrar, ext_dir, agent, path_pattern
):
ext, cmd_dir = ext_dir
body_text = "This is a plain command with no special content.\n"
_write_cmd(cmd_dir, name="plain.md", body=body_text)
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
registrar.register_commands(
agent, commands, "test-ext", ext, tmp_path
)
output_file = tmp_path / path_pattern
assert output_file.exists(), f"Output file missing for {agent}"
content = output_file.read_text(encoding="utf-8")
assert body_text.strip() in content, f"Body text missing in {agent} output"

Some files were not shown because too many files have changed in this diff Show More