"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch).""" import json import os from typer.testing import CliRunner from specify_cli import app runner = CliRunner() def _init_project(tmp_path, integration="copilot"): """Helper: init a spec-kit project with the given integration.""" project = tmp_path / "proj" project.mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "init", "--here", "--integration", integration, "--script", "sh", "--no-git", "--ignore-agent-tools", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" return project # ── 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 # ── 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 assert "already installed" in result.output assert "uninstall" in result.output 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 assert "already installed" in result.output assert "uninstall" in result.output 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() # ── 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 # 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 the currently installed" in result.output 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() # ── 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_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 installed" in result.output 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() 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() # integration.json updated data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" def test_switch_preserves_shared_infra(self, tmp_path): """Switching preserves shared scripts, templates, and memory.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" assert shared_script.exists() shared_content = 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 # Shared infra untouched assert shared_script.exists() assert shared_script.read_text(encoding="utf-8") == shared_content 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" # ── 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 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"