diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 28831e6cd..455beea38 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1486,6 +1486,493 @@ def get_speckit_version() -> str: return "unknown" +# ===== Integration Commands ===== + +integration_app = typer.Typer( + name="integration", + help="Manage AI agent integrations", + add_completion=False, +) +app.add_typer(integration_app, name="integration") + + +INTEGRATION_JSON = ".specify/integration.json" + + +def _read_integration_json(project_root: Path) -> dict[str, Any]: + """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + path = project_root / INTEGRATION_JSON + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + console.print(f"[red]Error:[/red] {path} contains invalid JSON.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + except OSError as exc: + console.print(f"[red]Error:[/red] Could not read {path}.") + console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + if not isinstance(data, dict): + console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + raise typer.Exit(1) + return data + + +def _write_integration_json( + project_root: Path, + integration_key: str, + script_type: str, +) -> None: + """Write ``.specify/integration.json`` for *integration_key*.""" + script_ext = "sh" if script_type == "sh" else "ps1" + dest = project_root / INTEGRATION_JSON + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps({ + "integration": integration_key, + "version": get_speckit_version(), + "scripts": { + "update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}", + }, + }, indent=2) + "\n", encoding="utf-8") + + +def _remove_integration_json(project_root: Path) -> None: + """Remove ``.specify/integration.json`` if it exists.""" + path = project_root / INTEGRATION_JSON + if path.exists(): + path.unlink() + + +def _normalize_script_type(script_type: str, source: str) -> str: + """Normalize and validate a script type from CLI/config sources.""" + normalized = script_type.strip().lower() + if normalized in SCRIPT_TYPE_CHOICES: + return normalized + console.print( + f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. " + f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}." + ) + raise typer.Exit(1) + + +def _resolve_script_type(project_root: Path, script_type: str | None) -> str: + """Resolve the script type from the CLI flag or init-options.json.""" + if script_type: + return _normalize_script_type(script_type, "--script") + opts = load_init_options(project_root) + saved = opts.get("script") + if isinstance(saved, str) and saved.strip(): + return _normalize_script_type(saved, ".specify/init-options.json") + return "ps" if os.name == "nt" else "sh" + + +@integration_app.command("list") +def integration_list(): + """List available integrations and installed status.""" + from .integrations import INTEGRATION_REGISTRY + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + table = Table(title="AI Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") + + for key in sorted(INTEGRATION_REGISTRY.keys()): + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config or {} + name = cfg.get("name", key) + requires_cli = cfg.get("requires_cli", False) + + if key == installed_key: + status = "[green]installed[/green]" + else: + status = "" + + cli_req = "yes" if requires_cli else "no (IDE)" + table.add_row(key, name, status, cli_req) + + console.print(table) + + if installed_key: + console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + else: + console.print("\n[yellow]No integration currently installed.[/yellow]") + console.print("Install one with: [cyan]specify integration install [/cyan]") + + +@integration_app.command("install") +def integration_install( + key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), +): + """Install an integration into an existing project.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key and installed_key == key: + console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") + console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + raise typer.Exit(0) + + if installed_key: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") + console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script) + if os.name != "nt": + ensure_executable_scripts(project_root) + + manifest = IntegrationManifest( + integration.key, project_root, version=get_speckit_version() + ) + + # Build parsed options from --integration-options + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, integration.key, selected_script) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration: {e}") + raise typer.Exit(1) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") + + +def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: + """Parse --integration-options string into a dict matching the integration's declared options. + + Returns ``None`` when no options are provided. + """ + import shlex + parsed: dict[str, Any] = {} + tokens = shlex.split(raw_options) + declared_options = list(integration.options()) + declared = {opt.name.lstrip("-"): opt for opt in declared_options} + allowed = ", ".join(sorted(opt.name for opt in declared_options)) + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + name = token.lstrip("-") + value: str | None = None + # Handle --name=value syntax + if "=" in name: + name, value = name.split("=", 1) + opt = declared.get(name) + if not opt: + console.print(f"[red]Error:[/red] Unknown integration option '{token}'.") + if allowed: + console.print(f"Allowed options: {allowed}") + raise typer.Exit(1) + key = name.replace("-", "_") + if opt.is_flag: + if value is not None: + console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") + raise typer.Exit(1) + parsed[key] = True + i += 1 + elif value is not None: + parsed[key] = value + i += 1 + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + parsed[key] = tokens[i + 1] + i += 2 + else: + console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") + raise typer.Exit(1) + return parsed or None + + +def _update_init_options_for_integration( + project_root: Path, + integration: Any, + script_type: str | None = None, +) -> None: + """Update ``init-options.json`` to reflect *integration* as the active one.""" + from .integrations.base import SkillsIntegration + opts = load_init_options(project_root) + opts["integration"] = integration.key + opts["ai"] = integration.key + if script_type: + opts["script"] = script_type + if isinstance(integration, SkillsIntegration): + opts["ai_skills"] = True + else: + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + +@integration_app.command("uninstall") +def integration_uninstall( + key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Remove files even if modified"), +): + """Uninstall an integration, safely preserving modified files.""" + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if installed_key and installed_key != key: + console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').") + raise typer.Exit(1) + + integration = get_integration(key) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]") + _remove_integration_json(project_root) + # Clear integration-related keys from init-options.json + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + raise typer.Exit(0) + + try: + manifest = IntegrationManifest.load(key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") + console.print(f"Manifest: {manifest_path}") + console.print( + f"To recover, delete the unreadable manifest, run " + f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, " + f"then run [cyan]specify integration install {key}[/cyan] to regenerate." + ) + console.print(f"[dim]Details:[/dim] {exc}") + raise typer.Exit(1) + + removed, skipped = manifest.uninstall(project_root, force=force) + + _remove_integration_json(project_root) + + # Update init-options.json to clear the integration + opts = load_init_options(project_root) + if opts.get("integration") == key or opts.get("ai") == key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + name = (integration.config or {}).get("name", key) if integration else key + console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") + for path in skipped: + rel = path.relative_to(project_root) if path.is_absolute() else path + console.print(f" {rel}") + + +@integration_app.command("switch") +def integration_switch( + target: str = typer.Argument(help="Integration key to switch to"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall"), + integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), +): + """Switch from the current integration to a different one.""" + from .integrations import INTEGRATION_REGISTRY, get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + target_integration = get_integration(target) + if target_integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{target}'") + available = ", ".join(sorted(INTEGRATION_REGISTRY.keys())) + console.print(f"Available integrations: {available}") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + if installed_key == target: + console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + selected_script = _resolve_script_type(project_root, script) + + # Phase 1: Uninstall current integration (if any) + if installed_key: + current_integration = get_integration(installed_key) + manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json" + + if current_integration and manifest_path.exists(): + console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + except (ValueError, FileNotFoundError) as exc: + console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}") + console.print(f"[dim]{exc}[/dim]") + console.print( + f"To recover, delete the unreadable manifest at {manifest_path}, " + f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry." + ) + raise typer.Exit(1) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except (ValueError, FileNotFoundError) as exc: + console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}") + else: + console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.") + console.print( + f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, " + f"then retry [cyan]specify integration switch {target}[/cyan]." + ) + raise typer.Exit(1) + + # Clear metadata so a failed Phase 2 doesn't leave stale references + _remove_integration_json(project_root) + opts = load_init_options(project_root) + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 2: Install target integration + console.print(f"Installing integration: [cyan]{target}[/cyan]") + manifest = IntegrationManifest( + target_integration.key, project_root, version=get_speckit_version() + ) + + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() + _write_integration_json(project_root, target_integration.key, selected_script) + _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) + + except Exception as e: + # Attempt rollback of any files written by setup + try: + target_integration.teardown(project_root, manifest, force=True) + except Exception as rollback_err: + # Suppress so the original setup error remains the primary failure + console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") + _remove_integration_json(project_root) + console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") + raise typer.Exit(1) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + + # ===== Preset Commands ===== diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py new file mode 100644 index 000000000..f5322bdf5 --- /dev/null +++ b/tests/integrations/test_integration_subcommand.py @@ -0,0 +1,540 @@ +"""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"