"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch)."""

import json
import os
import shutil
from pathlib import Path

import pytest
from typer.testing import CliRunner

from specify_cli import app
from tests.conftest import strip_ansi


runner = CliRunner()


def _init_project(tmp_path, integration="copilot", integration_options=None):
    """Helper: init a spec-kit project with the given integration."""
    project = tmp_path / "proj"
    project.mkdir()
    args = [
        "init", "--here",
        "--integration", integration,
        "--script", "sh",
        "--ignore-agent-tools",
    ]
    if integration_options:
        args += ["--integration-options", integration_options]
    old_cwd = os.getcwd()
    try:
        os.chdir(project)
        result = runner.invoke(app, args, catch_exceptions=False)
    finally:
        os.chdir(old_cwd)
    assert result.exit_code == 0, f"init failed: {result.output}"
    return project


def _run_in_project(project, args):
    """Run a CLI command from inside a generated project."""
    old_cwd = os.getcwd()
    try:
        os.chdir(project)
        return runner.invoke(app, args, catch_exceptions=False)
    finally:
        os.chdir(old_cwd)


def _write_invalid_manifest(project, key):
    manifest = project / ".specify" / "integrations" / f"{key}.manifest.json"
    manifest.write_bytes(b"\xff\xfe\x00")
    return manifest


def _copy_project_template(tmp_path, template):
    project = tmp_path / "proj"
    shutil.copytree(template, project)
    return project


@pytest.fixture(scope="module")
def status_copilot_template(tmp_path_factory):
    return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot")


@pytest.fixture(scope="module")
def status_claude_template(tmp_path_factory):
    return _init_project(tmp_path_factory.mktemp("status-claude"), "claude")


@pytest.fixture
def copilot_project(tmp_path, status_copilot_template):
    return _copy_project_template(tmp_path, status_copilot_template)


@pytest.fixture
def claude_project(tmp_path, status_claude_template):
    return _copy_project_template(tmp_path, status_claude_template)


def _integration_list_row_cells(output: str, key: str) -> list[str]:
    plain = strip_ansi(output)
    row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
    return [cell.strip() for cell in row.split("│")[1:-1]]


# ── list ─────────────────────────────────────────────────────────────


class TestIntegrationList:
    def test_list_requires_speckit_project(self, tmp_path):
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["integration", "list"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Not a spec-kit project" in result.output

    def test_list_shows_installed(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "list"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "copilot" in result.output
        assert "installed" in result.output

    def test_list_shows_available_integrations(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "list"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        # Should show multiple integrations
        assert "claude" in result.output
        assert "gemini" in result.output
        assert "zed" in result.output

    def test_list_shows_multi_install_safe_status(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "list"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "Multi-install" in result.output
        assert "Safe" in result.output
        assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
        assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"

    def test_list_rejects_newer_integration_state_schema(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        int_json = project / ".specify" / "integration.json"
        data = json.loads(int_json.read_text(encoding="utf-8"))
        data["integration_state_schema"] = 99
        int_json.write_text(json.dumps(data), encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "list"])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code != 0
        normalized = " ".join(result.output.split())
        assert "schema 99" in normalized
        assert "only supports schema 1" in normalized


# ── status ───────────────────────────────────────────────────────────


class TestIntegrationStatus:
    def test_status_requires_speckit_project(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["integration", "status"])
        assert result.exit_code != 0
        assert "Not a spec-kit project" in result.output

    def test_status_reports_healthy_project(self, copilot_project):
        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code == 0
        assert "Integration status: OK" in result.output
        assert "Default integration: copilot" in result.output
        assert "Installed integrations: copilot" in result.output
        assert "Shared templates target alignment: copilot" in result.output
        assert "Modified managed files: 0" in result.output
        assert "Missing managed files: 0" in result.output

    def test_status_json_reports_healthy_project(self, copilot_project):
        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "ok"
        assert payload["default_integration"] == "copilot"
        assert payload["installed_integrations"] == ["copilot"]
        assert payload["recorded_installed_integrations"] == ["copilot"]
        assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
        assert payload["multi_install_safe"] is True
        assert payload["shared_templates_target_alignment"] == "copilot"
        assert "shared_templates_aligned_to" not in payload
        assert payload["findings"] == []

    def test_status_reports_invalid_integration_json(self, copilot_project):
        (copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "integration-state-unreadable" in result.output
        assert "invalid JSON" in result.output
        assert "Detail:" in result.output
        assert "Multi-install safe: unknown" in result.output
        assert "Traceback" not in result.output

    def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable(
        self,
        copilot_project,
    ):
        (copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["status"] == "error"
        assert payload["multi_install_safe"] is None
        assert payload["manifest_checked_integrations"] == []
        assert payload["findings"][0]["code"] == "integration-state-unreadable"
        assert "Detail:" in payload["findings"][0]["message"]

    def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project):
        state_path = copilot_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["integration_state_schema"] = 99
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["findings"][0]["code"] == "integration-state-unreadable"
        assert "schema 99" in payload["findings"][0]["message"]
        assert "supported schema: 1" in payload["findings"][0]["message"]

    def test_status_reports_missing_integration_json(self, copilot_project):
        (copilot_project / ".specify" / "integration.json").unlink()

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "integration-state-missing" in result.output
        assert ".specify/integration.json is missing" in result.output
        assert "Multi-install safe: unknown" in result.output

    def test_status_json_reports_unknown_multi_install_safety_when_state_missing(
        self,
        copilot_project,
    ):
        (copilot_project / ".specify" / "integration.json").unlink()

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["status"] == "error"
        assert payload["multi_install_safe"] is None
        assert payload["manifest_checked_integrations"] == []
        assert payload["findings"][0]["code"] == "integration-state-missing"

    def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project):
        state_path = copilot_project / ".specify" / "integration.json"
        state_path.write_text(
            json.dumps({
                "version": "test",
                "integration_state_schema": 1,
                "installed_integrations": [],
            }),
            encoding="utf-8",
        )

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "warning"
        assert payload["installed_integrations"] == []
        assert payload["multi_install_safe"] is None
        assert payload["manifest_checked_integrations"] == ["speckit"]
        assert payload["findings"][0]["code"] == "no-installed-integrations"
        assert "speckit" in payload["manifests"]
        assert payload["manifests"]["speckit"]["readable"] is True

    def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project):
        state_path = copilot_project / ".specify" / "integration.json"
        state_path.write_text(
            json.dumps({
                "version": "test",
                "integration_state_schema": 1,
                "installed_integrations": [],
            }),
            encoding="utf-8",
        )
        (copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink()

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["status"] == "error"
        assert payload["installed_integrations"] == []
        assert payload["manifest_checked_integrations"] == ["speckit"]
        assert payload["unchecked_manifests"] == 1
        assert any(
            item["code"] == "no-installed-integrations"
            for item in payload["findings"]
        )
        assert any(
            item["code"] == "manifest-missing"
            and item["integration"] == "speckit"
            for item in payload["findings"]
        )

    def test_status_json_reports_missing_default_integration_as_error(self, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state.pop("default_integration", None)
        state.pop("integration", None)
        state["installed_integrations"] = ["claude"]
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["status"] == "error"
        assert payload["default_integration"] is None
        assert any(
            item["code"] == "default-integration-missing"
            for item in payload["findings"]
        )

    def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project):
        state_path = copilot_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state.pop("default_integration", None)
        state.pop("integration", None)
        state["installed_integrations"] = "copilot"
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "warning"
        assert payload["installed_integrations"] == []
        assert payload["recorded_installed_integrations"] == []
        assert payload["manifest_checked_integrations"] == ["speckit"]
        assert payload["multi_install_safe"] is None
        assert [item["code"] for item in payload["findings"]] == [
            "installed-integrations-invalid",
            "no-installed-integrations",
        ]

    def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project):
        state_path = copilot_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["default_integration"] = "copilot"
        state["integration"] = "copilot"
        state["installed_integrations"] = "copilot"
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "warning"
        assert payload["installed_integrations"] == ["copilot"]
        assert payload["recorded_installed_integrations"] == []
        assert payload["manifest_checked_integrations"] == ["copilot", "speckit"]
        assert payload["multi_install_safe"] is None
        assert [item["code"] for item in payload["findings"]] == [
            "installed-integrations-invalid",
        ]

    def test_status_reports_default_integration_not_installed(self, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["default_integration"] = "codex"
        state["integration"] = "codex"
        state["installed_integrations"] = ["claude"]
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["default_integration"] == "codex"
        assert payload["installed_integrations"] == ["codex", "claude"]
        assert payload["recorded_installed_integrations"] == ["claude"]
        assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
        assert any(
            item["code"] == "default-integration-not-installed"
            and "Default integration 'codex' is not listed" in item["message"]
            for item in payload["findings"]
        )
        assert "codex" not in payload["manifests"]
        assert not any(
            item["code"] == "manifest-missing" and item.get("integration") == "codex"
            for item in payload["findings"]
        )

    def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["installed_integrations"] = []
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["installed_integrations"] == ["claude"]
        assert payload["recorded_installed_integrations"] == []
        assert payload["manifest_checked_integrations"] == ["claude", "speckit"]
        assert payload["multi_install_safe"] is None
        assert payload["manifests"]["claude"]["readable"] is True
        assert any(
            item["code"] == "default-integration-not-installed"
            for item in payload["findings"]
        )

    def test_status_reports_missing_manifest(self, copilot_project):
        (copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink()

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "manifest-missing" in result.output
        assert "Manifest for integration 'copilot' is missing" in result.output

    def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project):
        _write_invalid_manifest(copilot_project, "copilot")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["unchecked_manifests"] == 1
        assert payload["manifests"]["copilot"]["readable"] is False
        assert payload["manifests"]["copilot"]["missing_files"] == []
        assert payload["manifests"]["copilot"]["modified_files"] == []

    def test_status_reports_modified_managed_files_without_failing(self, copilot_project):
        manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
        tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
        first_rel = next(iter(tracked_files))
        (copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code == 0
        assert "Integration status: WARNING" in result.output
        assert "managed-files-modified" in result.output
        assert "Modified managed files: 1" in result.output

    def test_status_reports_missing_managed_files(self, copilot_project):
        manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
        tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
        first_rel = next(iter(tracked_files))
        (copilot_project / first_rel).unlink()

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "managed-files-missing" in result.output
        assert "Missing managed files: 1" in result.output

    def test_status_reports_missing_shared_managed_files(self, copilot_project):
        shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh"
        assert shared_file.exists()
        shared_file.unlink()

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "managed-files-missing" in result.output
        assert "shared Spec Kit infrastructure" in result.output
        assert "Missing managed files: 1" in result.output

    def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch):
        from specify_cli.integration_status import _manifest_file_status
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "proj"
        project.mkdir()
        tracked = project / "tracked.md"
        tracked.write_text("content\n", encoding="utf-8")
        manifest = IntegrationManifest("test", project, version="test")
        manifest.record_existing("tracked.md")

        def fail_exists(self):
            raise AssertionError(f"Path.exists() should not be used for {self}")

        monkeypatch.setattr(Path, "exists", fail_exists)

        missing, modified, invalid, valid = _manifest_file_status(
            manifest,
            project.resolve(),
        )

        assert missing == []
        assert modified == []
        assert invalid == []
        assert valid == ["tracked.md"]

    def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch):
        def fail_exists(self):
            raise AssertionError(f"Path.exists() should not be used for {self}")

        monkeypatch.setattr(Path, "exists", fail_exists)

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "ok"
        assert payload["manifests"]["copilot"]["readable"] is True

    def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch):
        original_resolve = Path.resolve
        failed = {"done": False}

        def fail_first_project_root_resolve(self, *args, **kwargs):
            if self == copilot_project and not failed["done"]:
                failed["done"] = True
                raise RuntimeError("symlink loop")
            return original_resolve(self, *args, **kwargs)

        monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve)

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "warning"
        assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])

    def test_status_loads_manifests_when_project_root_resolution_keeps_failing(
        self,
        copilot_project,
        monkeypatch,
    ):
        original_resolve = Path.resolve

        def fail_project_root_resolve(self, *args, **kwargs):
            if self == copilot_project:
                raise RuntimeError("symlink loop")
            return original_resolve(self, *args, **kwargs)

        monkeypatch.setattr(Path, "resolve", fail_project_root_resolve)

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code == 0, result.output
        payload = json.loads(result.output)
        assert payload["status"] == "warning"
        assert payload["manifests"]["copilot"]["readable"] is True
        assert payload["manifests"]["speckit"]["readable"] is True
        assert any(item["code"] == "project-root-unresolved" for item in payload["findings"])

    def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path):
        from specify_cli.integration_status import _manifest_file_status
        from specify_cli.integrations.manifest import IntegrationManifest

        real_project = tmp_path / "real-project"
        real_project.mkdir()
        tracked = real_project / "tracked.md"
        tracked.write_text("content\n", encoding="utf-8")
        symlinked_project = tmp_path / "symlinked-project"
        try:
            symlinked_project.symlink_to(real_project, target_is_directory=True)
        except OSError as exc:
            pytest.skip(f"symlinks unavailable: {exc}")

        manifest = IntegrationManifest("test", real_project, version="test")
        manifest.record_existing("tracked.md")
        manifest.project_root = symlinked_project.absolute()

        missing, modified, invalid, valid = _manifest_file_status(
            manifest,
            symlinked_project.absolute(),
            project_root_is_resolved=False,
        )

        assert missing == []
        assert modified == []
        assert invalid == []
        assert valid == ["tracked.md"]

    def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch):
        from specify_cli.integration_status import _manifest_file_status
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "proj"
        project.mkdir()
        tracked = project / "tracked.md"
        tracked.write_text("content\n", encoding="utf-8")
        manifest = IntegrationManifest("test", project, version="test")
        manifest.record_existing("tracked.md")
        project_root_resolved = project.resolve()
        original_resolve = Path.resolve

        def fail_project_parent_resolve(self, *args, **kwargs):
            if self == project:
                raise RuntimeError("symlink loop")
            return original_resolve(self, *args, **kwargs)

        monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve)

        missing, modified, invalid, valid = _manifest_file_status(
            manifest,
            project_root_resolved,
        )

        assert missing == []
        assert modified == []
        assert invalid == ["tracked.md"]
        assert valid == []

    def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch):
        from specify_cli import integration_status as status_module

        def fail_load(key, project_root, **kwargs):
            raise RuntimeError(f"unexpected manifest loader bug for {key}")

        monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load)

        with pytest.raises(RuntimeError, match="unexpected manifest loader bug"):
            status_module.build_integration_status_report(copilot_project)

    def test_status_treats_dangling_symlink_as_missing(self, copilot_project):
        manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
        tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
        first_rel = next(iter(tracked_files))
        target = copilot_project / first_rel
        target.unlink()
        try:
            target.symlink_to(copilot_project / "missing-target")
        except OSError as exc:
            pytest.skip(f"symlinks unavailable: {exc}")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert first_rel in payload["manifests"]["copilot"]["missing_files"]
        assert first_rel not in payload["manifests"]["copilot"]["modified_files"]

    def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch):
        from specify_cli.integration_status import _manifest_file_status
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "proj"
        project.mkdir()
        tracked = project / "tracked.md"
        tracked.write_text("content\n", encoding="utf-8")
        regular_stat = tracked.lstat()

        manifest = IntegrationManifest("test", project, version="test")
        manifest.record_existing("tracked.md")

        tracked.unlink()
        try:
            tracked.symlink_to(project / "missing-target")
        except OSError as exc:
            pytest.skip(f"symlinks unavailable: {exc}")

        original_lstat = Path.lstat
        original_is_symlink = Path.is_symlink

        def windows_style_lstat(self):
            if self == tracked:
                return regular_stat
            return original_lstat(self)

        def windows_style_is_symlink(self):
            if self == tracked:
                return True
            return original_is_symlink(self)

        monkeypatch.setattr(Path, "lstat", windows_style_lstat)
        monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink)

        missing, modified, invalid, valid = _manifest_file_status(
            manifest,
            project.resolve(),
        )

        assert missing == ["tracked.md"]
        assert modified == []
        assert invalid == []
        assert valid == ["tracked.md"]

    def test_strip_extended_length_prefix_normalizes_windows_paths(self):
        from specify_cli.integration_status import _strip_extended_length_prefix

        # Build the prefixed strings explicitly so the test is meaningful on
        # every platform (POSIX won't parse backslash separators, but the
        # helper operates on the string form). Compare Path objects rather than
        # their str() form: on Windows pathlib renders a UNC root with a
        # trailing separator (``\\server\share\``), so an exact string match is
        # brittle, whereas Path equality captures the intended semantics on
        # both POSIX and Windows.
        bs = "\\"
        assert _strip_extended_length_prefix(
            Path(f"{bs}{bs}?{bs}C:{bs}proj")
        ) == Path(f"C:{bs}proj")
        assert _strip_extended_length_prefix(
            Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share")
        ) == Path(f"{bs}{bs}server{bs}share")
        # Paths without the prefix are returned unchanged.
        assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path")

    def test_is_within_project_tolerates_extended_length_prefix(self):
        from specify_cli.integration_status import _is_within_project

        # A readlink result on POSIX never carries the prefix, so an in-project
        # child is contained and an outside path is not. The Windows
        # prefix-stripping branch is exercised by the dangling-symlink tests on
        # Windows CI; here we lock in the cross-platform containment contract.
        root = Path("/tmp/project").resolve()
        assert _is_within_project(root, root / "child")
        assert not _is_within_project(root, Path("/tmp/other").resolve())

    def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project):
        outside = tmp_path / "outside"
        outside.mkdir()
        (outside / "secret.txt").write_text("outside project\n", encoding="utf-8")
        link = copilot_project / "outside-link"
        try:
            link.symlink_to(outside, target_is_directory=True)
        except OSError as exc:
            pytest.skip(f"symlinks unavailable: {exc}")

        manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
        manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
        manifest_data["files"]["outside-link/secret.txt"] = "wrong"
        manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["invalid_manifest_paths"] == 1
        assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"]
        assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"]

    def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch):
        outside = tmp_path / "outside"
        outside.mkdir()
        outside_file = outside / "secret.txt"
        outside_file.write_text("outside project\n", encoding="utf-8")

        manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json"
        tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
        first_rel = next(iter(tracked_files))
        tracked_path = copilot_project / first_rel
        tracked_path.unlink()
        try:
            tracked_path.symlink_to(outside_file)
        except OSError as exc:
            pytest.skip(f"symlinks unavailable: {exc}")

        original_stat = Path.stat

        def fail_tracked_symlink_stat(self, *args, **kwargs):
            follows_symlinks = kwargs.get("follow_symlinks", True)
            if self == tracked_path and follows_symlinks:
                raise AssertionError("Path.stat() should not follow tracked symlinks")
            return original_stat(self, *args, **kwargs)

        monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat)

        result = _run_in_project(copilot_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["invalid_manifest_paths"] == 1
        assert first_rel in payload["manifests"]["copilot"]["invalid_files"]
        assert first_rel not in payload["manifests"]["copilot"]["modified_files"]

    def test_status_reports_unsafe_multi_install_combination(self, copilot_project):
        from specify_cli.integrations.manifest import IntegrationManifest

        state_path = copilot_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["installed_integrations"] = ["copilot", "claude"]
        state["default_integration"] = "copilot"
        state["integration"] = "copilot"
        state_path.write_text(json.dumps(state), encoding="utf-8")
        IntegrationManifest("claude", copilot_project, version="test").save()

        result = _run_in_project(copilot_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "unsafe-multi-install" in result.output
        assert "Multi-install safe: no" in result.output
        assert "specify integration switch <key>" in result.output

    def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project):
        from specify_cli.integrations.manifest import IntegrationManifest

        state_path = claude_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["installed_integrations"] = ["claude", "mystery"]
        state["default_integration"] = "claude"
        state["integration"] = "claude"
        state_path.write_text(json.dumps(state), encoding="utf-8")
        IntegrationManifest("mystery", claude_project, version="test").save()

        result = _run_in_project(claude_project, ["integration", "status"])

        assert result.exit_code != 0
        assert "unknown-integration" in result.output
        assert "unsafe-multi-install" in result.output
        assert "remove the stale integration entry" in result.output
        assert "Multi-install safe: no" in result.output

    def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["installed_integrations"] = ["mystery"]
        state["default_integration"] = "mystery"
        state["integration"] = "mystery"
        state_path.write_text(json.dumps(state), encoding="utf-8")

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        manifest_finding = next(
            item for item in payload["findings"]
            if item["code"] == "manifest-missing" and item["integration"] == "mystery"
        )
        assert "remove the stale integration entry" in manifest_finding["suggestion"]
        assert "integration upgrade mystery" not in manifest_finding["suggestion"]

    def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        unsafe_key = "../../../escape"
        state_path.write_text(
            json.dumps({
                "integration": unsafe_key,
                "default_integration": unsafe_key,
                "installed_integrations": [unsafe_key],
            }),
            encoding="utf-8",
        )
        outside_manifest = tmp_path / "escape.manifest.json"
        outside_manifest.write_text(
            json.dumps({"integration": unsafe_key, "files": {}}),
            encoding="utf-8",
        )

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert unsafe_key not in payload["manifests"]
        assert payload["manifest_checked_integrations"] == ["speckit"]
        assert any(
            item["code"] == "integration-key-invalid"
            and item["integration"] == unsafe_key
            for item in payload["findings"]
        )

    def test_status_rejects_filename_invalid_integration_keys(self, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        unsafe_key = "bad:key"
        state_path.write_text(
            json.dumps({
                "integration": unsafe_key,
                "default_integration": unsafe_key,
                "installed_integrations": [unsafe_key],
            }),
            encoding="utf-8",
        )

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert any(
            item["code"] == "integration-key-invalid"
            and item["integration"] == unsafe_key
            for item in payload["findings"]
        )

    def test_status_rejects_windows_reserved_integration_keys(self, claude_project):
        state_path = claude_project / ".specify" / "integration.json"
        unsafe_key = "CON"
        state_path.write_text(
            json.dumps({
                "integration": unsafe_key,
                "default_integration": unsafe_key,
                "installed_integrations": [unsafe_key],
            }),
            encoding="utf-8",
        )

        result = _run_in_project(claude_project, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert any(
            item["code"] == "integration-key-invalid"
            and item["integration"] == unsafe_key
            for item in payload["findings"]
        )

    def test_status_reports_managed_file_collisions(self, claude_project):
        from specify_cli.integrations.manifest import IntegrationManifest

        state_path = claude_project / ".specify" / "integration.json"
        state = json.loads(state_path.read_text(encoding="utf-8"))
        state["installed_integrations"] = ["claude", "codex"]
        state["default_integration"] = "claude"
        state["integration"] = "claude"
        state_path.write_text(json.dumps(state), encoding="utf-8")

        claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json"
        tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"]
        shared_rel = next(iter(tracked_files))
        codex_manifest = IntegrationManifest("codex", claude_project, version="test")
        codex_manifest.record_existing(shared_rel)
        codex_manifest.save()

        result = _run_in_project(claude_project, ["integration", "status"])

        assert result.exit_code == 0
        assert "managed-file-collision" in result.output
        assert "Integration status: WARNING" in result.output

    def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch):
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        (project / ".specify" / "integration.json").write_text(
            json.dumps({
                "integration": "[red]x[/red]",
                "installed_integrations": ["[red]x[/red]"],
            }),
            encoding="utf-8",
        )
        monkeypatch.chdir(project)

        result = runner.invoke(app, ["integration", "status", "--json"])

        assert result.exit_code != 0
        payload = json.loads(result.output)
        assert payload["default_integration"] == "[red]x[/red]"
        assert payload["installed_integrations"] == ["[red]x[/red]"]

    def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch):
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        (project / ".specify" / "integration.json").write_text(
            json.dumps({
                "integration": "[red]x[/red]",
                "installed_integrations": ["[red]x[/red]"],
            }),
            encoding="utf-8",
        )
        monkeypatch.chdir(project)

        result = runner.invoke(app, ["integration", "status"])

        assert result.exit_code != 0
        assert "Default integration: [red]x[/red]" in result.output
        assert "Installed integrations: [red]x[/red]" in result.output


# ── install ──────────────────────────────────────────────────────────


class TestIntegrationInstall:
    def test_install_requires_speckit_project(self, tmp_path):
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["integration", "install", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Not a spec-kit project" in result.output

    def test_install_unknown_integration(self, tmp_path):
        project = _init_project(tmp_path)
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "install", "nonexistent"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Unknown integration" in result.output

    def test_install_already_installed(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "install", "copilot"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        plain = strip_ansi(result.output)
        assert "already installed" in plain
        normalized = " ".join(plain.split())
        assert "specify integration upgrade copilot" in normalized
        assert "already the default integration" in normalized
        assert "No files were changed" in normalized
        assert "specify integration uninstall copilot" not in normalized

    def test_install_already_installed_non_default_guides_use(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, ["integration", "install", "codex"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        output = strip_ansi(result.output)
        normalized = " ".join(output.split())
        assert "already installed" in normalized
        assert "specify integration use codex" in normalized
        assert "specify integration upgrade codex" in normalized
        assert "specify integration uninstall codex" not in normalized

    def test_install_different_when_one_exists(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "install", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        plain = strip_ansi(result.output)
        assert "Installed integrations: copilot" in plain
        assert "Default integration: copilot" in plain
        normalized = " ".join(plain.split())
        assert "To replace the default integration" in normalized
        assert "specify integration switch claude" in normalized
        assert "To install 'claude' alongside" in normalized
        assert "retry the same install command with --force" in normalized

    def test_install_multi_safe_integration(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output
        assert "installed successfully" in result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "claude"
        assert data["default_integration"] == "claude"
        assert data["integration_state_schema"] == 1
        assert data["installed_integrations"] == ["claude", "codex"]
        assert data["integration_settings"]["claude"]["invoke_separator"] == "-"
        assert data["integration_settings"]["codex"]["invoke_separator"] == "-"

        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
        assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()

    def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
        project = _init_project(tmp_path, "claude")
        init_options = project / ".specify" / "init-options.json"
        opts = json.loads(init_options.read_text(encoding="utf-8"))
        opts["speckit_version"] = "0.6.1"
        init_options.write_text(json.dumps(opts), encoding="utf-8")

        import specify_cli.integrations._commands as _int_cmds

        monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")

        result = _run_in_project(project, [
            "integration", "install", "codex",
            "--script", "sh",
        ])

        assert result.exit_code == 0, result.output
        updated = json.loads(init_options.read_text(encoding="utf-8"))
        assert updated["speckit_version"] == "0.8.11"
        assert updated["integration"] == "claude"
        assert updated["ai"] == "claude"
        assert "context_file" not in updated

    def test_install_additional_preserves_shared_manifest(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
        before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
        assert before

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
        assert before <= after

    def test_install_multi_safe_migrates_legacy_state(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        int_json = project / ".specify" / "integration.json"
        int_json.write_text(json.dumps({
            "integration": "claude",
            "version": "0.0.0",
        }), encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        data = json.loads(int_json.read_text(encoding="utf-8"))
        assert data["integration"] == "claude"
        assert data["default_integration"] == "claude"
        assert data["installed_integrations"] == ["claude", "codex"]

    def test_install_multi_unsafe_requires_force(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        plain = strip_ansi(result.output)
        assert "Installed integrations: copilot" in plain
        assert "multi-install safe" in plain
        normalized = " ".join(plain.split())
        assert "To replace the default integration" in normalized
        assert "specify integration switch claude" in normalized
        assert "To install 'claude' alongside" in normalized
        assert "retry the same install command with --force" in normalized

    def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
                "--force",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "copilot"
        assert data["installed_integrations"] == ["copilot", "claude"]

    def test_install_into_bare_project(self, tmp_path):
        """Install into a project with .specify/ but no integration."""
        project = tmp_path / "bare"
        project.mkdir()
        (project / ".specify").mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output
        assert "installed successfully" in result.output

        # integration.json written
        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "claude"

        # Manifest created
        assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()

        # Claude uses skills directory (not commands)
        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()

    def test_install_bare_project_gets_shared_infra(self, tmp_path):
        """Installing into a bare project should create shared scripts and templates."""
        project = tmp_path / "bare"
        project.mkdir()
        (project / ".specify").mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        # Shared infrastructure should be present
        assert (project / ".specify" / "scripts").is_dir()
        assert (project / ".specify" / "templates").is_dir()
        script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        script_content = script.read_text(encoding="utf-8")
        assert "/speckit-specify" in script_content
        assert "/speckit.specify" not in script_content

    def test_install_defers_extension_commands_until_use(self, tmp_path):
        """Installing a second integration does not register enabled extensions.

        Maintainer-requested behavior for #2886: extension command back-fill is
        limited to ``integration use`` / ``switch`` / ``upgrade``. Plain
        ``install`` only adds the integration; selecting it with ``use`` then
        registers the enabled extensions for that agent.
        """
        project = _init_project(tmp_path, "claude")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        registry_path = project / ".specify" / "extensions" / ".registry"
        registered = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert "claude" in registered
        assert "codex" not in registered, "precondition: codex not yet installed"

        result = _run_in_project(project, [
            "integration", "install", "codex",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        # Install alone does not back-fill the git extension for the secondary
        # agent.
        registered = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert "claude" in registered, "existing agent registration preserved"
        assert "codex" not in registered
        assert not (
            project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
        ).exists()

        result = _run_in_project(project, ["integration", "use", "codex"])
        assert result.exit_code == 0, result.output

        registered = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert "codex" in registered, "use should register extension commands (#2886)"
        assert (
            project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
        ).exists()

    def test_install_does_not_register_disabled_extensions(self, tmp_path):
        """A disabled extension must not be registered for a newly installed agent."""
        project = _init_project(tmp_path, "claude")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"
        result = _run_in_project(project, ["extension", "disable", "git"])
        assert result.exit_code == 0, result.output

        result = _run_in_project(project, [
            "integration", "install", "codex",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        registry_path = project / ".specify" / "extensions" / ".registry"
        git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]
        assert git_meta["enabled"] is False
        assert "codex" not in git_meta["registered_commands"]
        assert not (
            project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
        ).exists()

    def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path):
        """A non-active skills-mode agent gets extension artifacts only on use.

        Plain ``install`` has no extension side effects. Once the secondary
        Copilot ``--skills`` integration is selected with ``use``, it becomes the
        active agent and receives extension skills.
        """
        project = _init_project(tmp_path, "claude")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        # Copilot is not multi_install_safe, so --force is required to add it
        # alongside the existing default integration.
        result = _run_in_project(project, [
            "integration", "install", "copilot",
            "--script", "sh",
            "--integration-options", "--skills",
            "--force",
        ])
        assert result.exit_code == 0, result.output

        # Precondition that makes --skills load-bearing: copilot IS in skills
        # mode, so its own core commands are scaffolded as skills.
        assert (
            project / ".github" / "skills" / "speckit-specify" / "SKILL.md"
        ).exists(), "precondition: copilot installed in skills mode"

        # The git extension is not registered for the non-active copilot agent
        # during install.
        git_meta = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )["extensions"]["git"]
        assert "copilot" not in git_meta["registered_commands"]
        assert not (
            project / ".github" / "agents" / "speckit.git.feature.agent.md"
        ).exists()
        assert not (
            project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
        ).exists()

        result = _run_in_project(project, ["integration", "use", "copilot"])
        assert result.exit_code == 0, result.output

        git_meta = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )["extensions"]["git"]
        # `use` makes copilot active, so extension artifacts follow copilot's
        # skills-mode layout.
        assert "copilot" not in git_meta["registered_commands"]
        assert "speckit-git-feature" in git_meta["registered_skills"]
        assert not (
            project / ".github" / "agents" / "speckit.git.feature.agent.md"
        ).exists()
        assert (
            project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
        ).exists()


# ── uninstall ────────────────────────────────────────────────────────


class TestIntegrationUninstall:
    def test_uninstall_requires_speckit_project(self, tmp_path):
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["integration", "uninstall"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Not a spec-kit project" in result.output

    def test_uninstall_no_integration(self, tmp_path):
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "No integration" in result.output

    def test_uninstall_removes_files(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        # Claude uses skills directory
        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
        assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "uninstalled" in result.output

        # Command files removed
        assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()

        # Manifest removed
        assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists()

        # integration.json removed
        assert not (project / ".specify" / "integration.json").exists()

    def test_uninstall_preserves_modified_files(self, tmp_path):
        """Full lifecycle: install → modify → uninstall → modified file kept."""
        project = _init_project(tmp_path, "claude")
        plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
        assert plan_file.exists()

        # Modify a file
        plan_file.write_text("# My custom plan command\n", encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "preserved" in result.output
        assert ".claude/skills/speckit-plan/SKILL.md" in result.output

        # Modified file kept
        assert plan_file.exists()
        assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n"

    def test_uninstall_wrong_key(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "not installed" in result.output

    def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        _write_invalid_manifest(project, "claude")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "manifest" in result.output
        assert "unreadable" in result.output

    def test_uninstall_non_default_preserves_default(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, [
                "integration", "uninstall", "codex",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output
        assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "claude"
        assert data["installed_integrations"] == ["claude"]

    def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
        project = _init_project(tmp_path, "gemini")
        template = project / ".specify" / "templates" / "plan-template.md"
        script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        assert "/speckit.plan" in template.read_text(encoding="utf-8")
        assert "/speckit.plan" in script.read_text(encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "claude"
        assert "/speckit-plan" in template.read_text(encoding="utf-8")
        assert "/speckit-plan" in script.read_text(encoding="utf-8")

    def test_uninstall_preserves_shared_infra(self, tmp_path):
        """Shared scripts and templates are not removed by integration uninstall."""
        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
        assert shared_script.exists()

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0

        # Shared infrastructure preserved
        assert shared_script.exists()
        assert (project / ".specify" / "templates").is_dir()


class TestIntegrationUse:
    def test_use_installed_integration_sets_default(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "codex"
        assert data["default_integration"] == "codex"
        assert data["installed_integrations"] == ["claude", "codex"]

        opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
        assert opts["integration"] == "codex"
        assert opts["ai"] == "codex"

    def test_use_requires_installed_integration(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "use", "codex"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "not installed" in result.output

    def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        template = project / ".specify" / "templates" / "plan-template.md"
        script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        assert "/speckit-plan" in template.read_text(encoding="utf-8")
        assert "/speckit-plan" in script.read_text(encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "gemini",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
            assert use_gemini.exit_code == 0, use_gemini.output
            assert "/speckit.plan" in template.read_text(encoding="utf-8")
            assert "/speckit.plan" in script.read_text(encoding="utf-8")
            assert "/speckit-plan" not in script.read_text(encoding="utf-8")

            use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
            assert use_claude.exit_code == 0, use_claude.output
            assert "/speckit-plan" in template.read_text(encoding="utf-8")
            assert "/speckit-plan" in script.read_text(encoding="utf-8")
            assert "/speckit.plan" not in script.read_text(encoding="utf-8")
        finally:
            os.chdir(old_cwd)

    def test_use_preserves_modified_templates_unless_forced(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        template = project / ".specify" / "templates" / "plan-template.md"
        template.write_text("custom template with /speckit-plan\n", encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "gemini",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
            assert use_gemini.exit_code == 0, use_gemini.output
            normalized = " ".join(use_gemini.output.split())
            assert "specify integration use gemini --force" in normalized
            assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"

            force_use = runner.invoke(app, [
                "integration", "use", "gemini",
                "--force",
            ], catch_exceptions=False)
            assert force_use.exit_code == 0, force_use.output
        finally:
            os.chdir(old_cwd)

        updated = template.read_text(encoding="utf-8")
        assert "/speckit.plan" in updated
        assert "custom template" not in updated

    def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
        project = _init_project(tmp_path, "claude")
        int_json = project / ".specify" / "integration.json"
        init_options = project / ".specify" / "init-options.json"

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            before_state = json.loads(int_json.read_text(encoding="utf-8"))
            before_options = json.loads(init_options.read_text(encoding="utf-8"))
            import specify_cli

            def fail_refresh(*args, **kwargs):
                raise ValueError("refuse refresh")

            monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)

            result = runner.invoke(app, [
                "integration", "use", "codex",
                "--force",
            ])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code != 0
        assert "Failed to refresh shared infrastructure" in result.output
        assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
        assert json.loads(init_options.read_text(encoding="utf-8")) == before_options


# ── switch ───────────────────────────────────────────────────────────


class TestIntegrationSwitch:
    def test_switch_requires_speckit_project(self, tmp_path):
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["integration", "switch", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Not a spec-kit project" in result.output

    def test_switch_unknown_target(self, tmp_path):
        project = _init_project(tmp_path)
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "switch", "nonexistent"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Unknown integration" in result.output

    def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        _write_invalid_manifest(project, "claude")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "codex",
                "--script", "sh",
            ])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Could not read integration manifest" in result.output

    def test_switch_same_noop(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "switch", "copilot"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "already the default integration" in result.output

    def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        template = project / ".specify" / "templates" / "plan-template.md"
        script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        template.write_text("# custom shared template\n", encoding="utf-8")
        script.write_text("# custom shared script\n", encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "claude",
                "--force",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output
        assert "shared infrastructure refreshed" in result.output
        assert "managed shared infrastructure refreshed" not in result.output
        assert "/speckit-plan" in template.read_text(encoding="utf-8")
        assert "/speckit-plan" in script.read_text(encoding="utf-8")

    def test_switch_installed_target_rejects_integration_options(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, [
                "integration", "switch", "codex",
                "--integration-options", "--bogus",
            ])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "--integration-options cannot be used" in result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["default_integration"] == "claude"

    def test_switch_between_integrations(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        # Verify claude files exist (claude uses skills)
        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
        shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output
        assert "Switched to" in result.output

        # Old claude files removed
        assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()

        # New copilot files created
        assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
        assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
        assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")

        # integration.json updated
        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "copilot"

    def test_switch_migrates_extension_commands(self, tmp_path):
        """Switching should migrate extension commands to the new agent directory."""
        project = _init_project(tmp_path, "kimi")

        # Install the bundled git extension
        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        # Verify git extension skills exist for kimi
        kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md"
        assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"

        result = _run_in_project(project, [
            "integration", "switch", "opencode",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        # Git extension commands should exist for opencode
        opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
        assert opencode_git_feature.exists(), "Git extension command should exist for opencode"

        # Old kimi extension skills should be removed
        assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"

        # Extension registry should be updated
        registry = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )
        registered_commands = registry["extensions"]["git"]["registered_commands"]
        assert "opencode" in registered_commands
        assert "kimi" not in registered_commands

        # Switch to claude
        result = _run_in_project(project, [
            "integration", "switch", "claude",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        # Git extension skills should exist for claude
        claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
        assert claude_git_feature.exists(), "Git extension skill should exist for claude"

        # Old opencode extension commands should be removed
        assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"

        # Extension registry should be updated
        registry = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )
        registered_commands = registry["extensions"]["git"]["registered_commands"]
        assert "claude" in registered_commands
        assert "opencode" not in registered_commands

    def test_switch_installed_target_backfills_extension_commands(self, tmp_path):
        """Switching to an already-installed agent should register extensions."""
        project = _init_project(tmp_path, "claude")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        registry_path = project / ".specify" / "extensions" / ".registry"
        registered = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert "claude" in registered
        assert "codex" not in registered, "precondition: codex not yet installed"

        result = _run_in_project(project, [
            "integration", "install", "codex",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        codex_git_feature = (
            project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
        )
        assert not codex_git_feature.exists()

        result = _run_in_project(project, ["integration", "switch", "codex"])
        assert result.exit_code == 0, result.output

        registered = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert "codex" in registered
        assert codex_git_feature.exists()

    def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
        """Copilot --skills should receive extension skills, not .agent.md files."""
        project = _init_project(tmp_path, "opencode")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        result = _run_in_project(project, [
            "integration", "switch", "copilot",
            "--script", "sh",
            "--integration-options", "--skills",
        ])
        assert result.exit_code == 0, result.output

        copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
        copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
        assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
        assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"

        # Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it
        skill_content = copilot_git_feature.read_text(encoding="utf-8")
        assert "mode:" not in skill_content, (
            "Copilot skill frontmatter must not contain unsupported 'mode' field"
        )

        registry = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )
        git_meta = registry["extensions"]["git"]
        assert "speckit-git-feature" in git_meta["registered_skills"]
        assert "copilot" not in git_meta["registered_commands"]

        result = _run_in_project(project, [
            "integration", "switch", "opencode",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
        assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
        assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"

        registry = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )
        git_meta = registry["extensions"]["git"]
        assert git_meta["registered_skills"] == []
        assert "opencode" in git_meta["registered_commands"]
        assert "copilot" not in git_meta["registered_commands"]

    def test_switch_does_not_register_disabled_extensions(self, tmp_path):
        """Disabled extensions should stay disabled and should not migrate commands."""
        project = _init_project(tmp_path, "opencode")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"
        result = _run_in_project(project, ["extension", "disable", "git"])
        assert result.exit_code == 0, result.output

        opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
        assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"

        result = _run_in_project(project, [
            "integration", "switch", "claude",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
        assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
        assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"

        registry = json.loads(
            (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
        )
        git_meta = registry["extensions"]["git"]
        assert git_meta["enabled"] is False
        assert "claude" not in git_meta["registered_commands"]
        assert "opencode" not in git_meta["registered_commands"]

    def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
        """Switching refreshes managed shared scripts to the target command style."""
        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
        assert shared_script.exists()
        shared_content = shared_script.read_text(encoding="utf-8")
        assert "/speckit-plan" in shared_content

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0

        assert shared_script.exists()
        updated = shared_script.read_text(encoding="utf-8")
        assert "/speckit.plan" in updated
        assert "/speckit-plan" not in updated

    def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
        """Regression for #2293: stale managed shared scripts get refreshed on switch."""
        import hashlib

        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
        assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")

        # Simulate a stale vendored script: write truncated content as bytes
        # (write_text would translate \n→\r\n on Windows and break the hash)
        # and update the speckit manifest hash so the stale copy is treated
        # as "managed" (installed by spec-kit, not a user customization).
        stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n"
        shared_script.write_bytes(stale_bytes)

        manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
        manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
        manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
            hashlib.sha256(stale_bytes).hexdigest()
        )
        manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0

        # Stale managed file should be replaced by the target integration's rendered version.
        updated = shared_script.read_text(encoding="utf-8")
        assert "# stale vendored copy" not in updated
        assert "/speckit.plan" in updated
        assert "/speckit-plan" not in updated

    def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
        """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"

        # User customization: append bytes but do NOT update manifest hash,
        # so on-disk hash diverges from the recorded one.
        original = shared_script.read_bytes()
        custom_bytes = original + b"\n# user customization\n"
        shared_script.write_bytes(custom_bytes)

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert shared_script.read_bytes() == custom_bytes
        assert "Preserved" in result.output

    def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
        """--refresh-shared-infra explicitly overwrites user customizations on switch."""
        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
        assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
        rendered_bytes = shared_script.read_bytes()

        # User customization (hash diverges from manifest)
        custom_bytes = rendered_bytes + b"\n# user customization\n"
        shared_script.write_bytes(custom_bytes)

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
                "--refresh-shared-infra",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        # Customization is overwritten with the target integration's rendered version.
        updated = shared_script.read_text(encoding="utf-8")
        assert "# user customization" not in updated
        assert "/speckit.plan" in updated
        assert "/speckit-plan" not in updated

    def test_switch_preserves_recovered_files(self, tmp_path):
        """Regression for #2918: files marked recovered in the manifest are not overwritten.

        When a file already exists on disk before init and is recorded with
        ``recovered=True``, ``integration use``/``switch`` must not treat it as
        managed even when the on-disk hash matches the manifest hash.
        """
        import hashlib

        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
        assert shared_script.is_file()

        # Simulate a team-customized file that was recorded as recovered:
        # write custom content, then update the manifest to record its hash
        # with the recovered flag set.
        custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n"
        shared_script.write_bytes(custom_bytes)

        manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
        manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
        rel = ".specify/scripts/bash/setup-tasks.sh"
        manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest()
        manifest_data.setdefault("recovered_files", []).append(rel)
        manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        # Recovered file must NOT be overwritten — team content preserved.
        assert shared_script.read_bytes() == custom_bytes

    def test_switch_skips_symlinked_parent_directory(self, tmp_path):
        """Regression: if .specify/scripts/bash is a symlink, switch must not write through it.

        Copilot follow-up on #2375: leaf-only symlink check let writes escape
        when an *ancestor* directory was symlinked outside the project root.
        """
        import sys
        if sys.platform.startswith("win"):
            import pytest as _pytest
            _pytest.skip("Symlink creation typically requires admin on Windows")

        project = _init_project(tmp_path, "claude")
        bash_dir = project / ".specify" / "scripts" / "bash"
        outside = tmp_path / "outside"
        outside.mkdir()
        for child in bash_dir.iterdir():
            child.rename(outside / child.name)
        bash_dir.rmdir()
        bash_dir.symlink_to(outside, target_is_directory=True)
        sentinel = (outside / "common.sh").read_bytes()

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        # Symlinked tree reported, not written through.
        assert "symlink" in result.output.lower()
        # Outside dir contents unchanged.
        assert (outside / "common.sh").read_bytes() == sentinel

    def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path):
        """--force (uninstall semantics) must NOT overwrite shared-infra customizations.

        Regression: ensures the decoupling of --force and --refresh-shared-infra.
        """
        project = _init_project(tmp_path, "claude")
        shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
        bundled_bytes = shared_script.read_bytes()

        custom_bytes = bundled_bytes + b"\n# user customization\n"
        shared_script.write_bytes(custom_bytes)

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
                "--force",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        # --force alone preserves the customization
        assert shared_script.read_bytes() == custom_bytes

    def test_switch_from_nothing(self, tmp_path):
        """Switch when no integration is installed should just install the target."""
        project = tmp_path / "bare"
        project.mkdir()
        (project / ".specify").mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "switch", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert "Switched to" in result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "claude"

    def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "codex",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, [
                "integration", "switch", "generic",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "codex"
        assert data["installed_integrations"] == ["codex"]

        opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
        assert opts["integration"] == "codex"
        assert opts["ai"] == "codex"

        template = project / ".specify" / "templates" / "plan-template.md"
        assert "/speckit-plan" in template.read_text(encoding="utf-8")


class TestIntegrationUpgrade:
    def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
        project = _init_project(tmp_path, "claude")
        _write_invalid_manifest(project, "claude")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "upgrade", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "manifest" in result.output
        assert "unreadable" in result.output

    def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch):
        project = _init_project(tmp_path, "claude")
        init_options = project / ".specify" / "init-options.json"
        opts = json.loads(init_options.read_text(encoding="utf-8"))
        opts["speckit_version"] = "0.6.1"
        init_options.write_text(json.dumps(opts), encoding="utf-8")

        import specify_cli.integrations._commands as _int_cmds

        monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")

        result = _run_in_project(project, [
            "integration", "upgrade", "claude",
            "--force",
        ])

        assert result.exit_code == 0, result.output
        updated = json.loads(init_options.read_text(encoding="utf-8"))
        assert updated["speckit_version"] == "0.8.11"

    def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
        project = _init_project(tmp_path, "gemini")
        install = _run_in_project(project, [
            "integration", "install", "claude",
            "--script", "sh",
        ])
        assert install.exit_code == 0, install.output

        init_options = project / ".specify" / "init-options.json"
        opts = json.loads(init_options.read_text(encoding="utf-8"))
        opts["speckit_version"] = "0.6.1"
        init_options.write_text(json.dumps(opts), encoding="utf-8")

        import specify_cli.integrations._commands as _int_cmds

        monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")

        result = _run_in_project(project, [
            "integration", "upgrade", "claude",
            "--script", "sh",
            "--force",
        ])

        assert result.exit_code == 0, result.output
        updated = json.loads(init_options.read_text(encoding="utf-8"))
        assert updated["speckit_version"] == "0.8.11"
        assert updated["integration"] == "gemini"
        assert updated["ai"] == "gemini"
        assert "context_file" not in updated

    def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
        project = _init_project(tmp_path, "claude")
        int_json = project / ".specify" / "integration.json"
        init_options = project / ".specify" / "init-options.json"
        manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"

        before_state = json.loads(int_json.read_text(encoding="utf-8"))
        before_options = json.loads(init_options.read_text(encoding="utf-8"))
        before_manifest = manifest_path.read_text(encoding="utf-8")

        import specify_cli

        real_install_shared_infra = specify_cli._install_shared_infra
        calls = {"count": 0}

        def fail_refresh(*args, **kwargs):
            calls["count"] += 1
            if calls["count"] == 2:
                raise ValueError("refuse refresh")
            return real_install_shared_infra(*args, **kwargs)

        monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)

        result = _run_in_project(project, [
            "integration", "upgrade", "claude",
            "--force",
        ])

        assert result.exit_code != 0
        assert "Failed to refresh shared infrastructure" in result.output
        assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
        assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
        assert manifest_path.read_text(encoding="utf-8") == before_manifest

    def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
        project = _init_project(tmp_path, "copilot")
        template = project / ".specify" / "templates" / "plan-template.md"
        managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"

        assert "/speckit.plan" in template.read_text(encoding="utf-8")
        assert "/speckit.specify" in managed_script.read_text(encoding="utf-8")
        customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n"
        customized_script.write_text(customized_before, encoding="utf-8")

        result = _run_in_project(project, [
            "integration", "upgrade", "copilot",
            "--integration-options", "--skills",
        ])

        assert result.exit_code == 0, result.output
        assert "/speckit-plan" in template.read_text(encoding="utf-8")
        managed_content = managed_script.read_text(encoding="utf-8")
        assert "/speckit-specify" in managed_content
        assert "/speckit.specify" not in managed_content
        assert customized_script.read_text(encoding="utf-8") == customized_before

    def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
        project = _init_project(tmp_path, "gemini")
        template = project / ".specify" / "templates" / "plan-template.md"
        script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        assert "/speckit.plan" in template.read_text(encoding="utf-8")
        assert "/speckit.plan" in script.read_text(encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            install = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
            assert install.exit_code == 0, install.output

            result = runner.invoke(app, [
                "integration", "upgrade", "claude",
                "--script", "sh",
                "--force",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, result.output

        data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
        assert data["integration"] == "gemini"
        assert "/speckit.plan" in template.read_text(encoding="utf-8")
        assert "/speckit.plan" in script.read_text(encoding="utf-8")
        assert "/speckit-plan" not in script.read_text(encoding="utf-8")

    def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
        """Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
        project = _init_project(tmp_path, "opencode")

        # Simulate a legacy project: rename commands/ back to command/
        canonical = project / ".opencode" / "commands"
        legacy = project / ".opencode" / "command"
        assert canonical.is_dir(), "init should have created .opencode/commands/"
        canonical.rename(legacy)
        assert legacy.is_dir()
        assert not canonical.exists()

        # Patch the manifest to reflect old paths (command/ not commands/)
        manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json"
        manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
        patched_files = {}
        for path, info in manifest_data.get("files", {}).items():
            patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info
        manifest_data["files"] = patched_files
        manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")

        old_commands = sorted(legacy.glob("speckit.*.md"))
        assert len(old_commands) > 0, "Legacy dir should have speckit command files"

        result = _run_in_project(project, [
            "integration", "upgrade", "opencode",
            "--script", "sh",
            "--force",
        ])
        assert result.exit_code == 0, f"upgrade failed: {result.output}"

        # New commands in canonical dir
        assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade"
        new_commands = sorted(canonical.glob("speckit.*.md"))
        assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"

        # Stale files removed from legacy dir (extension-installed commands
        # like agent-context.update may still appear — only check the original
        # core command stems that should have been migrated).
        core_remaining = [
            f for f in legacy.glob("speckit.*.md")
            if "agent-context" not in f.name
        ]
        assert len(core_remaining) == 0, (
            f"Legacy .opencode/command/ should have no core speckit files after upgrade, "
            f"found: {[f.name for f in core_remaining]}"
        )

    def test_upgrade_preserves_existing_vscode_settings(self, tmp_path):
        """Regression: copilot upgrade must not stale-delete .vscode/settings.json.

        On init the file is created and recorded in the manifest. On upgrade,
        setup() merges into the now-existing file and intentionally stops
        tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2
        stale cleanup would delete it (destroying the user's settings).
        """
        project = _init_project(tmp_path, "copilot")
        settings = project / ".vscode" / "settings.json"
        assert settings.is_file(), "init should create .vscode/settings.json"
        before = json.loads(settings.read_text(encoding="utf-8"))
        assert before, "settings.json should contain managed defaults"

        # Simulate a user editing their settings: add a custom key that the
        # integration does not manage.  It must survive the upgrade.
        before["editor.fontSize"] = 17
        settings.write_text(json.dumps(before), encoding="utf-8")

        result = _run_in_project(project, [
            "integration", "upgrade", "copilot",
            "--script", "sh", "--force",
        ])
        assert result.exit_code == 0, result.output

        assert settings.is_file(), ".vscode/settings.json must survive upgrade"
        after = json.loads(settings.read_text(encoding="utf-8"))
        assert after.get("editor.fontSize") == 17, (
            "user-defined settings must be preserved after upgrade"
        )

    def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path):
        """Regression: scripts refreshed by the managed-refresh step stay +x."""
        if os.name == "nt":
            pytest.skip("POSIX execute bits are not meaningful on Windows")
        project = _init_project(tmp_path, "copilot")
        script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
        assert script.is_file()
        # Simulate a perms-losing install (e.g. wheel extraction dropping +x).
        script.chmod(0o644)
        assert not (script.stat().st_mode & 0o111)

        result = _run_in_project(project, [
            "integration", "upgrade", "copilot",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        assert script.stat().st_mode & 0o111, (
            "shared .sh scripts must be executable after upgrade"
        )

    def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path):
        """Upgrade re-registers enabled extensions for the upgraded agent.

        Regression for #2886: agents installed before extension back-fill
        existed (or whose extension artifacts went missing) should regain the
        enabled extensions' commands on ``upgrade``, reaching parity with
        ``switch``.
        """
        project = _init_project(tmp_path, "claude")

        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        result = _run_in_project(project, [
            "integration", "install", "codex",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        # Simulate a project created before the install/upgrade back-fill: drop
        # codex's extension registration and its rendered artifacts.
        registry_path = project / ".specify" / "extensions" / ".registry"
        registry = json.loads(registry_path.read_text(encoding="utf-8"))
        registry["extensions"]["git"]["registered_commands"].pop("codex", None)
        registry_path.write_text(json.dumps(registry), encoding="utf-8")
        agents_skills = project / ".agents" / "skills"
        for skill_dir in agents_skills.glob("speckit-git-*"):
            shutil.rmtree(skill_dir)

        # Precondition: codex is now missing the git extension.
        assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists()

        result = _run_in_project(project, [
            "integration", "upgrade", "codex",
            "--script", "sh",
        ])
        assert result.exit_code == 0, result.output

        # Upgrade back-filled the git extension for codex.
        registered = json.loads(registry_path.read_text(encoding="utf-8"))[
            "extensions"
        ]["git"]["registered_commands"]
        assert "codex" in registered, "upgrade should re-register extension commands (#2886)"
        assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists()

    def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
        """Upgrading a non-active agent must not touch the active agent's skills.

        Regression for the #2886 wiring: extension skill rendering is
        active-agent-scoped, so routing upgrade of a *secondary* agent through
        ``register_enabled_extensions_for_agent`` used to re-render the
        *active* skills-mode agent's extension skills as a side effect —
        resurrecting skill files the user had deliberately deleted. The skills
        pass is now gated on the target being the active agent. (Skills parity
        for non-active agents is tracked separately in #2948.)
        """
        # Active agent: copilot in skills mode → git extension renders as skills.
        project = _init_project(tmp_path, "copilot", integration_options="--skills")
        result = _run_in_project(project, ["extension", "add", "git"])
        assert result.exit_code == 0, f"extension add failed: {result.output}"

        skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
        assert skill.exists(), "precondition: active copilot has the git extension skill"

        # Add a secondary (non-active) agent; copilot is not multi_install_safe.
        result = _run_in_project(project, [
            "integration", "install", "codex", "--script", "sh", "--force",
        ])
        assert result.exit_code == 0, result.output

        # The user deliberately removes the active agent's git skill.
        shutil.rmtree(skill.parent)
        assert not skill.exists()

        # Upgrading the *non-active* agent must not re-render copilot's skills.
        result = _run_in_project(project, [
            "integration", "upgrade", "codex", "--script", "sh",
        ])
        assert result.exit_code == 0, result.output
        assert not skill.exists(), (
            "upgrading a non-active agent must not resurrect the active agent's "
            "deleted extension skill (#2886)"
        )


# ── Full lifecycle ───────────────────────────────────────────────────


class TestIntegrationLifecycle:
    def test_install_modify_uninstall_preserves_modified(self, tmp_path):
        """Full lifecycle: install → modify file → uninstall → verify modified file kept."""
        project = tmp_path / "lifecycle"
        project.mkdir()
        (project / ".specify").mkdir()

        old_cwd = os.getcwd()
        try:
            os.chdir(project)

            # Install
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
            assert result.exit_code == 0
            assert "installed successfully" in result.output

            # Claude uses skills directory
            plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
            assert plan_file.exists()

            # Modify one file
            plan_file.write_text("# user customization\n", encoding="utf-8")

            # Uninstall
            result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False)
            assert result.exit_code == 0
            assert "preserved" in result.output

            # Modified file kept
            assert plan_file.exists()
            assert plan_file.read_text(encoding="utf-8") == "# user customization\n"
        finally:
            os.chdir(old_cwd)


# ── Edge-case fixes ─────────────────────────────────────────────────


class TestScriptTypeValidation:
    def test_invalid_script_type_rejected(self, tmp_path):
        """--script with an invalid value should fail with a clear error."""
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "bash",
            ])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code != 0
        assert "Invalid script type" in result.output

    def test_valid_script_types_accepted(self, tmp_path):
        """Both 'sh' and 'ps' should be accepted."""
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "integration", "install", "claude",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0


class TestParseIntegrationOptionsEqualsForm:
    def test_equals_form_parsed(self):
        """--commands-dir=./x should be parsed the same as --commands-dir ./x."""
        from specify_cli.integrations._commands import _parse_integration_options
        from specify_cli.integrations import get_integration

        integration = get_integration("generic")
        assert integration is not None

        result_space = _parse_integration_options(integration, "--commands-dir ./mydir")
        result_equals = _parse_integration_options(integration, "--commands-dir=./mydir")
        assert result_space is not None
        assert result_equals is not None
        assert result_space["commands_dir"] == "./mydir"
        assert result_equals["commands_dir"] == "./mydir"


class TestUninstallNoManifestClearsInitOptions:
    def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
        """When no manifest exists, uninstall should still clear init-options.json."""
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()

        # Write integration.json and init-options.json without a manifest
        int_json = project / ".specify" / "integration.json"
        int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8")

        opts_json = project / ".specify" / "init-options.json"
        opts_json.write_text(json.dumps({
            "integration": "claude",
            "ai": "claude",
            "ai_skills": True,
            "script": "sh",
        }), encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, ["integration", "uninstall", "claude"])
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0

        # init-options.json should have integration keys cleared
        opts = json.loads(opts_json.read_text(encoding="utf-8"))
        assert "integration" not in opts
        assert "ai" not in opts
        assert "ai_skills" not in opts
        # Non-integration keys preserved
        assert opts.get("script") == "sh"


class TestSwitchClearsMetadataAfterTeardown:
    def test_metadata_cleared_between_phases(self, tmp_path):
        """After a successful switch, metadata should reference the new integration."""
        project = _init_project(tmp_path, "claude")

        # Verify initial state
        int_json = project / ".specify" / "integration.json"
        assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude"

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            # Switch to copilot — should succeed and update metadata
            result = runner.invoke(app, [
                "integration", "switch", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0

        # integration.json should reference copilot, not claude
        data = json.loads(int_json.read_text(encoding="utf-8"))
        assert data["integration"] == "copilot"

        # init-options.json should reference copilot
        opts_json = project / ".specify" / "init-options.json"
        opts = json.loads(opts_json.read_text(encoding="utf-8"))
        assert opts.get("ai") == "copilot"
