Files
github-spec-kit/tests/integrations/test_integration_subcommand.py
Ahmet TOK 1150d32aee Add Zed integration (#2780)
* feat: add Zed integration

* fix: update integrations stats grid to 31 for consistency

* fix: address Copilot review feedback

- Remove non-actionable --skills flag from ZedIntegration (Zed is always
  skills-based, like Agy)
- Align zed_skill_mode predicate with ai_skills for consistency across
  init output and hook rendering
- Consolidate claude/cursor/zed slash-skill return blocks in
  _render_hook_invocation to reduce duplication
- Override test_options_include_skills_flag for Zed (no --skills flag)

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: address Copilot review round 2

- Make zed_skill_mode unconditional in hook rendering (Zed is always
  skills-based, no --skills option)
- Add test_init_persists_ai_skills_for_zed that exercises the actual
  CLI init path and verifies HookExecutor renders /speckit-plan
  without manual init-options manipulation

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: address copilot review feedback for zed integration

- Update integration count from 31 to 33 in docs/index.md (32 integrations + Generic)
- Make zed_skill_mode unconditional to match extensions.py behavior
- Consolidate slash-skill integrations into a set for consistency
- Move os import to module level in test_integration_zed.py

* fix: refine slash-skill logic and ai-skills validation

- Fix slash-skill integrations: Claude/Cursor require ai_skills=true; Zed/Agy/Devin are always skills
- Allow --ai-skills with --integration (not just --ai) to fix validation error

* fix: remove unused variables and update ai-skills help text

- Add agy_skill_mode and devin_skill_mode variables to fix F841 lint error
- Use all skill mode variables in the slash-skill conditional check
- Update --ai-skills help text to reflect it works with --integration too

* fix: add trae_skill_mode to hook invocation for consistency

Trae is a SkillsIntegration like Zed/Agy/Devin, so it should also be treated
as always-skills-based in hook invocation rendering.

* fix: make Agy always skills-based for consistency

AgyIntegration is a SkillsIntegration subclass with no --skills option,
so it should be treated as always skills-based (like Zed, Devin, Trae).
This aligns init.py skill mode detection with extensions.py hook rendering.

* fix: gate agy_skill_mode and refactor _render_hook_invocation to use sets

Addressed Copilot review comments:

- Restored _is_skills_integration guard on agy_skill_mode in init.py
  to be defensive about runtime integration type.
- Refactored _render_hook_invocation() in extensions.py to use
  always_slash/conditional_slash frozensets instead of individual
  per-agent booleans, eliminating unused variables (F841) and making
  it harder for conditions to drift between integrations.
- Centralized slash-skill determination so adding a new unconditional
  slash-skill integration is a one-key addition.

* fix: address latest Copilot review comments

- Added copilot to CONDITIONAL_SLASH_AGENTS for consistent
  hook invocation rendering with init.py
- Moved always_slash/conditional_slash frozensets to module
  scope to avoid per-call reallocation
- Replaced manual os.chdir() with monkeypatch.chdir() in test
- Overrode test_options_include_skills_flag for Zed (no --skills)

* fix: address latest Copilot review comments

- Removed redundant local import yaml in _register_extension_skills
  (yaml is already imported at module scope)
- Split --ai-skills usage hint into two separate print statements
  for better readability
- Changed integrations count from '33' to '30+' to avoid future drift

* fix: re-add _is_skills_integration definition lost in merge

The _is_skills_integration variable was accidentally dropped during the
web UI merge resolution of upstream/main's removal of legacy --ai flags.
Re-added the definition via isinstance(resolved_integration, SkillsIntegration)
check so that skill-mode booleans work correctly.

* fix: gate zed_skill_mode on _is_skills_integration for consistency

Aligns zed_skill_mode with the other skills-based agents (codex, claude,
cursor-agent, copilot) which all use _is_skills_integration gating.
Since ZedIntegration extends SkillsIntegration, behavior is unchanged.

* fix: remove unused claude_skill_mode and cursor_skill_mode locals in _render_hook_invocation

These variables became unused after the refactor to ALWAYS_SLASH_AGENTS /
CONDITIONAL_SLASH_AGENTS sets. Claude and Cursor-Agent are now handled by the
CONDITIONAL_SLASH_AGENTS path, so the separate boolean locals are dead code.

Fixes ruff F841 and addresses Copilot review feedback that was repeated across
multiple review rounds.

* fix: align agy/trae invocation format in init next-steps with hook rendering and build_command_invocation

- Moved agy and trae from '-<name>' (dollar/Codex format) to
  '/speckit-<name>' (slash format) in _display_cmd() to match:
  - HookExecutor._render_hook_invocation() (ALWAYS_SLASH_AGENTS for trae,
    CONDITIONAL_SLASH_AGENTS for agy)
  - SkillsIntegration.build_command_invocation() (default: /speckit-<name>)
- The '$' prefix is specific to Codex; all other skills agents use '/'.

* fix: address Copilot review comments on hook invocation consistency

- Add is_slash_skills_agent() helper to extensions.py to centralize the
  agent-to-invocation-format mapping, reducing drift risk between
  HookExecutor._render_hook_invocation() and init.py _display_cmd()
- Use the shared helper in both locations; init.py now imports and
  delegates to is_slash_skills_agent() instead of maintaining its own
  per-agent boolean matrix
- Fix test_hooks_render_skill_invocation to use ai_skills=False,
  proving Zed renders /speckit-<name> unconditionally
- Add parameterized TestSlashSkillsSets covering all agents in
  ALWAYS_SLASH_AGENTS and CONDITIONAL_SLASH_AGENTS with ai_skills
  both true and false

* fix: address Copilot review comments on type safety and test API

- Make is_slash_skills_agent() accept str | None to match its call sites
  (init_options.get("ai") can return None)
- Refactor TestSlashSkillsSets to use public execute_hook() API instead of
  private _render_hook_invocation() method

* fix: address Copilot review comments on typing and naming clarity

- Add from __future__ import annotations to extensions.py so PEP 604
  unions (str | None) are safe regardless of Python version
- Add clarifying _ai_skills_enabled local variable in init.py's
  _display_cmd() to make the semantic meaning explicit when passing it
  to is_slash_skills_agent()

* fix: move invocation-style logic into shared _invocation_style module

- Extract ALWAYS_SLASH_AGENTS, CONDITIONAL_SLASH_AGENTS, and
  is_slash_skills_agent() from extensions.py into new _invocation_style.py
  module, eliminating the awkward init.py -> extensions.py import
  dependency for invocation-style decision logic
- Both HookExecutor._render_hook_invocation() and init.py _display_cmd()
  now import from the shared module instead of one subsystem importing
  from the other
- Revert /SKILL.md change: the leading slash is semantically significant
  (path component vs filename suffix)

* fix: add None guard before i.options() in test_options_include_skills_flag

get_integration() returns IntegrationBase | None, so i.options()
is a type error without a None check.

* fix: override test_options_include_skills_flag for Zed (always skills, no --skills flag)

Zed is always skills-based and doesn't expose a --skills option.
Override the inherited base test to assert --skills is absent.

* fix: rename test and skip inherited test_options_include_skills_flag for Zed

- Skip inherited test_options_include_skills_flag (not applicable — Zed
  is always skills-based with no --skills flag)
- Add test_options_do_not_include_skills_flag with correct name matching
  the assertion (--skills is absent)

* fix: add defensive non-string check in is_slash_skills_agent

Reject non-string values for selected_ai to prevent TypeError from
set membership checks when persisted init-options contain corrupted
data (e.g. list or dict instead of string).

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 17:29:08 -05:00

2437 lines
101 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"):
"""Helper: init a spec-kit project with the given integration."""
project = tmp_path / "proj"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--ignore-agent-tools",
], 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
# ── 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" / "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_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]}"
)
# ── 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"