"""Tests for --integration flag on specify init (CLI-level)."""

import io
import json
import os

import pytest
import yaml
from rich.console import Console

from tests.conftest import strip_ansi


class _NoopConsole:
    def print(self, *args, **kwargs):
        pass


def _normalize_cli_output(output: str) -> str:
    output = strip_ansi(output)
    output = " ".join(output.split())
    return output.strip()


class TestCliDiagnosticFormatting:
    def test_cli_error_detail_flattens_newlines(self):
        import specify_cli

        assert specify_cli._cli_error_detail(RuntimeError("line one\nline two")) == "line one line two"

    def test_cli_error_detail_handles_empty_message(self):
        import specify_cli

        assert specify_cli._cli_error_detail(RuntimeError()) == "RuntimeError"

    def test_cli_phase_label_includes_target(self):
        import specify_cli

        assert (
            specify_cli._cli_phase_label("rollback", "integration", "codex")
            == "rollback integration 'codex'"
        )


class TestInitIntegrationFlag:
    def test_unknown_integration_rejected(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        result = runner.invoke(app, [
            "init", str(tmp_path / "test-project"), "--integration", "nonexistent",
        ])
        assert result.exit_code != 0
        assert "Unknown integration" in result.output

    def test_integration_copilot_creates_files(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        runner = CliRunner()
        project = tmp_path / "int-test"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            result = runner.invoke(app, [
                "init", "--here", "--integration", "copilot", "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0, f"init failed: {result.output}"
        assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
        assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
        assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()

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

        opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
        assert opts["integration"] == "copilot"
        # init must not leave any legacy agent-context keys in init-options.json
        assert "context_file" not in opts

        # agent-context is fully opt-in: init must not install it or write its config
        ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
        assert not ext_cfg_path.exists(), "init must not create the agent-context extension config"

        assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()

        # init must not create or manage the agent context file
        assert not (project / ".github" / "copilot-instructions.md").exists()

        shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
        assert shared_manifest.exists()

    def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner
        from specify_cli import app
        import specify_cli

        def fail_select(*_args, **_kwargs):
            raise AssertionError("non-interactive init should not open the integration picker")

        monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select)

        runner = CliRunner()
        project = tmp_path / "noninteractive"
        result = runner.invoke(app, [
            "init", str(project), "--script", "sh", "--ignore-agent-tools",
        ], catch_exceptions=False)

        assert result.exit_code == 0, result.output
        assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
        assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()

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

    def test_integration_copilot_auto_promotes(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app
        project = tmp_path / "promote-test"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here", "--integration", "copilot", "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)
        assert result.exit_code == 0
        assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()

    def test_init_optional_preset_failure_reports_target_and_continues(
        self, tmp_path, monkeypatch
    ):
        from typer.testing import CliRunner
        from specify_cli import app
        from specify_cli.presets import PresetManager

        def fail_install(self, path, version):
            raise OSError("preset install exploded\nwith context")

        monkeypatch.setattr(PresetManager, "install_from_directory", fail_install)

        project = tmp_path / "init-preset-warning"
        result = CliRunner().invoke(
            app,
            [
                "init",
                str(project),
                "--integration",
                "copilot",
                "--script",
                "sh",
                "--preset",
                "lean",
            ],
            catch_exceptions=False,
        )
        normalized = _normalize_cli_output(result.output)

        assert result.exit_code == 0, result.output
        assert "Failed to install preset 'lean'" in normalized
        assert "preset install exploded with context" in normalized
        assert "Continuing without the optional preset" in normalized
        assert "Project ready" in normalized

    def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "claude-here-existing"
        project.mkdir()
        commands_dir = project / ".claude" / "skills"
        commands_dir.mkdir(parents=True)
        skill_dir = commands_dir / "speckit-specify"
        skill_dir.mkdir(parents=True)
        command_file = skill_dir / "SKILL.md"
        command_file.write_text("# preexisting command\n", encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, result.output
        assert command_file.exists()
        # init replaces skills (not additive); verify the file has valid skill content
        assert command_file.exists()
        assert "speckit-specify" in command_file.read_text(encoding="utf-8")
        assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()

    def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
        """Pre-existing shared files are not overwritten without --force."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "skip-test"
        project.mkdir()
        (project / ".specify").mkdir()

        # Pre-create a shared script with custom content
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        custom_content = "# user-modified common.sh\n"
        (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")

        # Pre-create a shared template with custom content
        templates_dir = project / ".specify" / "templates"
        templates_dir.mkdir(parents=True)
        custom_template = "# user-modified spec-template\n"
        (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")

        _install_shared_infra(project, "sh", force=False)

        # User's files should be preserved (not overwritten)
        assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
        assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template

        # Other shared files should still be installed
        assert (scripts_dir / "setup-plan.sh").exists()
        assert (templates_dir / "plan-template.md").exists()

    def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
        """Pre-existing shared files ARE overwritten when force=True."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "force-test"
        project.mkdir()
        (project / ".specify").mkdir()

        # Pre-create a shared script with custom content
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        custom_content = "# user-modified common.sh\n"
        (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")

        # Pre-create a shared template with custom content
        templates_dir = project / ".specify" / "templates"
        templates_dir.mkdir(parents=True)
        custom_template = "# user-modified spec-template\n"
        (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")

        _install_shared_infra(project, "sh", force=True)

        # Files should be overwritten with bundled versions
        assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
        assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template

        # Other shared files should also be installed
        assert (scripts_dir / "setup-plan.sh").exists()
        assert (templates_dir / "plan-template.md").exists()

    def test_shared_infra_removes_stale_managed_script(self, tmp_path):
        """A managed script the core no longer ships (e.g. the legacy
        update-agent-context.sh, superseded by the agent-context extension) is
        removed, and the manifest stops tracking it (#3076)."""
        from specify_cli import _install_shared_infra
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "stale-test"
        project.mkdir()
        (project / ".specify").mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)

        # Legacy orphan the current bundle no longer ships, recorded in the
        # manifest as a managed file (hash matches on disk) — a pre-refactor install.
        stale_rel = ".specify/scripts/bash/update-agent-context.sh"
        (scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8")
        manifest = IntegrationManifest("speckit", project, version="test")
        manifest.record_existing(stale_rel)
        manifest.save()

        _install_shared_infra(project, "sh", force=False)

        # The orphan is gone and the manifest no longer tracks it.
        assert not (scripts_dir / "update-agent-context.sh").exists()
        refreshed = IntegrationManifest.load("speckit", project)
        assert stale_rel not in refreshed.files
        # Scripts the core DOES ship are installed and tracked.
        assert (scripts_dir / "common.sh").exists()
        assert ".specify/scripts/bash/common.sh" in refreshed.files

    def test_shared_infra_preserves_modified_stale_script(self, tmp_path):
        """A user-modified stale script is preserved (hash diverges from the
        managed baseline), never silently deleted (#3076)."""
        from specify_cli import _install_shared_infra
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "stale-modified"
        project.mkdir()
        (project / ".specify").mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)

        stale = scripts_dir / "update-agent-context.sh"
        stale.write_text("# original managed\n", encoding="utf-8")
        manifest = IntegrationManifest("speckit", project, version="test")
        manifest.record_existing(".specify/scripts/bash/update-agent-context.sh")
        manifest.save()

        # User customizes it after install → on-disk hash now diverges.
        stale.write_text("# user customization\n", encoding="utf-8")

        _install_shared_infra(project, "sh", force=False)

        # Preserved: it is no longer a managed (hash-matching) copy.
        assert stale.exists()
        assert stale.read_text(encoding="utf-8") == "# user customization\n"

    def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path):
        """A stale manifest entry whose file is already gone from disk is pruned
        so the manifest stays consistent, not left tracked forever (#3076 review)."""
        from specify_cli import _install_shared_infra
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "orphan-entry"
        project.mkdir()
        (project / ".specify").mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)

        stale_rel = ".specify/scripts/bash/update-agent-context.sh"
        stale = scripts_dir / "update-agent-context.sh"
        stale.write_text("# legacy orphan\n", encoding="utf-8")
        manifest = IntegrationManifest("speckit", project, version="test")
        manifest.record_existing(stale_rel)
        manifest.save()
        # File removed out of band, but the manifest still tracks it.
        stale.unlink()

        _install_shared_infra(project, "sh", force=False)

        refreshed = IntegrationManifest.load("speckit", project)
        assert stale_rel not in refreshed.files

    def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch):
        """If the bundle's script source dir exists but is empty, stale-cleanup
        must NOT run (no source files seen → can't tell what's obsolete): a
        previously-tracked script is preserved, never mass-deleted (#3076 review)."""
        from specify_cli import _install_shared_infra, shared_infra
        from specify_cli.integrations.manifest import IntegrationManifest

        # Point the script source at an empty ``bash/`` directory.
        empty_src = tmp_path / "empty-bundle" / "scripts"
        (empty_src / "bash").mkdir(parents=True)
        monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src)

        project = tmp_path / "empty-source"
        project.mkdir()
        (project / ".specify").mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        tracked_rel = ".specify/scripts/bash/common.sh"
        (scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8")
        manifest = IntegrationManifest("speckit", project, version="test")
        manifest.record_existing(tracked_rel)
        manifest.save()

        _install_shared_infra(project, "sh", force=False)

        # Empty source → scripts_scanned stays False → nothing deleted.
        assert (scripts_dir / "common.sh").exists()
        refreshed = IntegrationManifest.load("speckit", project)
        assert tracked_rel in refreshed.files

    def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path):
        """A corrupted/hand-edited manifest key with a ``..`` segment is skipped
        before any filesystem access — its traversal target is never deleted
        (#3076 review, containment guard)."""
        import hashlib
        import json
        from specify_cli import _install_shared_infra

        project = tmp_path / "unsafe-key"
        project.mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        manifest_dir = project / ".specify" / "integrations"
        manifest_dir.mkdir(parents=True)

        # A file the traversal key would resolve to (outside scripts/bash/).
        victim = project / ".specify" / "scripts" / "keep-me.sh"
        victim_bytes = b"# do not touch\n"
        victim.write_bytes(victim_bytes)

        # Hand-crafted manifest: a key under the script prefix but with a ``..``
        # segment, with the *matching* hash so that — absent the containment guard
        # — stale-cleanup would consider it managed and unlink the target.
        traversal_key = ".specify/scripts/bash/../keep-me.sh"
        (manifest_dir / "speckit.manifest.json").write_text(
            json.dumps({
                "integration": "speckit",
                "version": "test",
                "files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()},
            }),
            encoding="utf-8",
        )

        _install_shared_infra(project, "sh", force=False)

        # The unsafe key was skipped; its target file is untouched.
        assert victim.exists()
        assert victim.read_bytes() == victim_bytes

    def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing(
        self, tmp_path, monkeypatch
    ):
        """A key that passes the lexical guard but escapes containment — e.g. a
        Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards
        the project root when joined — is skipped via ``_validate_rel_path``, never
        unlinked, and never turned into an install-time hard failure (#3076 review
        round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the
        managed key, since real drive-relative paths only escape on Windows."""
        from specify_cli import _install_shared_infra
        from specify_cli.integrations import manifest as manifest_mod
        from specify_cli.integrations.manifest import IntegrationManifest

        project = tmp_path / "escaping-key"
        project.mkdir()
        (project / ".specify").mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)

        # A managed stale orphan that would normally be removed.
        stale_rel = ".specify/scripts/bash/update-agent-context.sh"
        stale = scripts_dir / "update-agent-context.sh"
        stale.write_text("# legacy orphan\n", encoding="utf-8")
        manifest = IntegrationManifest("speckit", project, version="test")
        manifest.record_existing(stale_rel)
        manifest.save()

        # Force the containment check to reject this key, as it would for a
        # drive-relative escape on Windows. The cleanup must skip it gracefully.
        real_validate = manifest_mod._validate_rel_path

        def fake_validate(rel, root):
            if str(rel).endswith("update-agent-context.sh"):
                raise ValueError("simulated drive-relative escape")
            return real_validate(rel, root)

        monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate)

        # Must not raise (no install-time hard failure from a corrupted key).
        _install_shared_infra(project, "sh", force=False)

        # The escaping key was skipped, so its file is left untouched...
        assert stale.exists()
        assert stale.read_text(encoding="utf-8") == "# legacy orphan\n"
        # ...yet the install otherwise completed: real scripts are installed.
        assert (scripts_dir / "common.sh").exists()

    def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
        """Console warning is displayed when files are skipped."""
        from specify_cli import _install_shared_infra
        from tests.conftest import strip_ansi

        project = tmp_path / "warn-test"
        project.mkdir()
        (project / ".specify").mkdir()

        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")

        _install_shared_infra(project, "sh", force=False)

        captured = capsys.readouterr()
        plain = strip_ansi(captured.out)
        assert "already exist and were not updated" in plain
        assert "specify init --here --force" in plain
        # Rich may wrap long lines; normalize whitespace for the second command
        normalized = " ".join(plain.split())
        assert "specify integration upgrade --force" in normalized

    def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):
        """Invalid shared manifests warn before falling back to a new manifest."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "bad-shared-manifest-test"
        project.mkdir()
        integrations_dir = project / ".specify" / "integrations"
        integrations_dir.mkdir(parents=True)
        manifest_path = integrations_dir / "speckit.manifest.json"
        manifest_path.write_text("{not json", encoding="utf-8")

        _install_shared_infra(project, "sh")

        captured = capsys.readouterr()
        assert "Could not read shared infrastructure manifest" in captured.out
        assert "A new shared manifest will be created" in captured.out

    def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys):
        """Non-UTF-8 shared manifests warn before falling back to a new manifest."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "bad-shared-manifest-encoding-test"
        project.mkdir()
        integrations_dir = project / ".specify" / "integrations"
        integrations_dir.mkdir(parents=True)
        manifest_path = integrations_dir / "speckit.manifest.json"
        manifest_path.write_bytes(b"\xff\xfe\x00")

        _install_shared_infra(project, "sh")

        captured = capsys.readouterr()
        assert "Could not read shared infrastructure manifest" in captured.out
        assert "A new shared manifest will be created" in captured.out

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_infra_buckets_symlinked_script_destination(self, tmp_path, capsys):
        """Symlinked script destinations are bucketed with a warning; the symlink target is preserved."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "symlink-script-test"
        project.mkdir()
        (project / ".specify").mkdir()

        outside = tmp_path / "outside-script.sh"
        outside.write_text("# outside\n", encoding="utf-8")
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        os.symlink(outside, scripts_dir / "common.sh")

        _install_shared_infra(project, "sh", force=True)

        captured = capsys.readouterr()
        assert "symlinked shared infrastructure" in captured.out
        assert outside.read_text(encoding="utf-8") == "# outside\n"

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_infra_buckets_symlinked_template_destination(self, tmp_path, capsys):
        """Symlinked template destinations are bucketed with a warning; the symlink target is preserved."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "symlink-template-test"
        project.mkdir()
        (project / ".specify").mkdir()

        outside = tmp_path / "outside-template.md"
        outside.write_text("# outside\n", encoding="utf-8")
        templates_dir = project / ".specify" / "templates"
        templates_dir.mkdir(parents=True)
        os.symlink(outside, templates_dir / "plan-template.md")

        _install_shared_infra(project, "sh", force=True)

        captured = capsys.readouterr()
        assert "symlinked shared infrastructure" in captured.out
        assert outside.read_text(encoding="utf-8") == "# outside\n"

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
        """Template-only refreshes must not follow destination symlinks."""
        from specify_cli import _refresh_shared_templates

        project = tmp_path / "symlink-refresh-test"
        project.mkdir()
        (project / ".specify").mkdir()

        outside = tmp_path / "outside-refresh.md"
        outside.write_text("# outside\n", encoding="utf-8")
        templates_dir = project / ".specify" / "templates"
        templates_dir.mkdir(parents=True)
        os.symlink(outside, templates_dir / "plan-template.md")

        with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
            _refresh_shared_templates(project, invoke_separator=".", force=True)

        assert outside.read_text(encoding="utf-8") == "# outside\n"

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
        """Shared infra installs must not follow a symlinked .specify directory."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "symlink-dir-test"
        project.mkdir()
        outside = tmp_path / "outside-specify"
        outside.mkdir()
        os.symlink(outside, project / ".specify")

        with pytest.raises(ValueError, match="symlinked"):
            _install_shared_infra(project, "sh", force=True)
        # Nothing should have been written under the symlinked .specify target.
        assert list(outside.iterdir()) == []

        assert not (outside / "scripts").exists()
        assert not (outside / "templates").exists()

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path):
        """Shared infra manifest saves must not follow destination symlinks."""
        from specify_cli.shared_infra import install_shared_infra

        project = tmp_path / "symlink-shared-manifest-test"
        project.mkdir()
        integrations_dir = project / ".specify" / "integrations"
        integrations_dir.mkdir(parents=True)

        outside = tmp_path / "outside-manifest.json"
        outside.write_text("# outside\n", encoding="utf-8")
        os.symlink(outside, integrations_dir / "speckit.manifest.json")

        core_pack = tmp_path / "core-pack"
        templates_src = core_pack / "templates"
        templates_src.mkdir(parents=True)
        (templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")

        with pytest.raises(ValueError, match="symlinked integration manifest"):
            install_shared_infra(
                project,
                "sh",
                version="test",
                core_pack=core_pack,
                repo_root=tmp_path / "unused",
                console=_NoopConsole(),
                force=True,
            )

        assert outside.read_text(encoding="utf-8") == "# outside\n"

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
        """Template refresh validates all destinations before writing any file."""
        from specify_cli.shared_infra import refresh_shared_templates

        project = tmp_path / "preflight-refresh-test"
        project.mkdir()
        templates_dir = project / ".specify" / "templates"
        templates_dir.mkdir(parents=True)

        core_pack = tmp_path / "core-pack"
        templates_src = core_pack / "templates"
        templates_src.mkdir(parents=True)
        (templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8")
        (templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8")

        existing = templates_dir / "a-template.md"
        existing.write_text("# old a\n", encoding="utf-8")
        outside = tmp_path / "outside-z.md"
        outside.write_text("# outside\n", encoding="utf-8")
        os.symlink(outside, templates_dir / "z-template.md")

        with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
            refresh_shared_templates(
                project,
                version="test",
                core_pack=core_pack,
                repo_root=tmp_path / "unused",
                console=_NoopConsole(),
                invoke_separator=".",
                force=True,
            )

        assert existing.read_text(encoding="utf-8") == "# old a\n"
        assert outside.read_text(encoding="utf-8") == "# outside\n"

    @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
    def test_shared_infra_install_buckets_unsafe_destinations_and_continues(self, tmp_path):
        """Symlinked destinations are bucketed with a warning; safe destinations in the same install still complete."""
        from specify_cli.shared_infra import install_shared_infra

        project = tmp_path / "preflight-install-test"
        project.mkdir()
        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)

        core_pack = tmp_path / "core-pack"
        scripts_src = core_pack / "scripts" / "bash"
        scripts_src.mkdir(parents=True)
        (scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8")
        (scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8")

        existing = scripts_dir / "a.sh"
        existing.write_text("# old a\n", encoding="utf-8")
        outside = tmp_path / "outside-z.sh"
        outside.write_text("# outside\n", encoding="utf-8")
        os.symlink(outside, scripts_dir / "z.sh")

        install_shared_infra(
            project,
            "sh",
            version="test",
            core_pack=core_pack,
            repo_root=tmp_path / "unused",
            console=_NoopConsole(),
            force=True,
        )

        # Symlinked z.sh is preserved (bucketed); regular a.sh is overwritten.
        assert outside.read_text(encoding="utf-8") == "# outside\n"
        assert existing.read_text(encoding="utf-8") == "# new a\n"

    def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
        """Nested script source files create safe destination parents at write time."""
        from specify_cli.shared_infra import install_shared_infra

        project = tmp_path / "nested-script-install-test"
        project.mkdir()

        core_pack = tmp_path / "core-pack"
        nested_src = core_pack / "scripts" / "bash" / "nested"
        nested_src.mkdir(parents=True)
        (nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8")

        install_shared_infra(
            project,
            "sh",
            version="test",
            core_pack=core_pack,
            repo_root=tmp_path / "unused",
            console=_NoopConsole(),
            force=True,
        )

        nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh"
        assert nested_dest.read_text(encoding="utf-8") == "# nested\n"

    def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path):
        """Skipped shared infra paths are reported consistently across platforms."""
        from specify_cli.shared_infra import install_shared_infra

        project = tmp_path / "posix-skip-warning-test"
        project.mkdir()
        nested_dest = project / ".specify" / "scripts" / "bash" / "nested"
        nested_dest.mkdir(parents=True)
        (nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8")

        templates_dest = project / ".specify" / "templates"
        templates_dest.mkdir(parents=True)
        (templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8")

        core_pack = tmp_path / "core-pack"
        nested_src = core_pack / "scripts" / "bash" / "nested"
        nested_src.mkdir(parents=True)
        (nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8")

        templates_src = core_pack / "templates"
        templates_src.mkdir(parents=True)
        (templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8")

        buffer = io.StringIO()
        install_shared_infra(
            project,
            "sh",
            version="test",
            core_pack=core_pack,
            repo_root=tmp_path / "unused",
            console=Console(file=buffer, force_terminal=False, width=120),
            force=False,
        )

        output = buffer.getvalue()
        assert ".specify/scripts/bash/nested/deep.sh" in output
        assert ".specify/templates/plan-template.md" in output

    @pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows")
    def test_shared_template_writes_are_not_world_writable(self, tmp_path):
        """Shared template writes use a safe default mode instead of chmod 666."""
        from specify_cli.shared_infra import install_shared_infra

        project = tmp_path / "template-mode-test"
        project.mkdir()

        core_pack = tmp_path / "core-pack"
        templates_src = core_pack / "templates"
        templates_src.mkdir(parents=True)
        (templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")

        install_shared_infra(
            project,
            "sh",
            version="test",
            core_pack=core_pack,
            repo_root=tmp_path / "unused",
            console=_NoopConsole(),
            force=True,
        )

        written = project / ".specify" / "templates" / "plan-template.md"
        assert written.stat().st_mode & 0o777 == 0o644

    def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
        """No skip warning when force=True (all files overwritten)."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "no-warn-test"
        project.mkdir()
        (project / ".specify").mkdir()

        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")

        _install_shared_infra(project, "sh", force=True)

        captured = capsys.readouterr()
        assert "already exist and were not updated" not in captured.out

    def test_init_here_force_overwrites_shared_infra(self, tmp_path):
        """E2E: specify init --here --force overwrites shared infra files."""
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "e2e-force"
        project.mkdir()

        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        custom_content = "# user-modified common.sh\n"
        (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here", "--force",
                "--integration", "copilot",
                "--script", "sh",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        # --force should overwrite the custom file
        assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content

    def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
        """E2E: specify init --here (no --force) preserves existing shared infra files."""
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "e2e-no-force"
        project.mkdir()

        scripts_dir = project / ".specify" / "scripts" / "bash"
        scripts_dir.mkdir(parents=True)
        custom_content = "# user-modified common.sh\n"
        (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")

        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here",
                "--integration", "copilot",
                "--script", "sh",
            ], input="y\n", catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        # Without --force, custom file should be preserved
        assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
        # Warning about skipped files should appear
        assert "not updated" in result.output


class TestForceExistingDirectory:
    """Tests for --force merging into an existing named directory."""

    def test_force_merges_into_existing_dir(self, tmp_path):
        """specify init <dir> --force succeeds when the directory already exists."""
        from typer.testing import CliRunner
        from specify_cli import app

        target = tmp_path / "existing-proj"
        target.mkdir()
        # Place a pre-existing file to verify it survives the merge
        marker = target / "user-file.txt"
        marker.write_text("keep me", encoding="utf-8")

        runner = CliRunner()
        result = runner.invoke(app, [
            "init", str(target), "--integration", "copilot", "--force",
            "--script", "sh",
        ], catch_exceptions=False)

        assert result.exit_code == 0, f"init --force failed: {result.output}"

        # Pre-existing file should survive
        assert marker.read_text(encoding="utf-8") == "keep me"

        # Spec Kit files should be installed
        assert (target / ".specify" / "init-options.json").exists()
        assert (target / ".specify" / "templates" / "spec-template.md").exists()

    def test_without_force_errors_on_existing_dir(self, tmp_path):
        """specify init <dir> without --force errors when directory exists."""
        from typer.testing import CliRunner
        from specify_cli import app

        target = tmp_path / "existing-proj"
        target.mkdir()

        runner = CliRunner()
        result = runner.invoke(app, [
            "init", str(target), "--integration", "copilot",
            "--script", "sh",
        ], catch_exceptions=False)

        assert result.exit_code == 1
        assert "already exists" in _normalize_cli_output(result.output)


class TestGitExtensionOptIn:
    """Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""

    def test_git_extension_not_auto_installed(self, tmp_path):
        """Git extension is NOT installed automatically during init."""
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "git-opt-in"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here", "--integration", "claude", "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, f"init failed: {result.output}"

        # Git extension directory should NOT be present after init
        ext_dir = project / ".specify" / "extensions" / "git"
        assert not ext_dir.exists(), "git extension should not be auto-installed"

    def test_no_git_flag_is_rejected(self, tmp_path):
        """--no-git flag has been removed; passing it should fail."""
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "no-git-rejected"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here", "--integration", "claude", "--script", "sh",
                "--no-git", "--ignore-agent-tools",
            ])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
        assert "No such option" in result.output or "no such option" in result.output.lower()

    def test_git_extension_commands_not_registered_by_default(self, tmp_path):
        """Git extension commands are NOT registered with the agent during default init."""
        from typer.testing import CliRunner
        from specify_cli import app

        project = tmp_path / "git-cmds-absent"
        project.mkdir()
        old_cwd = os.getcwd()
        try:
            os.chdir(project)
            runner = CliRunner()
            result = runner.invoke(app, [
                "init", "--here", "--integration", "claude", "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, f"init failed: {result.output}"

        # Git extension skill commands should NOT be present
        claude_skills = project / ".claude" / "skills"
        assert claude_skills.exists(), "Claude skills directory was not created"
        git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
        assert len(git_skills) == 0, "git extension commands should not be registered by default"


class TestSharedInfraCommandRefs:
    """Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in shared infra."""

    @staticmethod
    def _combined_script_content(project, script_type):
        script_dir = "bash" if script_type == "sh" else "powershell"
        suffix = "sh" if script_type == "sh" else "ps1"
        names = [
            f"check-prerequisites.{suffix}",
            f"common.{suffix}",
            f"setup-tasks.{suffix}",
        ]
        return "\n".join(
            (project / ".specify" / "scripts" / script_dir / name).read_text(
                encoding="utf-8"
            )
            for name in names
        )

    def test_dot_separator_in_page_templates(self, tmp_path):
        """Markdown agents get /speckit.<name> in page templates."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "dot-test"
        project.mkdir()
        (project / ".specify").mkdir()

        _install_shared_infra(project, "sh", invoke_separator=".")

        plan = project / ".specify" / "templates" / "plan-template.md"
        assert plan.exists()
        content = plan.read_text(encoding="utf-8")
        assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
        assert "/speckit.plan" in content

        checklist = project / ".specify" / "templates" / "checklist-template.md"
        content = checklist.read_text(encoding="utf-8")
        assert "__SPECKIT_COMMAND_" not in content
        assert "/speckit.checklist" in content

    def test_hyphen_separator_in_page_templates(self, tmp_path):
        """Skills agents get /speckit-<name> in page templates."""
        from specify_cli import _install_shared_infra

        project = tmp_path / "hyphen-test"
        project.mkdir()
        (project / ".specify").mkdir()

        _install_shared_infra(project, "sh", invoke_separator="-")

        plan = project / ".specify" / "templates" / "plan-template.md"
        assert plan.exists()
        content = plan.read_text(encoding="utf-8")
        assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
        assert "/speckit-plan" in content
        assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"

        tasks = project / ".specify" / "templates" / "tasks-template.md"
        content = tasks.read_text(encoding="utf-8")
        assert "__SPECKIT_COMMAND_" not in content
        assert "/speckit-tasks" in content

    @pytest.mark.parametrize("script_type", ["sh", "ps"])
    def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
        """Markdown agents get /speckit.<name> in shared script hints."""
        from specify_cli import _install_shared_infra

        project = tmp_path / f"dot-script-{script_type}"
        project.mkdir()
        (project / ".specify").mkdir()

        _install_shared_infra(project, script_type, invoke_separator=".")

        content = self._combined_script_content(project, script_type)
        assert "__SPECKIT_COMMAND_" not in content
        assert "/speckit.specify" in content
        assert "/speckit.plan" in content
        assert "/speckit.tasks" in content
        assert "/speckit-specify" not in content
        assert "/speckit-plan" not in content
        assert "/speckit-tasks" not in content

    @pytest.mark.parametrize("script_type", ["sh", "ps"])
    def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type):
        """Skills agents get /speckit-<name> in shared script hints."""
        from specify_cli import _install_shared_infra

        project = tmp_path / f"hyphen-script-{script_type}"
        project.mkdir()
        (project / ".specify").mkdir()

        _install_shared_infra(project, script_type, invoke_separator="-")

        content = self._combined_script_content(project, script_type)
        assert "__SPECKIT_COMMAND_" not in content
        assert "/speckit-specify" in content
        assert "/speckit-plan" in content
        assert "/speckit-tasks" in content
        assert "/speckit.specify" not in content
        assert "/speckit.plan" not in content
        assert "/speckit.tasks" not in content

    def test_full_init_claude_resolves_page_templates(self, tmp_path):
        """Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
        from typer.testing import CliRunner
        from specify_cli import app

        runner = CliRunner()
        project = tmp_path / "init-claude"
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, [
                "init", str(project),
                "--integration", "claude",
                "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, f"init failed: {result.output}"

        plan = project / ".specify" / "templates" / "plan-template.md"
        content = plan.read_text(encoding="utf-8")
        assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
        assert "__SPECKIT_COMMAND_" not in content

        script_content = self._combined_script_content(project, "sh")
        assert "/speckit-specify" in script_content
        assert "/speckit.specify" not in script_content

    def test_full_init_copilot_resolves_page_templates(self, tmp_path):
        """Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
        from typer.testing import CliRunner
        from specify_cli import app

        runner = CliRunner()
        project = tmp_path / "init-copilot"
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, [
                "init", str(project),
                "--integration", "copilot",
                "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, f"init failed: {result.output}"

        plan = project / ".specify" / "templates" / "plan-template.md"
        content = plan.read_text(encoding="utf-8")
        assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
        assert "__SPECKIT_COMMAND_" not in content

        script_content = self._combined_script_content(project, "sh")
        assert "/speckit.specify" in script_content
        assert "/speckit-specify" not in script_content

    def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
        """Full CLI init with Copilot --skills produces hyphen refs in page templates."""
        from typer.testing import CliRunner
        from specify_cli import app

        runner = CliRunner()
        project = tmp_path / "init-copilot-skills"
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, [
                "init", str(project),
                "--integration", "copilot",
                "--integration-options", "--skills",
                "--script", "sh",
                "--ignore-agent-tools",
            ], catch_exceptions=False)
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0, f"init failed: {result.output}"

        plan = project / ".specify" / "templates" / "plan-template.md"
        content = plan.read_text(encoding="utf-8")
        assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
        assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
        assert "__SPECKIT_COMMAND_" not in content

        script_content = self._combined_script_content(project, "sh")
        assert "/speckit-specify" in script_content
        assert "/speckit.specify" not in script_content


class TestIntegrationCatalogDiscoveryCLI:
    """End-to-end CLI tests for `integration search`, `info`, and `catalog …`.

    All tests patch `IntegrationCatalog._get_merged_integrations` so no network
    or on-disk cache is touched. Adds #2344 coverage without affecting any
    existing integration install/switch/uninstall/upgrade behavior.
    """

    FAKE_INTEGRATIONS = [
        {
            "id": "acme-coder",
            "name": "Acme Coder",
            "version": "2.0.0",
            "description": "Community integration for Acme Coder",
            "author": "acme-org",
            "tags": ["cli", "acme"],
            "_catalog_name": "community",
            "_install_allowed": False,
        },
        {
            "id": "stellar-agent",
            "name": "Stellar Agent",
            "version": "1.3.0",
            "description": "First-party Stellar agent integration",
            "author": "stellar-labs",
            "tags": ["ide"],
            "_catalog_name": "default",
            "_install_allowed": True,
        },
    ]

    def _make_project(self, tmp_path):
        project = tmp_path / "proj"
        project.mkdir()
        (project / ".specify").mkdir()
        return project

    def _patch_catalog(self, monkeypatch, integrations=None):
        """Return a stubbed `_get_merged_integrations` that yields *integrations*."""
        from specify_cli.integrations.catalog import IntegrationCatalog

        data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS)

        def fake_merged(self, force_refresh=False):
            return data

        monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged)

    def _invoke(self, argv, cwd):
        from typer.testing import CliRunner
        from specify_cli import app

        runner = CliRunner()
        old = os.getcwd()
        try:
            os.chdir(cwd)
            return runner.invoke(app, argv, catch_exceptions=False)
        finally:
            os.chdir(old)

    def test_integration_install_failure_reports_phase_target_and_rollback(
        self, tmp_path, monkeypatch
    ):
        from specify_cli.integrations import INTEGRATION_REGISTRY
        from specify_cli.integrations.base import IntegrationBase

        class BrokenIntegration(IntegrationBase):
            key = "broken-test"
            config = {
                "name": "Broken Test",
                "folder": ".broken/",
                "commands_subdir": "commands",
                "install_url": None,
                "requires_cli": False,
            }
            registrar_config = {
                "dir": ".broken/commands",
                "format": "markdown",
                "args": "$ARGUMENTS",
                "extension": ".md",
            }

            def setup(self, project_root, manifest, **kwargs):
                raise OSError("setup exploded\nwith context")

            def teardown(self, project_root, manifest, force=False):
                raise OSError("rollback exploded")

        project = self._make_project(tmp_path)
        monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration())

        result = self._invoke(["integration", "install", "broken-test"], project)
        normalized = _normalize_cli_output(result.output)

        assert result.exit_code == 1, result.output
        assert "Failed to rollback integration 'broken-test'" in normalized
        assert "rollback exploded" in normalized
        assert "Failed to install integration 'broken-test'" in normalized
        assert "setup exploded with context" in normalized

    def test_integration_upgrade_failure_reports_phase_and_target(
        self, tmp_path, monkeypatch
    ):
        from specify_cli.integrations import INTEGRATION_REGISTRY
        from specify_cli.integrations.copilot import CopilotIntegration

        class UpgradeBrokenIntegration(CopilotIntegration):
            key = "upgrade-broken"
            config = dict(CopilotIntegration.config)
            config["name"] = "Upgrade Broken"

            def setup(self, project_root, manifest, **kwargs):
                raise OSError("upgrade exploded\nwith context")

        project = self._make_project(tmp_path)
        monkeypatch.setitem(
            INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration()
        )

        (project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
        (project / ".specify" / "integration.json").write_text(
            json.dumps(
                {
                    "version": 1,
                    "integration": "upgrade-broken",
                    "integrations": ["upgrade-broken"],
                    "integration_settings": {"upgrade-broken": {"script": "sh"}},
                }
            ),
            encoding="utf-8",
        )
        (
            project / ".specify" / "integrations" / "upgrade-broken.manifest.json"
        ).write_text(
            json.dumps(
                {
                    "integration": "upgrade-broken",
                    "version": "0.0.0",
                    "installed_at": "2026-05-16T00:00:00+00:00",
                    "files": {},
                }
            ),
            encoding="utf-8",
        )

        result = self._invoke(["integration", "upgrade", "upgrade-broken"], project)
        normalized = _normalize_cli_output(result.output)

        assert result.exit_code == 1, result.output
        assert "Failed to upgrade integration 'upgrade-broken'" in normalized
        assert "upgrade exploded with context" in normalized
        assert "previous integration files may still be in place" in normalized

    def test_integration_switch_cleanup_warning_reports_phase_and_targets(
        self, tmp_path, monkeypatch
    ):
        from specify_cli.extensions import ExtensionManager

        project = self._make_project(tmp_path)
        (project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
        (project / ".specify" / "integration.json").write_text(
            json.dumps(
                {
                    "version": 1,
                    "integration": "copilot",
                    "integrations": ["copilot"],
                    "integration_settings": {"copilot": {"script": "sh"}},
                }
            ),
            encoding="utf-8",
        )
        (project / ".specify" / "integrations" / "copilot.manifest.json").write_text(
            json.dumps(
                {
                    "integration": "copilot",
                    "version": "0.0.0",
                    "installed_at": "2026-05-16T00:00:00+00:00",
                    "files": {},
                }
            ),
            encoding="utf-8",
        )

        def fail_cleanup(self, integration_key):
            raise OSError("cleanup exploded")

        monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup)

        result = self._invoke(["integration", "switch", "claude"], project)
        normalized = _normalize_cli_output(result.output)

        assert result.exit_code == 0, result.output
        assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized
        assert "cleanup exploded" in normalized
        assert "Switched to integration" in normalized

    # -- Project guard -----------------------------------------------------

    def test_search_requires_specify_project(self, tmp_path):
        project = tmp_path / "bare"
        project.mkdir()
        result = self._invoke(["integration", "search"], project)
        assert result.exit_code == 1
        assert "Not a Spec Kit project" in result.output

    def test_catalog_list_requires_specify_project(self, tmp_path):
        project = tmp_path / "bare"
        project.mkdir()
        result = self._invoke(["integration", "catalog", "list"], project)
        assert result.exit_code == 1
        assert "Not a Spec Kit project" in result.output

    def test_primary_integration_commands_require_specify_project(self, tmp_path):
        project = tmp_path / "bare"
        project.mkdir()
        commands = [
            ["integration", "list"],
            ["integration", "install", "codex"],
            ["integration", "use", "codex"],
            ["integration", "uninstall"],
            ["integration", "switch", "codex"],
            ["integration", "upgrade"],
        ]

        for command in commands:
            result = self._invoke(command, project)
            failure_context = (
                f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
            )
            assert result.exit_code == 1, failure_context
            assert "Not a Spec Kit project" in result.output, failure_context

    def test_integration_commands_require_specify_directory(self, tmp_path):
        project = tmp_path / "bad"
        project.mkdir()
        (project / ".specify").write_text("not a directory")

        commands = [
            ["integration", "list"],
            ["integration", "use", "codex"],
        ]

        for command in commands:
            result = self._invoke(command, project)
            assert result.exit_code == 1, result.output
            assert "Not a Spec Kit project" in result.output

    def test_project_scoped_commands_require_specify_directory(self, tmp_path):
        project = tmp_path / "bad-feature-commands"
        project.mkdir()
        (project / ".specify").write_text("not a directory")

        commands = [
            ["preset", "list"],
            ["preset", "add", "demo"],
            ["preset", "remove", "demo"],
            ["preset", "search"],
            ["preset", "resolve", "spec-template"],
            ["preset", "info", "demo"],
            ["preset", "set-priority", "demo", "5"],
            ["preset", "enable", "demo"],
            ["preset", "disable", "demo"],
            ["preset", "catalog", "list"],
            ["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
            ["preset", "catalog", "remove", "demo"],
            ["extension", "list"],
            ["extension", "add", "demo"],
            ["extension", "remove", "demo"],
            ["extension", "search"],
            ["extension", "info", "demo"],
            ["extension", "update", "demo"],
            ["extension", "enable", "demo"],
            ["extension", "disable", "demo"],
            ["extension", "set-priority", "demo", "5"],
            ["extension", "catalog", "list"],
            ["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
            ["extension", "catalog", "remove", "demo"],
            ["workflow", "run", "demo"],
            ["workflow", "resume", "demo"],
            ["workflow", "status"],
            ["workflow", "list"],
            ["workflow", "add", "demo"],
            ["workflow", "remove", "demo"],
            ["workflow", "search"],
            ["workflow", "info", "demo"],
            ["workflow", "catalog", "list"],
            ["workflow", "catalog", "add", "https://example.com/catalog.yml"],
            ["workflow", "catalog", "remove", "0"],
        ]

        for command in commands:
            result = self._invoke(command, project)
            failure_context = (
                f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
            )
            assert result.exit_code == 1, failure_context
            assert "Not a Spec Kit project" in result.output, failure_context

    def test_catalog_config_output_uses_posix_paths(self, tmp_path):
        project = self._make_project(tmp_path)

        preset_add = self._invoke([
            "preset", "catalog", "add",
            "https://example.com/preset-catalog.yml",
            "--name", "demo-presets",
        ], project)
        assert preset_add.exit_code == 0, preset_add.output
        assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output

        preset_list = self._invoke(["preset", "catalog", "list"], project)
        assert preset_list.exit_code == 0, preset_list.output
        assert "Config: .specify/preset-catalogs.yml" in preset_list.output

        extension_add = self._invoke([
            "extension", "catalog", "add",
            "https://example.com/extension-catalog.yml",
            "--name", "demo-extensions",
        ], project)
        assert extension_add.exit_code == 0, extension_add.output
        assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output

        extension_list = self._invoke(["extension", "catalog", "list"], project)
        assert extension_list.exit_code == 0, extension_list.output
        assert "Config: .specify/extension-catalogs.yml" in extension_list.output

    def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path):
        project = self._make_project(tmp_path)
        cfg_path = project / ".specify" / "extension-catalogs.yml"
        cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")

        result = self._invoke([
            "extension", "catalog", "add",
            "https://example.com/extension-catalog.yml",
            "--name", "demo-extensions",
        ], project)

        assert result.exit_code == 1, result.output
        output = _normalize_cli_output(result.output)
        assert "Invalid catalog config .specify/extension-catalogs.yml" in output
        assert "expected a YAML mapping at the root" in output
        assert "AttributeError" not in output

    def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path):
        project = self._make_project(tmp_path)
        cfg_path = project / ".specify" / "extension-catalogs.yml"
        cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")

        result = self._invoke(["extension", "catalog", "remove", "demo"], project)

        assert result.exit_code == 1, result.output
        output = _normalize_cli_output(result.output)
        assert "Invalid catalog config .specify/extension-catalogs.yml" in output
        assert "expected a YAML mapping at the root" in output
        assert "AttributeError" not in output

    def test_extension_catalog_add_escapes_catalog_name_markup(self, tmp_path):
        project = self._make_project(tmp_path)
        catalog_name = "[red]demo[/red]"

        result = self._invoke([
            "extension", "catalog", "add",
            "https://example.com/extension-catalog.yml",
            "--name", catalog_name,
        ], project)

        assert result.exit_code == 0, result.output
        output = _normalize_cli_output(result.output)
        assert f"Added catalog '{catalog_name}'" in output

    def test_extension_catalog_remove_escapes_catalog_name_markup(self, tmp_path):
        project = self._make_project(tmp_path)
        catalog_name = "[red]demo[/red]"
        cfg_path = project / ".specify" / "extension-catalogs.yml"
        cfg_path.write_text(
            yaml.safe_dump(
                {
                    "catalogs": [
                        {
                            "name": catalog_name,
                            "url": "https://example.com/extension-catalog.yml",
                            "priority": 10,
                            "install_allowed": False,
                            "description": "",
                        }
                    ]
                },
                sort_keys=False,
            ),
            encoding="utf-8",
        )

        result = self._invoke(["extension", "catalog", "remove", catalog_name], project)

        assert result.exit_code == 0, result.output
        output = _normalize_cli_output(result.output)
        assert f"Removed catalog '{catalog_name}'" in output

    # -- search ------------------------------------------------------------

    def test_search_lists_all(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(["integration", "search"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 0, result.output
        assert "Found 2 integration(s)" in result.output
        assert "acme-coder" in result.output
        assert "stellar-agent" in result.output
        assert "specify integration install stellar-agent" not in normalized_output
        assert "Only built-in integration IDs can be installed" in normalized_output

    def test_search_validates_integration_json_before_catalog_lookup(
        self, tmp_path, monkeypatch
    ):
        project = self._make_project(tmp_path)
        (project / ".specify" / "integration.json").write_text(
            "{bad json\n", encoding="utf-8"
        )

        from specify_cli.integrations.catalog import IntegrationCatalog

        def fail_search(self, **kwargs):
            raise AssertionError("catalog search should not be called")

        monkeypatch.setattr(IntegrationCatalog, "search", fail_search)

        result = self._invoke(["integration", "search"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1
        assert "contains invalid JSON" in normalized_output
        assert "integration.json" in normalized_output

    def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
        self, tmp_path, monkeypatch
    ):
        """A non-UTF8 ``integration.json`` must surface a clear error and
        avoid falling through to the catalog lookup, mirroring the malformed-JSON
        case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
        project = self._make_project(tmp_path)
        # 0xFF is invalid as the leading byte of any UTF-8 sequence, so
        # ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
        (project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")

        from specify_cli.integrations.catalog import IntegrationCatalog

        def fail_search(self, **kwargs):
            raise AssertionError("catalog search should not be called")

        monkeypatch.setattr(IntegrationCatalog, "search", fail_search)

        result = self._invoke(["integration", "search"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1
        assert "not valid UTF-8" in normalized_output
        assert "integration.json" in normalized_output

    def test_search_filters_by_tag(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(["integration", "search", "--tag", "acme"], project)
        assert result.exit_code == 0, result.output
        assert "Found 1 integration(s)" in result.output
        assert "acme-coder" in result.output
        assert "stellar-agent" not in result.output

    def test_search_filters_by_author(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(
            ["integration", "search", "--author", "stellar-labs"], project
        )
        assert result.exit_code == 0, result.output
        assert "Found 1 integration(s)" in result.output
        assert "stellar-agent" in result.output

    def test_search_no_match_hint(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(
            ["integration", "search", "--tag", "nope"], project
        )
        assert result.exit_code == 0, result.output
        assert "No integrations found" in result.output
        assert "specify integration search" in result.output

    def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(["integration", "search", "acme"], project)
        assert result.exit_code == 0, result.output
        # acme-coder is flagged _install_allowed=False, so we should warn
        assert "Not directly installable" in result.output

    # -- info --------------------------------------------------------------

    def test_info_found(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(
            ["integration", "info", "stellar-agent"], project
        )
        assert result.exit_code == 0, result.output
        assert "Stellar Agent" in result.output
        assert "stellar-agent" in result.output
        assert "v1.3.0" in result.output

    def test_info_not_found(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        self._patch_catalog(monkeypatch)
        result = self._invoke(
            ["integration", "info", "does-not-exist"], project
        )
        assert result.exit_code == 1
        assert "not found" in result.output

    def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        # Empty catalog, but copilot is a registered built-in.
        self._patch_catalog(monkeypatch, integrations=[])
        result = self._invoke(["integration", "info", "copilot"], project)
        assert result.exit_code == 0, result.output
        assert "Built-in integration" in result.output

    # -- validation vs network guidance ------------------------------------

    def test_search_local_config_error_shows_local_config_tip(
        self, tmp_path, monkeypatch
    ):
        """`integration search` must point at .specify/integration-catalogs.yml
        for local-config errors (not the generic 'temporarily unavailable')."""
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        # Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError.
        cfg = project / ".specify" / "integration-catalogs.yml"
        invalid_yaml = "catalogs:\n  - [bad\n"
        cfg.write_text(invalid_yaml, encoding="utf-8")

        result = self._invoke(["integration", "search"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1, result.output
        assert "configuration file path shown above" in normalized_output
        assert ".specify/integration-catalogs.yml" in normalized_output
        assert "~/.specify/integration-catalogs.yml" in normalized_output
        assert "temporarily unavailable" not in normalized_output

    def test_search_invalid_env_catalog_url_shows_env_tip(
        self, tmp_path, monkeypatch
    ):
        project = self._make_project(tmp_path)
        monkeypatch.setenv(
            "SPECKIT_INTEGRATION_CATALOG_URL",
            "http://insecure.example.com/catalog.json",
        )

        result = self._invoke(["integration", "search"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1, result.output
        assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output
        assert "unset it to use the configured catalog files" in normalized_output
        assert ".specify/integration-catalogs.yml" in normalized_output
        assert "~/.specify/integration-catalogs.yml" in normalized_output
        assert "temporarily unavailable" not in normalized_output

    def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip(
        self, tmp_path, monkeypatch
    ):
        project = self._make_project(tmp_path)
        monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", "   ")

        from specify_cli.integrations.catalog import (
            IntegrationCatalog,
            IntegrationCatalogError,
        )

        def fail_search(self, **kwargs):
            raise IntegrationCatalogError("catalog offline")

        monkeypatch.setattr(IntegrationCatalog, "search", fail_search)

        result = self._invoke(["integration", "search"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1, result.output
        assert "temporarily unavailable" in normalized_output
        assert (
            "SPECKIT_INTEGRATION_CATALOG_URL environment variable"
            not in normalized_output
        )

    def test_info_unknown_with_local_config_error_shows_local_config_tip(
        self, tmp_path, monkeypatch
    ):
        """`integration info <unknown>` falls back to the catalog-error branch
        and must show local-config guidance, not 'Try again when online'."""
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        cfg = project / ".specify" / "integration-catalogs.yml"
        invalid_yaml = "catalogs:\n  - [bad\n"
        cfg.write_text(invalid_yaml, encoding="utf-8")

        result = self._invoke(
            ["integration", "info", "definitely-not-real"], project
        )
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1, result.output
        assert "configuration file path shown above" in normalized_output
        assert ".specify/integration-catalogs.yml" in normalized_output
        assert "~/.specify/integration-catalogs.yml" in normalized_output
        assert "Try again when online" not in normalized_output

    def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip(
        self, tmp_path, monkeypatch
    ):
        project = self._make_project(tmp_path)
        monkeypatch.setenv(
            "SPECKIT_INTEGRATION_CATALOG_URL",
            "http://insecure.example.com/catalog.json",
        )

        result = self._invoke(
            ["integration", "info", "definitely-not-real"], project
        )
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 1, result.output
        assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output
        assert "unset it to use the configured catalog files" in normalized_output
        assert "Try again when online" not in normalized_output

    # -- catalog list / add / remove ---------------------------------------

    def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        result = self._invoke(["integration", "catalog", "list"], project)
        assert result.exit_code == 0, result.output
        assert "Integration Catalog Sources" in result.output
        assert "No project-level catalog sources configured" in result.output
        assert "Active catalog sources" in result.output
        assert "non-removable" in result.output
        assert "default" in result.output
        assert "community" in result.output
        # Built-in defaults are active, but not removable project entries.
        assert "[0]" not in result.output
        assert "[1]" not in result.output

    def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)

        add_result = self._invoke(
            [
                "integration",
                "catalog",
                "add",
                "https://new.example.com/catalog.json",
                "--name",
                "mine",
            ],
            project,
        )
        assert add_result.exit_code == 0, add_result.output
        assert "Catalog source added" in add_result.output

        cfg_path = project / ".specify" / "integration-catalogs.yml"
        assert cfg_path.exists()

        list_result = self._invoke(["integration", "catalog", "list"], project)
        assert list_result.exit_code == 0, list_result.output
        assert "Project catalog sources" in list_result.output
        assert "[0]" in list_result.output
        assert "mine" in list_result.output
        assert "default" not in list_result.output
        assert "community" not in list_result.output

        remove_result = self._invoke(
            ["integration", "catalog", "remove", "0"], project
        )
        assert remove_result.exit_code == 0, remove_result.output
        assert "'mine' removed" in remove_result.output

    def test_catalog_list_normalizes_blank_project_catalog_names(
        self, tmp_path, monkeypatch
    ):
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
        cfg_path = project / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": "https://null-name.example.com/catalog.json",
                            "name": None,
                        },
                        {
                            "url": "https://blank-name.example.com/catalog.json",
                            "name": "   ",
                        },
                    ]
                }
            ),
            encoding="utf-8",
        )

        result = self._invoke(["integration", "catalog", "list"], project)
        normalized_output = _normalize_cli_output(result.output)

        assert result.exit_code == 0, result.output
        assert "[0] catalog-1" in normalized_output
        assert "[1] catalog-2" in normalized_output
        assert "None" not in normalized_output

    def test_catalog_list_env_override_supersedes_project_config(
        self, tmp_path, monkeypatch
    ):
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.setenv(
            "SPECKIT_INTEGRATION_CATALOG_URL",
            "https://env.example.com/catalog.json",
        )
        cfg_path = project / ".specify" / "integration-catalogs.yml"
        cfg_path.write_text(
            yaml.dump(
                {
                    "catalogs": [
                        {
                            "url": "https://project.example.com/catalog.json",
                            "name": "project",
                            "priority": 1,
                        }
                    ]
                }
            ),
            encoding="utf-8",
        )

        result = self._invoke(["integration", "catalog", "list"], project)
        normalized_output = _normalize_cli_output(result.output)
        assert result.exit_code == 0, result.output
        assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output
        assert "supersedes configured catalog files" in normalized_output
        assert "non-removable" in normalized_output
        assert "https://env.example.com/catalog.json" in normalized_output
        assert "https://project.example.com/catalog.json" not in normalized_output
        assert "[0]" not in normalized_output

    def test_catalog_add_strips_whitespace_in_success_output_and_storage(
        self, tmp_path, monkeypatch
    ):
        """Surrounding whitespace in the URL must not appear in the success
        message or be persisted to the YAML config."""
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)

        padded_url = "  https://padded.example.com/catalog.json  "
        clean_url = "https://padded.example.com/catalog.json"

        add_result = self._invoke(
            [
                "integration",
                "catalog",
                "add",
                padded_url,
                "--name",
                "padded",
            ],
            project,
        )
        assert add_result.exit_code == 0, add_result.output
        assert clean_url in add_result.output
        assert padded_url not in add_result.output

        cfg_path = project / ".specify" / "integration-catalogs.yml"
        import yaml as _yaml
        data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
        urls = [c["url"] for c in data["catalogs"]]
        assert clean_url in urls
        assert padded_url not in urls

    def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        result = self._invoke(
            [
                "integration",
                "catalog",
                "add",
                "http://insecure.example.com/catalog.json",
            ],
            project,
        )
        assert result.exit_code == 1
        assert "HTTPS" in result.output

    def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        url = "https://dup.example.com/catalog.json"
        first = self._invoke(
            ["integration", "catalog", "add", url], project
        )
        assert first.exit_code == 0, first.output
        second = self._invoke(
            ["integration", "catalog", "add", url], project
        )
        assert second.exit_code == 1
        assert "already configured" in second.output

    def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        # Need a config file for remove to attempt an index lookup
        self._invoke(
            [
                "integration",
                "catalog",
                "add",
                "https://only.example.com/catalog.json",
            ],
            project,
        )
        result = self._invoke(
            ["integration", "catalog", "remove", "9"], project
        )
        assert result.exit_code == 1
        assert "out of range" in result.output

    def test_catalog_remove_without_config(self, tmp_path, monkeypatch):
        project = self._make_project(tmp_path)
        result = self._invoke(
            ["integration", "catalog", "remove", "0"], project
        )
        assert result.exit_code == 1
        assert "No catalog config" in result.output

    def test_catalog_remove_final_entry_restores_defaults(
        self, tmp_path, monkeypatch
    ):
        """End-to-end: add → remove-last-entry → list should not error.

        Regression for the flow where a user adds a catalog, removes it, then
        runs any follow-up integration command. Without the fix the config
        file would be left as `catalogs: []` and every subsequent
        `integration` call would fail with "contains no 'catalogs' entries".
        """
        project = self._make_project(tmp_path)
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setenv("USERPROFILE", str(tmp_path))
        monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)

        add = self._invoke(
            [
                "integration",
                "catalog",
                "add",
                "https://only.example.com/catalog.json",
                "--name",
                "only",
            ],
            project,
        )
        assert add.exit_code == 0, add.output

        remove = self._invoke(
            ["integration", "catalog", "remove", "0"], project
        )
        assert remove.exit_code == 0, remove.output
        assert "'only' removed" in remove.output

        cfg_path = project / ".specify" / "integration-catalogs.yml"
        assert not cfg_path.exists(), (
            "config file should be deleted when the final catalog is removed"
        )

        # Follow-up command must succeed and show the built-in defaults,
        # not error out on "contains no 'catalogs' entries".
        listing = self._invoke(["integration", "catalog", "list"], project)
        assert listing.exit_code == 0, listing.output
        assert "default" in listing.output
        assert "community" in listing.output


def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path):
    """refresh_shared_templates must not overwrite a recovered (pre-existing
    user) template without --force, matching install_shared_infra's gate (#2918).
    """
    from specify_cli.shared_infra import (
        load_speckit_manifest,
        refresh_shared_templates,
    )

    project = tmp_path / "proj"
    templates_dir = project / ".specify" / "templates"
    templates_dir.mkdir(parents=True)
    user_file = templates_dir / "spec-template.md"
    user_file.write_text("# USER CUSTOM CONTENT\n", encoding="utf-8")

    # Record the pre-existing file as recovered (its hash was adopted, not written).
    manifest = load_speckit_manifest(project, version="test", console=_NoopConsole())
    rel = ".specify/templates/spec-template.md"
    manifest.record_existing(rel, recovered=True)
    manifest.save()

    # Bundled source ships a different body for the same template.
    core_pack = tmp_path / "core-pack"
    src = core_pack / "templates"
    src.mkdir(parents=True)
    (src / "spec-template.md").write_text("# BUNDLED CONTENT v2\n", encoding="utf-8")

    refresh_shared_templates(
        project,
        version="test",
        core_pack=core_pack,
        repo_root=tmp_path / "unused",
        console=_NoopConsole(),
        invoke_separator=".",
        force=False,
    )

    # Recovered user content must survive (fail-before: replaced by bundled body).
    assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"
