"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch).""" import json import os import pytest from typer.testing import CliRunner from specify_cli import app from tests.conftest import strip_ansi runner = CliRunner() def _init_project(tmp_path, integration="copilot"): """Helper: init a spec-kit project with the given integration.""" project = tmp_path / "proj" project.mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "init", "--here", "--integration", integration, "--script", "sh", "--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 def _run_in_project(project, args): """Run a CLI command from inside a generated project.""" old_cwd = os.getcwd() try: os.chdir(project) return runner.invoke(app, args, catch_exceptions=False) finally: os.chdir(old_cwd) def _write_invalid_manifest(project, key): manifest = project / ".specify" / "integrations" / f"{key}.manifest.json" manifest.write_bytes(b"\xff\xfe\x00") return manifest def _integration_list_row_cells(output: str, key: str) -> list[str]: plain = strip_ansi(output) row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}")) return [cell.strip() for cell in row.split("│")[1:-1]] # ── list ───────────────────────────────────────────────────────────── class TestIntegrationList: def test_list_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) result = runner.invoke(app, ["integration", "list"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Not a spec-kit project" in result.output def test_list_shows_installed(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "list"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "copilot" in result.output assert "installed" in result.output def test_list_shows_available_integrations(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "list"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 # Should show multiple integrations assert "claude" in result.output assert "gemini" in result.output def test_list_shows_multi_install_safe_status(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "list"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "Multi-install" in result.output assert "Safe" in result.output assert _integration_list_row_cells(result.output, "claude")[-1] == "yes" assert _integration_list_row_cells(result.output, "copilot")[-1] == "no" def test_list_rejects_newer_integration_state_schema(self, tmp_path): project = _init_project(tmp_path, "claude") int_json = project / ".specify" / "integration.json" data = json.loads(int_json.read_text(encoding="utf-8")) data["integration_state_schema"] = 99 int_json.write_text(json.dumps(data), encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "list"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 normalized = " ".join(result.output.split()) assert "schema 99" in normalized assert "only supports schema 1" in normalized # ── install ────────────────────────────────────────────────────────── class TestIntegrationInstall: def test_install_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) result = runner.invoke(app, ["integration", "install", "claude"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Not a spec-kit project" in result.output def test_install_unknown_integration(self, tmp_path): project = _init_project(tmp_path) old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "install", "nonexistent"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Unknown integration" in result.output def test_install_already_installed(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "install", "copilot"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 plain = strip_ansi(result.output) assert "already installed" in plain normalized = " ".join(plain.split()) assert "specify integration upgrade copilot" in normalized assert "already the default integration" in normalized assert "No files were changed" in normalized assert "specify integration uninstall copilot" not in normalized def test_install_already_installed_non_default_guides_use(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, ["integration", "install", "codex"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 normalized = " ".join(result.output.split()) assert "already installed" in normalized assert "specify integration use codex" in normalized assert "specify integration upgrade codex" in normalized assert "specify integration uninstall codex" not in normalized def test_install_different_when_one_exists(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "install", "claude"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 plain = strip_ansi(result.output) assert "Installed integrations: copilot" in plain assert "Default integration: copilot" in plain normalized = " ".join(plain.split()) assert "To replace the default integration" in normalized assert "specify integration switch claude" in normalized assert "To install 'claude' alongside" in normalized assert "retry the same install command with --force" in normalized def test_install_multi_safe_integration(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert "installed successfully" in result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" assert data["default_integration"] == "claude" assert data["integration_state_schema"] == 1 assert data["installed_integrations"] == ["claude", "codex"] assert data["integration_settings"]["claude"]["invoke_separator"] == "-" assert data["integration_settings"]["codex"]["invoke_separator"] == "-" assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") init_options = project / ".specify" / "init-options.json" opts = json.loads(init_options.read_text(encoding="utf-8")) opts["speckit_version"] = "0.6.1" init_options.write_text(json.dumps(opts), encoding="utf-8") import specify_cli monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11") result = _run_in_project(project, [ "integration", "install", "codex", "--script", "sh", ]) assert result.exit_code == 0, result.output updated = json.loads(init_options.read_text(encoding="utf-8")) assert updated["speckit_version"] == "0.8.11" assert updated["integration"] == "claude" assert updated["ai"] == "claude" assert updated["context_file"] == "CLAUDE.md" def test_install_additional_preserves_shared_manifest(self, tmp_path): project = _init_project(tmp_path, "claude") shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) assert before old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) assert before <= after def test_install_multi_safe_migrates_legacy_state(self, tmp_path): project = _init_project(tmp_path, "claude") int_json = project / ".specify" / "integration.json" int_json.write_text(json.dumps({ "integration": "claude", "version": "0.0.0", }), encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output data = json.loads(int_json.read_text(encoding="utf-8")) assert data["integration"] == "claude" assert data["default_integration"] == "claude" assert data["installed_integrations"] == ["claude", "codex"] def test_install_multi_unsafe_requires_force(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ]) finally: os.chdir(old_cwd) assert result.exit_code != 0 plain = strip_ansi(result.output) assert "Installed integrations: copilot" in plain assert "multi-install safe" in plain normalized = " ".join(plain.split()) assert "To replace the default integration" in normalized assert "specify integration switch claude" in normalized assert "To install 'claude' alongside" in normalized assert "retry the same install command with --force" in normalized def test_install_multi_unsafe_allowed_with_force(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", "--force", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" assert data["installed_integrations"] == ["copilot", "claude"] def test_install_into_bare_project(self, tmp_path): """Install into a project with .specify/ but no integration.""" project = tmp_path / "bare" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert "installed successfully" in result.output # integration.json written data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" # Manifest created assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() # Claude uses skills directory (not commands) assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() def test_install_bare_project_gets_shared_infra(self, tmp_path): """Installing into a bare project should create shared scripts and templates.""" project = tmp_path / "bare" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output # Shared infrastructure should be present assert (project / ".specify" / "scripts").is_dir() assert (project / ".specify" / "templates").is_dir() script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script_content = script.read_text(encoding="utf-8") assert "/speckit-specify" in script_content assert "/speckit.specify" not in script_content # ── uninstall ──────────────────────────────────────────────────────── class TestIntegrationUninstall: def test_uninstall_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) result = runner.invoke(app, ["integration", "uninstall"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Not a spec-kit project" in result.output def test_uninstall_no_integration(self, tmp_path): project = tmp_path / "proj" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "uninstall"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "No integration" in result.output def test_uninstall_removes_files(self, tmp_path): project = _init_project(tmp_path, "claude") # Claude uses skills directory assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "uninstalled" in result.output # Command files removed assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() # Manifest removed assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists() # integration.json removed assert not (project / ".specify" / "integration.json").exists() def test_uninstall_preserves_modified_files(self, tmp_path): """Full lifecycle: install → modify → uninstall → modified file kept.""" project = _init_project(tmp_path, "claude") plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists() # Modify a file plan_file.write_text("# My custom plan command\n", encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "preserved" in result.output assert ".claude/skills/speckit-plan/SKILL.md" in result.output # Modified file kept assert plan_file.exists() assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n" def test_uninstall_wrong_key(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "uninstall", "claude"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "not installed" in result.output def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "uninstall", "claude"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "manifest" in result.output assert "unreadable" in result.output def test_uninstall_non_default_preserves_default(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, [ "integration", "uninstall", "codex", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" assert data["installed_integrations"] == ["claude"] def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): project = _init_project(tmp_path, "gemini") template = project / ".specify" / "templates" / "plan-template.md" script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit.plan" in template.read_text(encoding="utf-8") assert "/speckit.plan" in script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" assert "/speckit-plan" in template.read_text(encoding="utf-8") assert "/speckit-plan" in script.read_text(encoding="utf-8") def test_uninstall_preserves_shared_infra(self, tmp_path): """Shared scripts and templates are not removed by integration uninstall.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" assert shared_script.exists() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 # Shared infrastructure preserved assert shared_script.exists() assert (project / ".specify" / "templates").is_dir() class TestIntegrationUse: def test_use_installed_integration_sets_default(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "codex" assert data["default_integration"] == "codex" assert data["installed_integrations"] == ["claude", "codex"] opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "codex" assert opts["ai"] == "codex" def test_use_requires_installed_integration(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "use", "codex"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "not installed" in result.output def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit-plan" in template.read_text(encoding="utf-8") assert "/speckit-plan" in script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "gemini", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) assert use_gemini.exit_code == 0, use_gemini.output assert "/speckit.plan" in template.read_text(encoding="utf-8") assert "/speckit.plan" in script.read_text(encoding="utf-8") assert "/speckit-plan" not in script.read_text(encoding="utf-8") use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False) assert use_claude.exit_code == 0, use_claude.output assert "/speckit-plan" in template.read_text(encoding="utf-8") assert "/speckit-plan" in script.read_text(encoding="utf-8") assert "/speckit.plan" not in script.read_text(encoding="utf-8") finally: os.chdir(old_cwd) def test_use_preserves_modified_templates_unless_forced(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" template.write_text("custom template with /speckit-plan\n", encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "gemini", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) assert use_gemini.exit_code == 0, use_gemini.output normalized = " ".join(use_gemini.output.split()) assert "specify integration use gemini --force" in normalized assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n" force_use = runner.invoke(app, [ "integration", "use", "gemini", "--force", ], catch_exceptions=False) assert force_use.exit_code == 0, force_use.output finally: os.chdir(old_cwd) updated = template.read_text(encoding="utf-8") assert "/speckit.plan" in updated assert "custom template" not in updated def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") int_json = project / ".specify" / "integration.json" init_options = project / ".specify" / "init-options.json" old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output before_state = json.loads(int_json.read_text(encoding="utf-8")) before_options = json.loads(init_options.read_text(encoding="utf-8")) import specify_cli def fail_refresh(*args, **kwargs): raise ValueError("refuse refresh") monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) result = runner.invoke(app, [ "integration", "use", "codex", "--force", ]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Failed to refresh shared infrastructure" in result.output assert json.loads(int_json.read_text(encoding="utf-8")) == before_state assert json.loads(init_options.read_text(encoding="utf-8")) == before_options # ── switch ─────────────────────────────────────────────────────────── class TestIntegrationSwitch: def test_switch_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) result = runner.invoke(app, ["integration", "switch", "claude"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Not a spec-kit project" in result.output def test_switch_unknown_target(self, tmp_path): project = _init_project(tmp_path) old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "switch", "nonexistent"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Unknown integration" in result.output def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "codex", "--script", "sh", ]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Could not read integration manifest" in result.output def test_switch_same_noop(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "switch", "copilot"]) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "already the default integration" in result.output def test_switch_same_force_refreshes_shared_templates(self, tmp_path): project = _init_project(tmp_path, "claude") template = project / ".specify" / "templates" / "plan-template.md" script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" template.write_text("# custom shared template\n", encoding="utf-8") script.write_text("# custom shared script\n", encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "claude", "--force", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert "shared infrastructure refreshed" in result.output assert "managed shared infrastructure refreshed" not in result.output assert "/speckit-plan" in template.read_text(encoding="utf-8") assert "/speckit-plan" in script.read_text(encoding="utf-8") def test_switch_installed_target_rejects_integration_options(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, [ "integration", "switch", "codex", "--integration-options", "--bogus", ]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "--integration-options cannot be used" in result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["default_integration"] == "claude" def test_switch_between_integrations(self, tmp_path): project = _init_project(tmp_path, "claude") # Verify claude files exist (claude uses skills) assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit-specify" in shared_script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output assert "Switched to" in result.output # Old claude files removed assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() # New copilot files created assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() assert "/speckit.specify" in shared_script.read_text(encoding="utf-8") assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8") # integration.json updated data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" def test_switch_migrates_extension_commands(self, tmp_path): """Switching should migrate extension commands to the new agent directory.""" project = _init_project(tmp_path, "kimi") # Install the bundled git extension result = _run_in_project(project, ["extension", "add", "git"]) assert result.exit_code == 0, f"extension add failed: {result.output}" # Verify git extension skills exist for kimi kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md" assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" result = _run_in_project(project, [ "integration", "switch", "opencode", "--script", "sh", ]) assert result.exit_code == 0, result.output # Git extension commands should exist for opencode opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" assert opencode_git_feature.exists(), "Git extension command should exist for opencode" # Old kimi extension skills should be removed assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed" # Extension registry should be updated registry = json.loads( (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") ) registered_commands = registry["extensions"]["git"]["registered_commands"] assert "opencode" in registered_commands assert "kimi" not in registered_commands # Switch to claude result = _run_in_project(project, [ "integration", "switch", "claude", "--script", "sh", ]) assert result.exit_code == 0, result.output # Git extension skills should exist for claude claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" assert claude_git_feature.exists(), "Git extension skill should exist for claude" # Old opencode extension commands should be removed assert not opencode_git_feature.exists(), "Old opencode extension command should be removed" # Extension registry should be updated registry = json.loads( (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") ) registered_commands = registry["extensions"]["git"]["registered_commands"] assert "claude" in registered_commands assert "opencode" not in registered_commands def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): """Copilot --skills should receive extension skills, not .agent.md files.""" project = _init_project(tmp_path, "opencode") result = _run_in_project(project, ["extension", "add", "git"]) assert result.exit_code == 0, f"extension add failed: {result.output}" result = _run_in_project(project, [ "integration", "switch", "copilot", "--script", "sh", "--integration-options", "--skills", ]) assert result.exit_code == 0, result.output copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md" assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode" assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files" # Verify Copilot-specific frontmatter: mode field should map from # skill name (speckit-git-feature) back to dot notation (speckit.git-feature) skill_content = copilot_git_feature.read_text(encoding="utf-8") assert "mode: speckit.git-feature" in skill_content, ( "Copilot skill frontmatter should contain mode mapped from skill name" ) registry = json.loads( (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") ) git_meta = registry["extensions"]["git"] assert "speckit-git-feature" in git_meta["registered_skills"] assert "copilot" not in git_meta["registered_commands"] result = _run_in_project(project, [ "integration", "switch", "opencode", "--script", "sh", ]) assert result.exit_code == 0, result.output opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" assert opencode_git_feature.exists(), "Git extension command should exist for opencode" assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed" registry = json.loads( (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") ) git_meta = registry["extensions"]["git"] assert git_meta["registered_skills"] == [] assert "opencode" in git_meta["registered_commands"] assert "copilot" not in git_meta["registered_commands"] def test_switch_does_not_register_disabled_extensions(self, tmp_path): """Disabled extensions should stay disabled and should not migrate commands.""" project = _init_project(tmp_path, "opencode") result = _run_in_project(project, ["extension", "add", "git"]) assert result.exit_code == 0, f"extension add failed: {result.output}" result = _run_in_project(project, ["extension", "disable", "git"]) assert result.exit_code == 0, result.output opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch" result = _run_in_project(project, [ "integration", "switch", "claude", "--script", "sh", ]) assert result.exit_code == 0, result.output claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent" assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch" registry = json.loads( (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") ) git_meta = registry["extensions"]["git"] assert git_meta["enabled"] is False assert "claude" not in git_meta["registered_commands"] assert "opencode" not in git_meta["registered_commands"] def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): """Switching refreshes managed shared scripts to the target command style.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" assert shared_script.exists() shared_content = shared_script.read_text(encoding="utf-8") assert "/speckit-plan" in shared_content old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert shared_script.exists() updated = shared_script.read_text(encoding="utf-8") assert "/speckit.plan" in updated assert "/speckit-plan" not in updated def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): """Regression for #2293: stale managed shared scripts get refreshed on switch.""" import hashlib project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") # Simulate a stale vendored script: write truncated content as bytes # (write_text would translate \n→\r\n on Windows and break the hash) # and update the speckit manifest hash so the stale copy is treated # as "managed" (installed by spec-kit, not a user customization). stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n" shared_script.write_bytes(stale_bytes) manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) manifest_data["files"][".specify/scripts/bash/common.sh"] = ( hashlib.sha256(stale_bytes).hexdigest() ) manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 # Stale managed file should be replaced by the target integration's rendered version. updated = shared_script.read_text(encoding="utf-8") assert "# stale vendored copy" not in updated assert "/speckit.plan" in updated assert "/speckit-plan" not in updated def test_switch_preserves_user_customized_shared_infra(self, tmp_path): """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" # User customization: append bytes but do NOT update manifest hash, # so on-disk hash diverges from the recorded one. original = shared_script.read_bytes() custom_bytes = original + b"\n# user customization\n" shared_script.write_bytes(custom_bytes) old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert shared_script.read_bytes() == custom_bytes assert "Preserved" in result.output def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): """--refresh-shared-infra explicitly overwrites user customizations on switch.""" project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") rendered_bytes = shared_script.read_bytes() # User customization (hash diverges from manifest) custom_bytes = rendered_bytes + b"\n# user customization\n" shared_script.write_bytes(custom_bytes) old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", "--refresh-shared-infra", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 # Customization is overwritten with the target integration's rendered version. updated = shared_script.read_text(encoding="utf-8") assert "# user customization" not in updated assert "/speckit.plan" in updated assert "/speckit-plan" not in updated def test_switch_skips_symlinked_parent_directory(self, tmp_path): """Regression: if .specify/scripts/bash is a symlink, switch must not write through it. Copilot follow-up on #2375: leaf-only symlink check let writes escape when an *ancestor* directory was symlinked outside the project root. """ import sys if sys.platform.startswith("win"): import pytest as _pytest _pytest.skip("Symlink creation typically requires admin on Windows") project = _init_project(tmp_path, "claude") bash_dir = project / ".specify" / "scripts" / "bash" outside = tmp_path / "outside" outside.mkdir() for child in bash_dir.iterdir(): child.rename(outside / child.name) bash_dir.rmdir() bash_dir.symlink_to(outside, target_is_directory=True) sentinel = (outside / "common.sh").read_bytes() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 # Symlinked tree reported, not written through. assert "symlink" in result.output.lower() # Outside dir contents unchanged. assert (outside / "common.sh").read_bytes() == sentinel def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path): """--force (uninstall semantics) must NOT overwrite shared-infra customizations. Regression: ensures the decoupling of --force and --refresh-shared-infra. """ project = _init_project(tmp_path, "claude") shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" bundled_bytes = shared_script.read_bytes() custom_bytes = bundled_bytes + b"\n# user customization\n" shared_script.write_bytes(custom_bytes) old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "copilot", "--script", "sh", "--force", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 # --force alone preserves the customization assert shared_script.read_bytes() == custom_bytes def test_switch_from_nothing(self, tmp_path): """Switch when no integration is installed should just install the target.""" project = tmp_path / "bare" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "switch", "claude", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 assert "Switched to" in result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "codex", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, [ "integration", "switch", "generic", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code != 0 data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "codex" assert data["installed_integrations"] == ["codex"] opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "codex" assert opts["ai"] == "codex" template = project / ".specify" / "templates" / "plan-template.md" assert "/speckit-plan" in template.read_text(encoding="utf-8") class TestIntegrationUpgrade: def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, ["integration", "upgrade", "claude"]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "manifest" in result.output assert "unreadable" in result.output def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") init_options = project / ".specify" / "init-options.json" opts = json.loads(init_options.read_text(encoding="utf-8")) opts["speckit_version"] = "0.6.1" init_options.write_text(json.dumps(opts), encoding="utf-8") import specify_cli monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11") result = _run_in_project(project, [ "integration", "upgrade", "claude", "--force", ]) assert result.exit_code == 0, result.output updated = json.loads(init_options.read_text(encoding="utf-8")) assert updated["speckit_version"] == "0.8.11" def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "gemini") install = _run_in_project(project, [ "integration", "install", "claude", "--script", "sh", ]) assert install.exit_code == 0, install.output init_options = project / ".specify" / "init-options.json" opts = json.loads(init_options.read_text(encoding="utf-8")) opts["speckit_version"] = "0.6.1" init_options.write_text(json.dumps(opts), encoding="utf-8") import specify_cli monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11") result = _run_in_project(project, [ "integration", "upgrade", "claude", "--script", "sh", "--force", ]) assert result.exit_code == 0, result.output updated = json.loads(init_options.read_text(encoding="utf-8")) assert updated["speckit_version"] == "0.8.11" assert updated["integration"] == "gemini" assert updated["ai"] == "gemini" assert updated["context_file"] == "GEMINI.md" def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): project = _init_project(tmp_path, "claude") int_json = project / ".specify" / "integration.json" init_options = project / ".specify" / "init-options.json" manifest_path = project / ".specify" / "integrations" / "claude.manifest.json" before_state = json.loads(int_json.read_text(encoding="utf-8")) before_options = json.loads(init_options.read_text(encoding="utf-8")) before_manifest = manifest_path.read_text(encoding="utf-8") import specify_cli real_install_shared_infra = specify_cli._install_shared_infra calls = {"count": 0} def fail_refresh(*args, **kwargs): calls["count"] += 1 if calls["count"] == 2: raise ValueError("refuse refresh") return real_install_shared_infra(*args, **kwargs) monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) result = _run_in_project(project, [ "integration", "upgrade", "claude", "--force", ]) assert result.exit_code != 0 assert "Failed to refresh shared infrastructure" in result.output assert json.loads(int_json.read_text(encoding="utf-8")) == before_state assert json.loads(init_options.read_text(encoding="utf-8")) == before_options assert manifest_path.read_text(encoding="utf-8") == before_manifest def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path): project = _init_project(tmp_path, "copilot") template = project / ".specify" / "templates" / "plan-template.md" managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" assert "/speckit.plan" in template.read_text(encoding="utf-8") assert "/speckit.specify" in managed_script.read_text(encoding="utf-8") customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n" customized_script.write_text(customized_before, encoding="utf-8") result = _run_in_project(project, [ "integration", "upgrade", "copilot", "--integration-options", "--skills", ]) assert result.exit_code == 0, result.output assert "/speckit-plan" in template.read_text(encoding="utf-8") managed_content = managed_script.read_text(encoding="utf-8") assert "/speckit-specify" in managed_content assert "/speckit.specify" not in managed_content assert customized_script.read_text(encoding="utf-8") == customized_before def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): project = _init_project(tmp_path, "gemini") template = project / ".specify" / "templates" / "plan-template.md" script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" assert "/speckit.plan" in template.read_text(encoding="utf-8") assert "/speckit.plan" in script.read_text(encoding="utf-8") old_cwd = os.getcwd() try: os.chdir(project) install = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ], catch_exceptions=False) assert install.exit_code == 0, install.output result = runner.invoke(app, [ "integration", "upgrade", "claude", "--script", "sh", "--force", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0, result.output data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "gemini" assert "/speckit.plan" in template.read_text(encoding="utf-8") assert "/speckit.plan" in script.read_text(encoding="utf-8") assert "/speckit-plan" not in script.read_text(encoding="utf-8") def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): """Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/.""" project = _init_project(tmp_path, "opencode") # Simulate a legacy project: rename commands/ back to command/ canonical = project / ".opencode" / "commands" legacy = project / ".opencode" / "command" assert canonical.is_dir(), "init should have created .opencode/commands/" canonical.rename(legacy) assert legacy.is_dir() assert not canonical.exists() # Patch the manifest to reflect old paths (command/ not commands/) manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json" manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) patched_files = {} for path, info in manifest_data.get("files", {}).items(): patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info manifest_data["files"] = patched_files manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") old_commands = sorted(legacy.glob("speckit.*.md")) assert len(old_commands) > 0, "Legacy dir should have speckit command files" result = _run_in_project(project, [ "integration", "upgrade", "opencode", "--script", "sh", "--force", ]) assert result.exit_code == 0, f"upgrade failed: {result.output}" # New commands in canonical dir assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade" new_commands = sorted(canonical.glob("speckit.*.md")) assert len(new_commands) > 0, "Commands should exist in .opencode/commands/" # Stale files removed from legacy dir remaining = list(legacy.glob("speckit.*.md")) assert len(remaining) == 0, ( f"Legacy .opencode/command/ should have no speckit files after upgrade, " f"found: {[f.name for f in remaining]}" ) # ── Full lifecycle ─────────────────────────────────────────────────── class TestIntegrationLifecycle: def test_install_modify_uninstall_preserves_modified(self, tmp_path): """Full lifecycle: install → modify file → uninstall → verify modified file kept.""" project = tmp_path / "lifecycle" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) # Install result = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ], catch_exceptions=False) assert result.exit_code == 0 assert "installed successfully" in result.output # Claude uses skills directory plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists() # Modify one file plan_file.write_text("# user customization\n", encoding="utf-8") # Uninstall result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) assert result.exit_code == 0 assert "preserved" in result.output # Modified file kept assert plan_file.exists() assert plan_file.read_text(encoding="utf-8") == "# user customization\n" finally: os.chdir(old_cwd) # ── Edge-case fixes ───────────────────────────────────────────────── class TestScriptTypeValidation: def test_invalid_script_type_rejected(self, tmp_path): """--script with an invalid value should fail with a clear error.""" project = tmp_path / "proj" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", "--script", "bash", ]) finally: os.chdir(old_cwd) assert result.exit_code != 0 assert "Invalid script type" in result.output def test_valid_script_types_accepted(self, tmp_path): """Both 'sh' and 'ps' should be accepted.""" project = tmp_path / "proj" project.mkdir() (project / ".specify").mkdir() old_cwd = os.getcwd() try: os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", "--script", "sh", ], catch_exceptions=False) finally: os.chdir(old_cwd) assert result.exit_code == 0 class TestParseIntegrationOptionsEqualsForm: def test_equals_form_parsed(self): """--commands-dir=./x should be parsed the same as --commands-dir ./x.""" from specify_cli 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"