Files
github-spec-kit/tests/integrations/test_integration_subcommand.py
Pascal THUET 490566847c feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver (#3186)
* feat(cli): honor SPECIFY_INIT_DIR in the specify CLI project resolver

The shell resolver honors SPECIFY_INIT_DIR (#2892), but the Python CLI did
not: it resolved the project as Path.cwd() + a .specify/ check and never read
the override. So setup-plan.sh respected it while `specify integration install`
ignored it, and you still had to cd into the member project.

Route project resolution through a shared _resolve_init_dir_override() that
applies the shell resolver's validation rules (relative to cwd, must exist and
contain .specify/, hard error, no fallback, same error strings). It's wired into
_require_specify_project() — the chokepoint for every project-scoped subcommand
(integration/extension/workflow/preset/...) — and the `workflow run <file>`
standalone path, which re-applies its symlinked-.specify guard on the override
branch too. init is unchanged: it creates .specify/, so the must-pre-exist rule
doesn't apply.

The resolver canonicalizes symlinks via Path.resolve() while the shell keeps the
logical path; they agree for non-symlinked paths (documented in the resolver).

Tests in tests/test_init_dir_cli.py mirror the strict cases from test_init_dir.py
through the CLI; conftest now strips SPECIFY_* for the whole suite so a stray
export can't perturb the now-env-reading resolver. Docs note the CLI applies the
same rules.

Discussion: github/spec-kit#2834

(Disclosure: I used an AI coding agent to audit the call sites and resolver,
draft the change, and run an adversarial code review; reviewed by me.)

* fix(cli): honor SPECIFY_INIT_DIR for bundle commands

Assisted-by: Codex (model: GPT-5, autonomous)

* fix(bundler): refuse symlinked .specify on the SPECIFY_INIT_DIR override path

find_project_root refuses a symlinked .specify (following it could read/write
outside the tree, and a test pins that), but the SPECIFY_INIT_DIR override added
for bundle commands returned early and skipped that guard:
_resolve_init_dir_override validates .specify with is_dir(), which follows
symlinks. So `specify bundle` accepted via the override a layout the cwd path
rejects. Re-check the override result with the same guard, plus a regression test.

(Disclosure: found via an AI code review and fixed with an AI coding agent;
reviewed by me.)

* fix(cli): keep SPECIFY_INIT_DIR strict for bundles

Treat an explicit symlinked SPECIFY_INIT_DIR project as a hard bundle error instead of returning no project, which could initialize the current directory. Align the docs with the actual unset resolver behavior.

Assisted-by: Codex (model: GPT-5, autonomous)

* docs(core): note symlinked .specify handling differs across CLI surfaces

A symlinked .specify is followed by integration/extension/workflow (matching the
shell resolver) but refused by bundle and workflow run <file> (write
confinement). Document the asymmetry so it reads as intentional.

(Disclosure: AI-assisted; reviewed by me.)

* docs(core): reframe symlinked .specify note around the override invariant

Per maintainer feedback on #3186: SPECIFY_INIT_DIR relocates where the project
is, not how a surface treats symlinks. Each surface keeps its cwd-path stance
(write surfaces refuse a symlinked .specify, read/config surfaces follow it),
so the split is one policy relocated, not an inconsistency.

* docs: address Copilot review on resolver docstrings

- _project.py: the error messages "mirror" the shell wording rather than
  "match" it (the CLI renders a Rich `Error:` line, the shell a plain `ERROR:`).
- find_project_root: document that honoring SPECIFY_INIT_DIR when start is None
  can raise typer.Exit / BundlerError, so the Path | None signature isn't
  surprising to direct callers.

* docs(bundler): note require_project_root inherits the override raise behavior

find_project_root can raise typer.Exit / BundlerError under the SPECIFY_INIT_DIR
override (start=None); require_project_root inherits that, so document it
alongside its own BundlerError-on-missing-project.

* docs: clarify symlinked project root behavior

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* Address SPECIFY_INIT_DIR review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* Route workflow JSON errors to stderr

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
2026-07-01 15:55:18 -05:00

2744 lines
115 KiB
Python

"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch)."""
import json
import os
import shutil
from pathlib import Path
import pytest
from typer.testing import CliRunner
from specify_cli import app
from tests.conftest import strip_ansi
runner = CliRunner()
def _init_project(tmp_path, integration="copilot", integration_options=None):
"""Helper: init a spec-kit project with the given integration."""
project = tmp_path / "proj"
project.mkdir()
args = [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--ignore-agent-tools",
]
if integration_options:
args += ["--integration-options", integration_options]
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
return project
def _run_in_project(project, args):
"""Run a CLI command from inside a generated project."""
old_cwd = os.getcwd()
try:
os.chdir(project)
return runner.invoke(app, args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
def _write_invalid_manifest(project, key):
manifest = project / ".specify" / "integrations" / f"{key}.manifest.json"
manifest.write_bytes(b"\xff\xfe\x00")
return manifest
def _copy_project_template(tmp_path, template):
project = tmp_path / "proj"
shutil.copytree(template, project)
return project
@pytest.fixture(scope="module")
def status_copilot_template(tmp_path_factory):
return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")
@pytest.fixture(scope="module")
def status_claude_template(tmp_path_factory):
return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")
@pytest.fixture
def copilot_project(tmp_path, status_copilot_template):
return _copy_project_template(tmp_path, status_copilot_template)
@pytest.fixture
def claude_project(tmp_path, status_claude_template):
return _copy_project_template(tmp_path, status_claude_template)
def _integration_list_row_cells(output: str, key: str) -> list[str]:
plain = strip_ansi(output)
row = next(line for line in plain.splitlines() if line.startswith(f"{key}"))
return [cell.strip() for cell in row.split("")[1:-1]]
# ── list ─────────────────────────────────────────────────────────────
class TestIntegrationList:
def test_list_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a Spec Kit project" in result.output
def test_list_shows_installed(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "copilot" in result.output
assert "installed" in result.output
def test_list_shows_available_integrations(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Should show multiple integrations
assert "claude" in result.output
assert "gemini" in result.output
assert "zed" in result.output
def test_list_shows_multi_install_safe_status(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "Multi-install" in result.output
assert "Safe" in result.output
assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"
def test_list_rejects_newer_integration_state_schema(self, tmp_path):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
data = json.loads(int_json.read_text(encoding="utf-8"))
data["integration_state_schema"] = 99
int_json.write_text(json.dumps(data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
normalized = " ".join(result.output.split())
assert "schema 99" in normalized
assert "only supports schema 1" in normalized
# ── status ───────────────────────────────────────────────────────────
class TestIntegrationStatus:
def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["integration", "status"])
assert result.exit_code != 0
assert "Not a Spec Kit project" in result.output
def test_status_reports_healthy_project(self, copilot_project):
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code == 0
assert "Integration status: OK" in result.output
assert "Default integration: copilot" in result.output
assert "Installed integrations: copilot" in result.output
assert "Shared templates target alignment: copilot" in result.output
assert "Modified managed files: 0" in result.output
assert "Missing managed files: 0" in result.output
def test_status_json_reports_healthy_project(self, copilot_project):
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["default_integration"] == "copilot"
assert payload["installed_integrations"] == ["copilot"]
assert payload["recorded_installed_integrations"] == ["copilot"]
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
assert payload["multi_install_safe"] is True
assert payload["shared_templates_target_alignment"] == "copilot"
assert "shared_templates_aligned_to" not in payload
assert payload["findings"] == []
def test_status_reports_invalid_integration_json(self, copilot_project):
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "integration-state-unreadable" in result.output
assert "invalid JSON" in result.output
assert "Detail:" in result.output
assert "Multi-install safe: unknown" in result.output
assert "Traceback" not in result.output
def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
self,
copilot_project,
):
(copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == []
assert payload["findings"][0]["code"] == "integration-state-unreadable"
assert "Detail:" in payload["findings"][0]["message"]
def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["integration_state_schema"] = 99
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["findings"][0]["code"] == "integration-state-unreadable"
assert "schema 99" in payload["findings"][0]["message"]
assert "supported schema: 1" in payload["findings"][0]["message"]
def test_status_reports_missing_integration_json(self, copilot_project):
(copilot_project / ".specify" / "integration.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "integration-state-missing" in result.output
assert ".specify/integration.json is missing" in result.output
assert "Multi-install safe: unknown" in result.output
def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
self,
copilot_project,
):
(copilot_project / ".specify" / "integration.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == []
assert payload["findings"][0]["code"] == "integration-state-missing"
def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state_path.write_text(
json.dumps({
"version": "test",
"integration_state_schema": 1,
"installed_integrations": [],
}),
encoding="utf-8",
)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == []
assert payload["multi_install_safe"] is None
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["findings"][0]["code"] == "no-installed-integrations"
assert "speckit" in payload["manifests"]
assert payload["manifests"]["speckit"]["readable"] is True
def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state_path.write_text(
json.dumps({
"version": "test",
"integration_state_schema": 1,
"installed_integrations": [],
}),
encoding="utf-8",
)
(copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["unchecked_manifests"] == 1
assert any(
item["code"] == "no-installed-integrations"
for item in payload["findings"]
)
assert any(
item["code"] == "manifest-missing"
and item["integration"] == "speckit"
for item in payload["findings"]
)
def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state.pop("default_integration", None)
state.pop("integration", None)
state["installed_integrations"] = ["claude"]
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["status"] == "error"
assert payload["default_integration"] is None
assert any(
item["code"] == "default-integration-missing"
for item in payload["findings"]
)
def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state.pop("default_integration", None)
state.pop("integration", None)
state["installed_integrations"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == []
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["speckit"]
assert payload["multi_install_safe"] is None
assert [item["code"] for item in payload["findings"]] == [
"installed-integrations-invalid",
"no-installed-integrations",
]
def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["default_integration"] = "copilot"
state["integration"] = "copilot"
state["installed_integrations"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["installed_integrations"] == ["copilot"]
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
assert payload["multi_install_safe"] is None
assert [item["code"] for item in payload["findings"]] == [
"installed-integrations-invalid",
]
def test_status_reports_default_integration_not_installed(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["default_integration"] = "codex"
state["integration"] = "codex"
state["installed_integrations"] = ["claude"]
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["default_integration"] == "codex"
assert payload["installed_integrations"] == ["codex", "claude"]
assert payload["recorded_installed_integrations"] == ["claude"]
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
assert any(
item["code"] == "default-integration-not-installed"
and "Default integration 'codex' is not listed" in item["message"]
for item in payload["findings"]
)
assert "codex" not in payload["manifests"]
assert not any(
item["code"] == "manifest-missing" and item.get("integration") == "codex"
for item in payload["findings"]
)
def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = []
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["installed_integrations"] == ["claude"]
assert payload["recorded_installed_integrations"] == []
assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
assert payload["multi_install_safe"] is None
assert payload["manifests"]["claude"]["readable"] is True
assert any(
item["code"] == "default-integration-not-installed"
for item in payload["findings"]
)
def test_status_reports_missing_manifest(self, copilot_project):
(copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "manifest-missing" in result.output
assert "Manifest for integration 'copilot' is missing" in result.output
def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
_write_invalid_manifest(copilot_project, "copilot")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["unchecked_manifests"] == 1
assert payload["manifests"]["copilot"]["readable"] is False
assert payload["manifests"]["copilot"]["missing_files"] == []
assert payload["manifests"]["copilot"]["modified_files"] == []
def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
(copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code == 0
assert "Integration status: WARNING" in result.output
assert "managed-files-modified" in result.output
assert "Modified managed files: 1" in result.output
def test_status_reports_missing_managed_files(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
(copilot_project / first_rel).unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "managed-files-missing" in result.output
assert "Missing managed files: 1" in result.output
def test_status_reports_missing_shared_managed_files(self, copilot_project):
shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_file.exists()
shared_file.unlink()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "managed-files-missing" in result.output
assert "shared Spec Kit infrastructure" in result.output
assert "Missing managed files: 1" in result.output
def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
def fail_exists(self):
raise AssertionError(f"Path.exists() should not be used for {self}")
monkeypatch.setattr(Path, "exists", fail_exists)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project.resolve(),
)
assert missing == []
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
def fail_exists(self):
raise AssertionError(f"Path.exists() should not be used for {self}")
monkeypatch.setattr(Path, "exists", fail_exists)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "ok"
assert payload["manifests"]["copilot"]["readable"] is True
def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
original_resolve = Path.resolve
failed = {"done": False}
def fail_first_project_root_resolve(self, *args, **kwargs):
if self == copilot_project and not failed["done"]:
failed["done"] = True
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
self,
copilot_project,
monkeypatch,
):
original_resolve = Path.resolve
def fail_project_root_resolve(self, *args, **kwargs):
if self == copilot_project:
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["status"] == "warning"
assert payload["manifests"]["copilot"]["readable"] is True
assert payload["manifests"]["speckit"]["readable"] is True
assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])
def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
real_project = tmp_path / "real-project"
real_project.mkdir()
tracked = real_project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
symlinked_project = tmp_path / "symlinked-project"
try:
symlinked_project.symlink_to(real_project, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
manifest = IntegrationManifest("test", real_project, version="test")
manifest.record_existing("tracked.md")
manifest.project_root = symlinked_project.absolute()
missing, modified, invalid, valid = _manifest_file_status(
manifest,
symlinked_project.absolute(),
project_root_is_resolved=False,
)
assert missing == []
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
project_root_resolved = project.resolve()
original_resolve = Path.resolve
def fail_project_parent_resolve(self, *args, **kwargs):
if self == project:
raise RuntimeError("symlink loop")
return original_resolve(self, *args, **kwargs)
monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project_root_resolved,
)
assert missing == []
assert modified == []
assert invalid == ["tracked.md"]
assert valid == []
def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
from specify_cli import integration_status as status_module
def fail_load(key, project_root, **kwargs):
raise RuntimeError(f"unexpected manifest loader bug for {key}")
monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)
with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
status_module.build_integration_status_report(copilot_project)
def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
target = copilot_project / first_rel
target.unlink()
try:
target.symlink_to(copilot_project / "missing-target")
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert first_rel in payload["manifests"]["copilot"]["missing_files"]
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
from specify_cli.integration_status import _manifest_file_status
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "proj"
project.mkdir()
tracked = project / "tracked.md"
tracked.write_text("content\n", encoding="utf-8")
regular_stat = tracked.lstat()
manifest = IntegrationManifest("test", project, version="test")
manifest.record_existing("tracked.md")
tracked.unlink()
try:
tracked.symlink_to(project / "missing-target")
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
original_lstat = Path.lstat
original_is_symlink = Path.is_symlink
def windows_style_lstat(self):
if self == tracked:
return regular_stat
return original_lstat(self)
def windows_style_is_symlink(self):
if self == tracked:
return True
return original_is_symlink(self)
monkeypatch.setattr(Path, "lstat", windows_style_lstat)
monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)
missing, modified, invalid, valid = _manifest_file_status(
manifest,
project.resolve(),
)
assert missing == ["tracked.md"]
assert modified == []
assert invalid == []
assert valid == ["tracked.md"]
def test_strip_extended_length_prefix_normalizes_windows_paths(self):
from specify_cli.integration_status import _strip_extended_length_prefix
# Build the prefixed strings explicitly so the test is meaningful on
# every platform (POSIX won't parse backslash separators, but the
# helper operates on the string form). Compare Path objects rather than
# their str() form: on Windows pathlib renders a UNC root with a
# trailing separator (``\\server\share\``), so an exact string match is
# brittle, whereas Path equality captures the intended semantics on
# both POSIX and Windows.
bs = "\\"
assert _strip_extended_length_prefix(
Path(f"{bs}{bs}?{bs}C:{bs}proj")
) == Path(f"C:{bs}proj")
assert _strip_extended_length_prefix(
Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
) == Path(f"{bs}{bs}server{bs}share")
# Paths without the prefix are returned unchanged.
assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")
def test_is_within_project_tolerates_extended_length_prefix(self):
from specify_cli.integration_status import _is_within_project
# A readlink result on POSIX never carries the prefix, so an in-project
# child is contained and an outside path is not. The Windows
# prefix-stripping branch is exercised by the dangling-symlink tests on
# Windows CI; here we lock in the cross-platform containment contract.
root = Path("/tmp/project").resolve()
assert _is_within_project(root, root / "child")
assert not _is_within_project(root, Path("/tmp/other").resolve())
def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
link = copilot_project / "outside-link"
try:
link.symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"]["outside-link/secret.txt"] = "wrong"
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["invalid_manifest_paths"] == 1
assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]
def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
outside = tmp_path / "outside"
outside.mkdir()
outside_file = outside / "secret.txt"
outside_file.write_text("outside project\n", encoding="utf-8")
manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
first_rel = next(iter(tracked_files))
tracked_path = copilot_project / first_rel
tracked_path.unlink()
try:
tracked_path.symlink_to(outside_file)
except OSError as exc:
pytest.skip(f"symlinks unavailable: {exc}")
original_stat = Path.stat
def fail_tracked_symlink_stat(self, *args, **kwargs):
follows_symlinks = kwargs.get("follow_symlinks", True)
if self == tracked_path and follows_symlinks:
raise AssertionError("Path.stat() should not follow tracked symlinks")
return original_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)
result = _run_in_project(copilot_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["invalid_manifest_paths"] == 1
assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
assert first_rel not in payload["manifests"]["copilot"]["modified_files"]
def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = copilot_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["copilot", "claude"]
state["default_integration"] = "copilot"
state["integration"] = "copilot"
state_path.write_text(json.dumps(state), encoding="utf-8")
IntegrationManifest("claude", copilot_project, version="test").save()
result = _run_in_project(copilot_project, ["integration", "status"])
assert result.exit_code != 0
assert "unsafe-multi-install" in result.output
assert "Multi-install safe: no" in result.output
assert "specify integration switch <key>" in result.output
def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["claude", "mystery"]
state["default_integration"] = "claude"
state["integration"] = "claude"
state_path.write_text(json.dumps(state), encoding="utf-8")
IntegrationManifest("mystery", claude_project, version="test").save()
result = _run_in_project(claude_project, ["integration", "status"])
assert result.exit_code != 0
assert "unknown-integration" in result.output
assert "unsafe-multi-install" in result.output
assert "remove the stale integration entry" in result.output
assert "Multi-install safe: no" in result.output
def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["mystery"]
state["default_integration"] = "mystery"
state["integration"] = "mystery"
state_path.write_text(json.dumps(state), encoding="utf-8")
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
manifest_finding = next(
item for item in payload["findings"]
if item["code"] == "manifest-missing" and item["integration"] == "mystery"
)
assert "remove the stale integration entry" in manifest_finding["suggestion"]
assert "integration upgrade mystery" not in manifest_finding["suggestion"]
def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "../../../escape"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
outside_manifest = tmp_path / "escape.manifest.json"
outside_manifest.write_text(
json.dumps({"integration": unsafe_key, "files": {}}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert unsafe_key not in payload["manifests"]
assert payload["manifest_checked_integrations"] == ["speckit"]
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "bad:key"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
state_path = claude_project / ".specify" / "integration.json"
unsafe_key = "CON"
state_path.write_text(
json.dumps({
"integration": unsafe_key,
"default_integration": unsafe_key,
"installed_integrations": [unsafe_key],
}),
encoding="utf-8",
)
result = _run_in_project(claude_project, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert any(
item["code"] == "integration-key-invalid"
and item["integration"] == unsafe_key
for item in payload["findings"]
)
def test_status_reports_managed_file_collisions(self, claude_project):
from specify_cli.integrations.manifest import IntegrationManifest
state_path = claude_project / ".specify" / "integration.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["installed_integrations"] = ["claude", "codex"]
state["default_integration"] = "claude"
state["integration"] = "claude"
state_path.write_text(json.dumps(state), encoding="utf-8")
claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
shared_rel = next(iter(tracked_files))
codex_manifest = IntegrationManifest("codex", claude_project, version="test")
codex_manifest.record_existing(shared_rel)
codex_manifest.save()
result = _run_in_project(claude_project, ["integration", "status"])
assert result.exit_code == 0
assert "managed-file-collision" in result.output
assert "Integration status: WARNING" in result.output
def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
(project / ".specify" / "integration.json").write_text(
json.dumps({
"integration": "[red]x[/red]",
"installed_integrations": ["[red]x[/red]"],
}),
encoding="utf-8",
)
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "status", "--json"])
assert result.exit_code != 0
payload = json.loads(result.output)
assert payload["default_integration"] == "[red]x[/red]"
assert payload["installed_integrations"] == ["[red]x[/red]"]
def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
(project / ".specify" / "integration.json").write_text(
json.dumps({
"integration": "[red]x[/red]",
"installed_integrations": ["[red]x[/red]"],
}),
encoding="utf-8",
)
monkeypatch.chdir(project)
result = runner.invoke(app, ["integration", "status"])
assert result.exit_code != 0
assert "Default integration: [red]x[/red]" in result.output
assert "Installed integrations: [red]x[/red]" in result.output
# ── install ──────────────────────────────────────────────────────────
class TestIntegrationInstall:
def test_install_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "install", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a Spec Kit project" in result.output
def test_install_unknown_integration(self, tmp_path):
project = _init_project(tmp_path)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "install", "nonexistent"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Unknown integration" in result.output
def test_install_already_installed(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "install", "copilot"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
plain = strip_ansi(result.output)
assert "already installed" in plain
normalized = " ".join(plain.split())
assert "specify integration upgrade copilot" in normalized
assert "already the default integration" in normalized
assert "No files were changed" in normalized
assert "specify integration uninstall copilot" not in normalized
def test_install_already_installed_non_default_guides_use(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, ["integration", "install", "codex"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
output = strip_ansi(result.output)
normalized = " ".join(output.split())
assert "already installed" in normalized
assert "specify integration use codex" in normalized
assert "specify integration upgrade codex" in normalized
assert "specify integration uninstall codex" not in normalized
def test_install_different_when_one_exists(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "install", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
plain = strip_ansi(result.output)
assert "Installed integrations: copilot" in plain
assert "Default integration: copilot" in plain
normalized = " ".join(plain.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
assert "retry the same install command with --force" in normalized
def test_install_multi_safe_integration(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "installed successfully" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert data["default_integration"] == "claude"
assert data["integration_state_schema"] == 1
assert data["installed_integrations"] == ["claude", "codex"]
assert data["integration_settings"]["claude"]["invoke_separator"] == "-"
assert data["integration_settings"]["codex"]["invoke_separator"] == "-"
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
init_options = project / ".specify" / "init-options.json"
opts = json.loads(init_options.read_text(encoding="utf-8"))
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
updated = json.loads(init_options.read_text(encoding="utf-8"))
assert updated["speckit_version"] == "0.8.11"
assert updated["integration"] == "claude"
assert updated["ai"] == "claude"
assert "context_file" not in updated
def test_install_additional_preserves_shared_manifest(self, tmp_path):
project = _init_project(tmp_path, "claude")
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
assert before
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
assert before <= after
def test_install_multi_safe_migrates_legacy_state(self, tmp_path):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
int_json.write_text(json.dumps({
"integration": "claude",
"version": "0.0.0",
}), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads(int_json.read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert data["default_integration"] == "claude"
assert data["installed_integrations"] == ["claude", "codex"]
def test_install_multi_unsafe_requires_force(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
plain = strip_ansi(result.output)
assert "Installed integrations: copilot" in plain
assert "multi-install safe" in plain
normalized = " ".join(plain.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
assert "retry the same install command with --force" in normalized
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
assert data["installed_integrations"] == ["copilot", "claude"]
def test_install_into_bare_project(self, tmp_path):
"""Install into a project with .specify/ but no integration."""
project = tmp_path / "bare"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "installed successfully" in result.output
# integration.json written
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
# Manifest created
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
# Claude uses skills directory (not commands)
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_install_bare_project_gets_shared_infra(self, tmp_path):
"""Installing into a bare project should create shared scripts and templates."""
project = tmp_path / "bare"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
# Shared infrastructure should be present
assert (project / ".specify" / "scripts").is_dir()
assert (project / ".specify" / "templates").is_dir()
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
script_content = script.read_text(encoding="utf-8")
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
def test_install_defers_extension_commands_until_use(self, tmp_path):
"""Installing a second integration does not register enabled extensions.
Maintainer-requested behavior for #2886: extension command back-fill is
limited to ``integration use`` / ``switch`` / ``upgrade``. Plain
``install`` only adds the integration; selecting it with ``use`` then
registers the enabled extensions for that agent.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered
assert "codex" not in registered, "precondition: codex not yet installed"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Install alone does not back-fill the git extension for the secondary
# agent.
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered, "existing agent registration preserved"
assert "codex" not in registered
assert not (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
result = _run_in_project(project, ["integration", "use", "codex"])
assert result.exit_code == 0, result.output
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, "use should register extension commands (#2886)"
assert (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_install_does_not_register_disabled_extensions(self, tmp_path):
"""A disabled extension must not be registered for a newly installed agent."""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, result.output
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
registry_path = project / ".specify" / "extensions" / ".registry"
git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]
assert git_meta["enabled"] is False
assert "codex" not in git_meta["registered_commands"]
assert not (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path):
"""A non-active skills-mode agent gets extension artifacts only on use.
Plain ``install`` has no extension side effects. Once the secondary
Copilot ``--skills`` integration is selected with ``use``, it becomes the
active agent and receives extension skills.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Copilot is not multi_install_safe, so --force is required to add it
# alongside the existing default integration.
result = _run_in_project(project, [
"integration", "install", "copilot",
"--script", "sh",
"--integration-options", "--skills",
"--force",
])
assert result.exit_code == 0, result.output
# Precondition that makes --skills load-bearing: copilot IS in skills
# mode, so its own core commands are scaffolded as skills.
assert (
project / ".github" / "skills" / "speckit-specify" / "SKILL.md"
).exists(), "precondition: copilot installed in skills mode"
# The git extension is not registered for the non-active copilot agent
# during install.
git_meta = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)["extensions"]["git"]
assert "copilot" not in git_meta["registered_commands"]
assert not (
project / ".github" / "agents" / "speckit.git.feature.agent.md"
).exists()
assert not (
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
result = _run_in_project(project, ["integration", "use", "copilot"])
assert result.exit_code == 0, result.output
git_meta = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)["extensions"]["git"]
# `use` makes copilot active, so extension artifacts follow copilot's
# skills-mode layout.
assert "copilot" not in git_meta["registered_commands"]
assert "speckit-git-feature" in git_meta["registered_skills"]
assert not (
project / ".github" / "agents" / "speckit.git.feature.agent.md"
).exists()
assert (
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
# ── uninstall ────────────────────────────────────────────────────────
class TestIntegrationUninstall:
def test_uninstall_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "uninstall"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a Spec Kit project" in result.output
def test_uninstall_no_integration(self, tmp_path):
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "No integration" in result.output
def test_uninstall_removes_files(self, tmp_path):
project = _init_project(tmp_path, "claude")
# Claude uses skills directory
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "uninstalled" in result.output
# Command files removed
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
# Manifest removed
assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists()
# integration.json removed
assert not (project / ".specify" / "integration.json").exists()
def test_uninstall_preserves_modified_files(self, tmp_path):
"""Full lifecycle: install → modify → uninstall → modified file kept."""
project = _init_project(tmp_path, "claude")
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
# Modify a file
plan_file.write_text("# My custom plan command\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "preserved" in result.output
assert ".claude/skills/speckit-plan/SKILL.md" in result.output
# Modified file kept
assert plan_file.exists()
assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n"
def test_uninstall_wrong_key(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "not installed" in result.output
def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "manifest" in result.output
assert "unreadable" in result.output
def test_uninstall_non_default_preserves_default(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "uninstall", "codex",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert data["installed_integrations"] == ["claude"]
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
project = _init_project(tmp_path, "gemini")
template = project / ".specify" / "templates" / "plan-template.md"
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
assert "/speckit.plan" in script.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
assert "/speckit-plan" in script.read_text(encoding="utf-8")
def test_uninstall_preserves_shared_infra(self, tmp_path):
"""Shared scripts and templates are not removed by integration uninstall."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert shared_script.exists()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Shared infrastructure preserved
assert shared_script.exists()
assert (project / ".specify" / "templates").is_dir()
class TestIntegrationUse:
def test_use_installed_integration_sets_default(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "codex"
assert data["default_integration"] == "codex"
assert data["installed_integrations"] == ["claude", "codex"]
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "codex"
assert opts["ai"] == "codex"
def test_use_requires_installed_integration(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "use", "codex"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "not installed" in result.output
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
assert "/speckit-plan" in script.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "gemini",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
assert use_gemini.exit_code == 0, use_gemini.output
assert "/speckit.plan" in template.read_text(encoding="utf-8")
assert "/speckit.plan" in script.read_text(encoding="utf-8")
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
assert use_claude.exit_code == 0, use_claude.output
assert "/speckit-plan" in template.read_text(encoding="utf-8")
assert "/speckit-plan" in script.read_text(encoding="utf-8")
assert "/speckit.plan" not in script.read_text(encoding="utf-8")
finally:
os.chdir(old_cwd)
def test_use_preserves_modified_templates_unless_forced(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
template.write_text("custom template with /speckit-plan\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "gemini",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
assert use_gemini.exit_code == 0, use_gemini.output
normalized = " ".join(use_gemini.output.split())
assert "specify integration use gemini --force" in normalized
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
force_use = runner.invoke(app, [
"integration", "use", "gemini",
"--force",
], catch_exceptions=False)
assert force_use.exit_code == 0, force_use.output
finally:
os.chdir(old_cwd)
updated = template.read_text(encoding="utf-8")
assert "/speckit.plan" in updated
assert "custom template" not in updated
def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
init_options = project / ".specify" / "init-options.json"
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
before_state = json.loads(int_json.read_text(encoding="utf-8"))
before_options = json.loads(init_options.read_text(encoding="utf-8"))
import specify_cli
def fail_refresh(*args, **kwargs):
raise ValueError("refuse refresh")
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
result = runner.invoke(app, [
"integration", "use", "codex",
"--force",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Failed to refresh shared infrastructure" in result.output
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
# ── switch ───────────────────────────────────────────────────────────
class TestIntegrationSwitch:
def test_switch_requires_speckit_project(self, tmp_path):
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "switch", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a Spec Kit project" in result.output
def test_switch_unknown_target(self, tmp_path):
project = _init_project(tmp_path)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "switch", "nonexistent"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Unknown integration" in result.output
def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "codex",
"--script", "sh",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Could not read integration manifest" in result.output
def test_switch_same_noop(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "switch", "copilot"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "already the default integration" in result.output
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
template.write_text("# custom shared template\n", encoding="utf-8")
script.write_text("# custom shared script\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "claude",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "shared infrastructure refreshed" in result.output
assert "managed shared infrastructure refreshed" not in result.output
assert "/speckit-plan" in template.read_text(encoding="utf-8")
assert "/speckit-plan" in script.read_text(encoding="utf-8")
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "switch", "codex",
"--integration-options", "--bogus",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "--integration-options cannot be used" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["default_integration"] == "claude"
def test_switch_between_integrations(self, tmp_path):
project = _init_project(tmp_path, "claude")
# Verify claude files exist (claude uses skills)
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "Switched to" in result.output
# Old claude files removed
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
# New copilot files created
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
# integration.json updated
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
def test_switch_migrates_extension_commands(self, tmp_path):
"""Switching should migrate extension commands to the new agent directory."""
project = _init_project(tmp_path, "kimi")
# Install the bundled git extension
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Verify git extension skills exist for kimi
kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md"
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Git extension commands should exist for opencode
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
# Old kimi extension skills should be removed
assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"
# Extension registry should be updated
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
registered_commands = registry["extensions"]["git"]["registered_commands"]
assert "opencode" in registered_commands
assert "kimi" not in registered_commands
# Switch to claude
result = _run_in_project(project, [
"integration", "switch", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Git extension skills should exist for claude
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert claude_git_feature.exists(), "Git extension skill should exist for claude"
# Old opencode extension commands should be removed
assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"
# Extension registry should be updated
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
registered_commands = registry["extensions"]["git"]["registered_commands"]
assert "claude" in registered_commands
assert "opencode" not in registered_commands
def test_switch_installed_target_backfills_extension_commands(self, tmp_path):
"""Switching to an already-installed agent should register extensions."""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered
assert "codex" not in registered, "precondition: codex not yet installed"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
codex_git_feature = (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
)
assert not codex_git_feature.exists()
result = _run_in_project(project, ["integration", "switch", "codex"])
assert result.exit_code == 0, result.output
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered
assert codex_git_feature.exists()
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
"""Copilot --skills should receive extension skills, not .agent.md files."""
project = _init_project(tmp_path, "opencode")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, [
"integration", "switch", "copilot",
"--script", "sh",
"--integration-options", "--skills",
])
assert result.exit_code == 0, result.output
copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
# Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it
skill_content = copilot_git_feature.read_text(encoding="utf-8")
assert "mode:" not in skill_content, (
"Copilot skill frontmatter must not contain unsupported 'mode' field"
)
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert "speckit-git-feature" in git_meta["registered_skills"]
assert "copilot" not in git_meta["registered_commands"]
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert git_meta["registered_skills"] == []
assert "opencode" in git_meta["registered_commands"]
assert "copilot" not in git_meta["registered_commands"]
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
"""Disabled extensions should stay disabled and should not migrate commands."""
project = _init_project(tmp_path, "opencode")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
result = _run_in_project(project, [
"integration", "switch", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert git_meta["enabled"] is False
assert "claude" not in git_meta["registered_commands"]
assert "opencode" not in git_meta["registered_commands"]
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
"""Switching refreshes managed shared scripts to the target command style."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.exists()
shared_content = shared_script.read_text(encoding="utf-8")
assert "/speckit-plan" in shared_content
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert shared_script.exists()
updated = shared_script.read_text(encoding="utf-8")
assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
# Simulate a stale vendored script: write truncated content as bytes
# (write_text would translate \n→\r\n on Windows and break the hash)
# and update the speckit manifest hash so the stale copy is treated
# as "managed" (installed by spec-kit, not a user customization).
stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
shared_script.write_bytes(stale_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
hashlib.sha256(stale_bytes).hexdigest()
)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Stale managed file should be replaced by the target integration's rendered version.
updated = shared_script.read_text(encoding="utf-8")
assert "# stale vendored copy" not in updated
assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
# User customization: append bytes but do NOT update manifest hash,
# so on-disk hash diverges from the recorded one.
original = shared_script.read_bytes()
custom_bytes = original + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert shared_script.read_bytes() == custom_bytes
assert "Preserved" in result.output
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
rendered_bytes = shared_script.read_bytes()
# User customization (hash diverges from manifest)
custom_bytes = rendered_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--refresh-shared-infra",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Customization is overwritten with the target integration's rendered version.
updated = shared_script.read_text(encoding="utf-8")
assert "# user customization" not in updated
assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated
def test_switch_preserves_recovered_files(self, tmp_path):
"""Regression for #2918: files marked recovered in the manifest are not overwritten.
When a file already exists on disk before init and is recorded with
``recovered=True``, ``integration use``/``switch`` must not treat it as
managed even when the on-disk hash matches the manifest hash.
"""
import hashlib
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.is_file()
# Simulate a team-customized file that was recorded as recovered:
# write custom content, then update the manifest to record its hash
# with the recovered flag set.
custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
shared_script.write_bytes(custom_bytes)
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
rel = ".specify/scripts/bash/setup-tasks.sh"
manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
manifest_data.setdefault("recovered_files", []).append(rel)
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Recovered file must NOT be overwritten — team content preserved.
assert shared_script.read_bytes() == custom_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
Copilot follow-up on #2375: leaf-only symlink check let writes escape
when an *ancestor* directory was symlinked outside the project root.
"""
import sys
if sys.platform.startswith("win"):
import pytest as _pytest
_pytest.skip("Symlink creation typically requires admin on Windows")
project = _init_project(tmp_path, "claude")
bash_dir = project / ".specify" / "scripts" / "bash"
outside = tmp_path / "outside"
outside.mkdir()
for child in bash_dir.iterdir():
child.rename(outside / child.name)
bash_dir.rmdir()
bash_dir.symlink_to(outside, target_is_directory=True)
sentinel = (outside / "common.sh").read_bytes()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# Symlinked tree reported, not written through.
assert "symlink" in result.output.lower()
# Outside dir contents unchanged.
assert (outside / "common.sh").read_bytes() == sentinel
def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
"""--force (uninstall semantics) must NOT overwrite shared-infra customizations.
Regression: ensures the decoupling of --force and --refresh-shared-infra.
"""
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
bundled_bytes = shared_script.read_bytes()
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# --force alone preserves the customization
assert shared_script.read_bytes() == custom_bytes
def test_switch_from_nothing(self, tmp_path):
"""Switch when no integration is installed should just install the target."""
project = tmp_path / "bare"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "Switched to" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "switch", "generic",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "codex"
assert data["installed_integrations"] == ["codex"]
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "codex"
assert opts["ai"] == "codex"
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
class TestIntegrationUpgrade:
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "manifest" in result.output
assert "unreadable" in result.output
def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
init_options = project / ".specify" / "init-options.json"
opts = json.loads(init_options.read_text(encoding="utf-8"))
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--force",
])
assert result.exit_code == 0, result.output
updated = json.loads(init_options.read_text(encoding="utf-8"))
assert updated["speckit_version"] == "0.8.11"
def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "gemini")
install = _run_in_project(project, [
"integration", "install", "claude",
"--script", "sh",
])
assert install.exit_code == 0, install.output
init_options = project / ".specify" / "init-options.json"
opts = json.loads(init_options.read_text(encoding="utf-8"))
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli.integrations._commands as _int_cmds
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, result.output
updated = json.loads(init_options.read_text(encoding="utf-8"))
assert updated["speckit_version"] == "0.8.11"
assert updated["integration"] == "gemini"
assert updated["ai"] == "gemini"
assert "context_file" not in updated
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
init_options = project / ".specify" / "init-options.json"
manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"
before_state = json.loads(int_json.read_text(encoding="utf-8"))
before_options = json.loads(init_options.read_text(encoding="utf-8"))
before_manifest = manifest_path.read_text(encoding="utf-8")
import specify_cli
real_install_shared_infra = specify_cli._install_shared_infra
calls = {"count": 0}
def fail_refresh(*args, **kwargs):
calls["count"] += 1
if calls["count"] == 2:
raise ValueError("refuse refresh")
return real_install_shared_infra(*args, **kwargs)
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--force",
])
assert result.exit_code != 0
assert "Failed to refresh shared infrastructure" in result.output
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
assert manifest_path.read_text(encoding="utf-8") == before_manifest
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
project = _init_project(tmp_path, "copilot")
template = project / ".specify" / "templates" / "plan-template.md"
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
assert "/speckit.specify" in managed_script.read_text(encoding="utf-8")
customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n"
customized_script.write_text(customized_before, encoding="utf-8")
result = _run_in_project(project, [
"integration", "upgrade", "copilot",
"--integration-options", "--skills",
])
assert result.exit_code == 0, result.output
assert "/speckit-plan" in template.read_text(encoding="utf-8")
managed_content = managed_script.read_text(encoding="utf-8")
assert "/speckit-specify" in managed_content
assert "/speckit.specify" not in managed_content
assert customized_script.read_text(encoding="utf-8") == customized_before
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
project = _init_project(tmp_path, "gemini")
template = project / ".specify" / "templates" / "plan-template.md"
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
assert "/speckit.plan" in script.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "upgrade", "claude",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "gemini"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
assert "/speckit.plan" in script.read_text(encoding="utf-8")
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
project = _init_project(tmp_path, "opencode")
# Simulate a legacy project: rename commands/ back to command/
canonical = project / ".opencode" / "commands"
legacy = project / ".opencode" / "command"
assert canonical.is_dir(), "init should have created .opencode/commands/"
canonical.rename(legacy)
assert legacy.is_dir()
assert not canonical.exists()
# Patch the manifest to reflect old paths (command/ not commands/)
manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
patched_files = {}
for path, info in manifest_data.get("files", {}).items():
patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info
manifest_data["files"] = patched_files
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
old_commands = sorted(legacy.glob("speckit.*.md"))
assert len(old_commands) > 0, "Legacy dir should have speckit command files"
result = _run_in_project(project, [
"integration", "upgrade", "opencode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, f"upgrade failed: {result.output}"
# New commands in canonical dir
assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade"
new_commands = sorted(canonical.glob("speckit.*.md"))
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
# Stale files removed from legacy dir (extension-installed commands
# like agent-context.update may still appear — only check the original
# core command stems that should have been migrated).
core_remaining = [
f for f in legacy.glob("speckit.*.md")
if "agent-context" not in f.name
]
assert len(core_remaining) == 0, (
f"Legacy .opencode/command/ should have no core speckit files after upgrade, "
f"found: {[f.name for f in core_remaining]}"
)
def test_upgrade_preserves_existing_vscode_settings(self, tmp_path):
"""Regression: copilot upgrade must not stale-delete .vscode/settings.json.
On init the file is created and recorded in the manifest. On upgrade,
setup() merges into the now-existing file and intentionally stops
tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2
stale cleanup would delete it (destroying the user's settings).
"""
project = _init_project(tmp_path, "copilot")
settings = project / ".vscode" / "settings.json"
assert settings.is_file(), "init should create .vscode/settings.json"
before = json.loads(settings.read_text(encoding="utf-8"))
assert before, "settings.json should contain managed defaults"
# Simulate a user editing their settings: add a custom key that the
# integration does not manage. It must survive the upgrade.
before["editor.fontSize"] = 17
settings.write_text(json.dumps(before), encoding="utf-8")
result = _run_in_project(project, [
"integration", "upgrade", "copilot",
"--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
assert settings.is_file(), ".vscode/settings.json must survive upgrade"
after = json.loads(settings.read_text(encoding="utf-8"))
assert after.get("editor.fontSize") == 17, (
"user-defined settings must be preserved after upgrade"
)
def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path):
"""Regression: scripts refreshed by the managed-refresh step stay +x."""
if os.name == "nt":
pytest.skip("POSIX execute bits are not meaningful on Windows")
project = _init_project(tmp_path, "copilot")
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
assert script.is_file()
# Simulate a perms-losing install (e.g. wheel extraction dropping +x).
script.chmod(0o644)
assert not (script.stat().st_mode & 0o111)
result = _run_in_project(project, [
"integration", "upgrade", "copilot",
"--script", "sh",
])
assert result.exit_code == 0, result.output
assert script.stat().st_mode & 0o111, (
"shared .sh scripts must be executable after upgrade"
)
def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path):
"""Upgrade re-registers enabled extensions for the upgraded agent.
Regression for #2886: agents installed before extension back-fill
existed (or whose extension artifacts went missing) should regain the
enabled extensions' commands on ``upgrade``, reaching parity with
``switch``.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Simulate a project created before the install/upgrade back-fill: drop
# codex's extension registration and its rendered artifacts.
registry_path = project / ".specify" / "extensions" / ".registry"
registry = json.loads(registry_path.read_text(encoding="utf-8"))
registry["extensions"]["git"]["registered_commands"].pop("codex", None)
registry_path.write_text(json.dumps(registry), encoding="utf-8")
agents_skills = project / ".agents" / "skills"
for skill_dir in agents_skills.glob("speckit-git-*"):
shutil.rmtree(skill_dir)
# Precondition: codex is now missing the git extension.
assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
result = _run_in_project(project, [
"integration", "upgrade", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Upgrade back-filled the git extension for codex.
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, "upgrade should re-register extension commands (#2886)"
assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
"""Upgrading a non-active agent must not touch the active agent's skills.
Regression for the #2886 wiring: extension skill rendering is
active-agent-scoped, so routing upgrade of a *secondary* agent through
``register_enabled_extensions_for_agent`` used to re-render the
*active* skills-mode agent's extension skills as a side effect —
resurrecting skill files the user had deliberately deleted. The skills
pass is now gated on the target being the active agent. (Skills parity
for non-active agents is tracked separately in #2948.)
"""
# Active agent: copilot in skills mode → git extension renders as skills.
project = _init_project(tmp_path, "copilot", integration_options="--skills")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
assert skill.exists(), "precondition: active copilot has the git extension skill"
# Add a secondary (non-active) agent; copilot is not multi_install_safe.
result = _run_in_project(project, [
"integration", "install", "codex", "--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
# The user deliberately removes the active agent's git skill.
shutil.rmtree(skill.parent)
assert not skill.exists()
# Upgrading the *non-active* agent must not re-render copilot's skills.
result = _run_in_project(project, [
"integration", "upgrade", "codex", "--script", "sh",
])
assert result.exit_code == 0, result.output
assert not skill.exists(), (
"upgrading a non-active agent must not resurrect the active agent's "
"deleted extension skill (#2886)"
)
# ── Full lifecycle ───────────────────────────────────────────────────
class TestIntegrationLifecycle:
def test_install_modify_uninstall_preserves_modified(self, tmp_path):
"""Full lifecycle: install → modify file → uninstall → verify modified file kept."""
project = tmp_path / "lifecycle"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
# Install
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
assert result.exit_code == 0
assert "installed successfully" in result.output
# Claude uses skills directory
plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
# Modify one file
plan_file.write_text("# user customization\n", encoding="utf-8")
# Uninstall
result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
assert result.exit_code == 0
assert "preserved" in result.output
# Modified file kept
assert plan_file.exists()
assert plan_file.read_text(encoding="utf-8") == "# user customization\n"
finally:
os.chdir(old_cwd)
# ── Edge-case fixes ─────────────────────────────────────────────────
class TestScriptTypeValidation:
def test_invalid_script_type_rejected(self, tmp_path):
"""--script with an invalid value should fail with a clear error."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "bash",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Invalid script type" in result.output
def test_valid_script_types_accepted(self, tmp_path):
"""Both 'sh' and 'ps' should be accepted."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
class TestParseIntegrationOptionsEqualsForm:
def test_equals_form_parsed(self):
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
from specify_cli.integrations._commands import _parse_integration_options
from specify_cli.integrations import get_integration
integration = get_integration("generic")
assert integration is not None
result_space = _parse_integration_options(integration, "--commands-dir ./mydir")
result_equals = _parse_integration_options(integration, "--commands-dir=./mydir")
assert result_space is not None
assert result_equals is not None
assert result_space["commands_dir"] == "./mydir"
assert result_equals["commands_dir"] == "./mydir"
class TestUninstallNoManifestClearsInitOptions:
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
"""When no manifest exists, uninstall should still clear init-options.json."""
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
# Write integration.json and init-options.json without a manifest
int_json = project / ".specify" / "integration.json"
int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8")
opts_json = project / ".specify" / "init-options.json"
opts_json.write_text(json.dumps({
"integration": "claude",
"ai": "claude",
"ai_skills": True,
"script": "sh",
}), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# init-options.json should have integration keys cleared
opts = json.loads(opts_json.read_text(encoding="utf-8"))
assert "integration" not in opts
assert "ai" not in opts
assert "ai_skills" not in opts
# Non-integration keys preserved
assert opts.get("script") == "sh"
class TestSwitchClearsMetadataAfterTeardown:
def test_metadata_cleared_between_phases(self, tmp_path):
"""After a successful switch, metadata should reference the new integration."""
project = _init_project(tmp_path, "claude")
# Verify initial state
int_json = project / ".specify" / "integration.json"
assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude"
old_cwd = os.getcwd()
try:
os.chdir(project)
# Switch to copilot — should succeed and update metadata
result = runner.invoke(app, [
"integration", "switch", "copilot",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# integration.json should reference copilot, not claude
data = json.loads(int_json.read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
# init-options.json should reference copilot
opts_json = project / ".specify" / "init-options.json"
opts = json.loads(opts_json.read_text(encoding="utf-8"))
assert opts.get("ai") == "copilot"