diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ee75b925d0..a64cd97e15 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -39,22 +39,6 @@ from rich.panel import Panel from rich.align import Align from rich.table import Table -from .integration_runtime import ( - invoke_separator_for_integration as _invoke_separator_for_integration, - resolve_integration_options as _resolve_integration_options_impl, - with_integration_setting as _with_integration_setting, -) -from .integration_state import ( - INTEGRATION_JSON, - INTEGRATION_STATE_SCHEMA, - dedupe_integration_keys as _dedupe_integration_keys, - default_integration_key as _default_integration_key, - installed_integration_keys as _installed_integration_keys, - integration_setting as _integration_setting, - integration_settings as _integration_settings, - try_read_integration_json as _try_read_integration_json, - write_integration_json as _write_integration_json_file, -) from .shared_infra import ( install_shared_infra as _install_shared_infra_impl, refresh_shared_templates as _refresh_shared_templates_impl, @@ -522,210 +506,9 @@ def version( # ===== Integration Commands ===== - -integration_app = typer.Typer( - name="integration", - help="Manage coding agent integrations", - add_completion=False, -) -app.add_typer(integration_app, name="integration") - -integration_catalog_app = typer.Typer( - name="catalog", - help="Manage integration catalog sources", - add_completion=False, -) -integration_app.add_typer(integration_catalog_app, name="catalog") - - -def _read_integration_json(project_root: Path) -> dict[str, Any]: - """Load ``.specify/integration.json``. Returns normalized state when present. - - Delegates the parse / schema-guard logic to the shared - :func:`_try_read_integration_json` helper so the CLI and workflow engine - cannot drift on validation rules. Each error variant is translated into - the existing loud-fail UX (console message + ``typer.Exit(1)``). - """ - path = project_root / INTEGRATION_JSON - state, error = _try_read_integration_json(project_root) - if error is None: - return state or {} - if error.kind == "decode": - console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.") - console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") - console.print(f"[dim]Details:[/dim] {error.detail}") - elif error.kind == "os": - 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] {error.detail}") - elif error.kind == "not_object": - console.print( - f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}." - ) - console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") - elif error.kind == "schema_too_new": - console.print( - f"[red]Error:[/red] {path} uses integration state schema {error.schema}, " - f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}." - ) - console.print("Please upgrade Spec Kit before modifying integrations.") - raise typer.Exit(1) - - -def _write_integration_json( - project_root: Path, - integration_key: str | None, - installed_integrations: list[str] | None = None, - integration_settings: dict[str, dict[str, Any]] | None = None, -) -> None: - """Write ``.specify/integration.json`` with legacy-compatible state.""" - _write_integration_json_file( - project_root, - version=get_speckit_version(), - integration_key=integration_key, - installed_integrations=installed_integrations, - settings=integration_settings, - ) - - -def _refresh_init_options_speckit_version(project_root: Path) -> None: - """Refresh only the Spec Kit version recorded in init-options.json.""" - opts = load_init_options(project_root) - if not isinstance(opts, dict) or not opts: - return - opts["speckit_version"] = get_speckit_version() - save_init_options(project_root, opts) - - -def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match.""" - opts = load_init_options(project_root) - if opts.get("integration") == integration_key or opts.get("ai") == integration_key: - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) - - -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() - - -_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) - - -class _SharedTemplateRefreshError(RuntimeError): - """Raised when default integration metadata should not be persisted.""" - - -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" - - -def _resolve_integration_script_type( - project_root: Path, - state: dict[str, Any], - key: str, - script_type: str | None = None, -) -> str: - """Resolve script type for an integration, preferring stored settings.""" - if script_type: - return _normalize_script_type(script_type, "--script") - - stored = _integration_setting(state, key).get("script") - if isinstance(stored, str) and stored.strip(): - return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script") - - return _resolve_script_type(project_root, None) - - -def _resolve_integration_options( - integration: Any, - state: dict[str, Any], - key: str, - raw_options: str | None, -) -> tuple[str | None, dict[str, Any] | None]: - """Resolve raw and parsed options for an integration operation.""" - return _resolve_integration_options_impl( - integration, - state, - key, - raw_options, - parse_options=_parse_integration_options, - ) - - -def _set_default_integration( - project_root: Path, - state: dict[str, Any], - key: str, - integration: Any, - installed_keys: list[str], - *, - script_type: str | None = None, - raw_options: str | None = None, - parsed_options: dict[str, Any] | None = None, - refresh_templates: bool = True, - refresh_templates_force: bool = False, -) -> None: - """Persist *key* as default and align active runtime metadata.""" - resolved_script = _resolve_integration_script_type(project_root, state, key, script_type) - settings = _with_integration_setting( - state, - key, - integration, - script_type=resolved_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - - if refresh_templates: - try: - _refresh_shared_templates( - project_root, - invoke_separator=_invoke_separator_for_integration( - integration, {"integration_settings": settings}, key, parsed_options - ), - force=refresh_templates_force, - ) - except (ValueError, OSError) as exc: - raise _SharedTemplateRefreshError( - f"Failed to refresh shared templates for '{key}': {exc}" - ) from exc - - _write_integration_json(project_root, key, installed_keys, settings) - _update_init_options_for_integration(project_root, integration, script_type=resolved_script) - - -def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None: - try: - _set_default_integration(*args, **kwargs) - except _SharedTemplateRefreshError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) +# Moved to integrations/_commands.py — registered here to preserve CLI surface. +from .integrations._commands import register as _register_integration_cmds # noqa: E402 +_register_integration_cmds(app) def _require_specify_project() -> Path: @@ -738,1222 +521,6 @@ def _require_specify_project() -> Path: raise typer.Exit(1) -@integration_app.command("list") -def integration_list( - catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), -): - """List available integrations and installed status.""" - from .integrations import INTEGRATION_REGISTRY - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = set(_installed_integration_keys(current)) - - if catalog: - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - ic = IntegrationCatalog(project_root) - try: - entries = ic.search() - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - if not entries: - console.print("[yellow]No integrations found in catalog.[/yellow]") - return - - table = Table(title="Integration Catalog") - table.add_column("ID", style="cyan") - table.add_column("Name") - table.add_column("Version") - table.add_column("Source") - table.add_column("Status") - table.add_column("Multi-install Safe") - - for entry in sorted(entries, key=lambda e: e["id"]): - eid = entry["id"] - cat_name = entry.get("_catalog_name", "") - install_allowed = entry.get("_install_allowed", True) - if eid == default_key: - status = "[green]installed (default)[/green]" - elif eid in installed_keys: - status = "[green]installed[/green]" - elif eid in INTEGRATION_REGISTRY: - status = "built-in" - elif install_allowed is False: - status = "discovery-only" - else: - status = "" - safe = "" - if eid in INTEGRATION_REGISTRY: - safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no" - table.add_row( - eid, - entry.get("name", eid), - entry.get("version", ""), - cat_name, - status, - safe, - ) - - console.print(table) - return - - table = Table(title="Coding Agent Integrations") - table.add_column("Key", style="cyan") - table.add_column("Name") - table.add_column("Status") - table.add_column("CLI Required") - table.add_column("Multi-install Safe") - - 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 == default_key: - status = "[green]installed (default)[/green]" - elif key in installed_keys: - status = "[green]installed[/green]" - else: - status = "" - - cli_req = "yes" if requires_cli else "no (IDE)" - safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" - table.add_row(key, name, status, cli_req, safe) - - console.print(table) - - if installed_keys: - console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]") - console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/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)"), - force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), - 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 = _require_specify_project() - 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) - default_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - if key in installed_keys: - console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") - if default_key == key: - console.print("It is already the default integration.") - else: - console.print( - f"To make it the default integration, run " - f"[cyan]specify integration use {key}[/cyan]." - ) - console.print( - f"To refresh its managed files or options, run " - f"[cyan]specify integration upgrade {key}[/cyan]." - ) - console.print("No files were changed.") - raise typer.Exit(0) - - if installed_keys and not force: - unsafe_keys = [] - for installed_key in installed_keys: - installed_integration = get_integration(installed_key) - if not installed_integration or not getattr(installed_integration, "multi_install_safe", False): - unsafe_keys.append(installed_key) - if unsafe_keys or not getattr(integration, "multi_install_safe", False): - console.print( - f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}." - ) - if default_key: - console.print(f"Default integration: [cyan]{default_key}[/cyan].") - console.print( - "Installing multiple integrations is only automatic when all involved " - "integrations are declared multi-install safe." - ) - console.print( - f"To replace the default integration, run " - f"[cyan]specify integration switch {key}[/cyan]." - ) - console.print( - f"To install '{key}' alongside the existing integrations anyway, " - "retry the same install command with [cyan]--force[/cyan]." - ) - raise typer.Exit(1) - - selected_script = _resolve_script_type(project_root, script) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - integration, current, key, integration_options - ) - - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - infra_integration = integration - infra_key = key - infra_parsed = parsed_options - if default_key: - default_integration = get_integration(default_key) - if default_integration is not None: - infra_integration = default_integration - infra_key = default_key - _, infra_parsed = _resolve_integration_options( - default_integration, current, default_key, None - ) - _install_shared_infra_or_exit( - project_root, - selected_script, - invoke_separator=_invoke_separator_for_integration( - infra_integration, current, infra_key, infra_parsed - ), - ) - if os.name != "nt": - ensure_executable_scripts(project_root) - - manifest = IntegrationManifest( - integration.key, project_root, version=get_speckit_version() - ) - - try: - integration.setup( - project_root, manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - manifest.save() - new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) - new_default = default_key or integration.key - settings = _with_integration_setting( - current, - integration.key, - integration, - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - _write_integration_json(project_root, new_default, new_installed, settings) - if new_default == integration.key: - _update_init_options_for_integration(project_root, integration, script_type=selected_script) - else: - _refresh_init_options_speckit_version(project_root) - - except Exception as exc: - # 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 - _print_cli_warning( - "rollback", - "integration", - key, - rollback_err, - continuing="The original install failure is still the primary error.", - ) - if installed_keys: - _write_integration_json( - project_root, default_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - console.print( - f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: " - f"{_cli_error_detail(exc)}" - ) - raise typer.Exit(1) - - name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") - if default_key: - console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") - - -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 - opts["context_file"] = integration.context_file - opts["speckit_version"] = get_speckit_version() - if script_type: - opts["script"] = script_type - if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): - opts["ai_skills"] = True - else: - opts.pop("ai_skills", None) - save_init_options(project_root, opts) - - -@integration_app.command("use") -def integration_use( - key: str = typer.Argument(help="Installed integration key to make the default"), - force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), -): - """Set the default integration without uninstalling other integrations.""" - from .integrations import get_integration - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - installed_keys = _installed_integration_keys(current) - if key not in installed_keys: - console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") - if installed_keys: - console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}") - else: - console.print("Install one with: [cyan]specify integration install [/cyan]") - raise typer.Exit(1) - - integration = get_integration(key) - if integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{key}'") - raise typer.Exit(1) - - raw_options, parsed_options = _resolve_integration_options(integration, current, key, None) - _set_default_integration_or_exit( - project_root, - current, - key, - integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - refresh_templates_force=force, - ) - console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") - - -@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 = _require_specify_project() - current = _read_integration_json(project_root) - default_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - if key is None: - if not default_key: - console.print("[yellow]No integration is currently installed.[/yellow]") - raise typer.Exit(0) - key = default_key - - if key not in installed_keys: - console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") - 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]") - remaining = [installed for installed in installed_keys if installed != key] - new_default = default_key if default_key != key else (remaining[0] if remaining else None) - if remaining: - if default_key == key and new_default and (new_integration := get_integration(new_default)): - raw_options, parsed_options = _resolve_integration_options( - new_integration, current, new_default, None - ) - _set_default_integration_or_exit( - project_root, - current, - new_default, - new_integration, - remaining, - raw_options=raw_options, - parsed_options=parsed_options, - ) - else: - _write_integration_json( - project_root, new_default, remaining, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - if default_key == key: - _clear_init_options_for_integration(project_root, key) - raise typer.Exit(0) - - try: - manifest = IntegrationManifest.load(key, project_root) - except _MANIFEST_READ_ERRORS 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 managed context section from the agent context file - if integration: - integration.remove_context_section(project_root) - - remaining = [installed for installed in installed_keys if installed != key] - new_default = default_key if default_key != key else (remaining[0] if remaining else None) - if remaining: - if default_key == key and new_default and (new_integration := get_integration(new_default)): - raw_options, parsed_options = _resolve_integration_options( - new_integration, current, new_default, None - ) - _set_default_integration_or_exit( - project_root, - current, - new_default, - new_integration, - remaining, - raw_options=raw_options, - parsed_options=parsed_options, - ) - else: - _write_integration_json( - project_root, new_default, remaining, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - - if default_key == key: - _clear_init_options_for_integration(project_root, key) - - 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 = _display_project_path(project_root, 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 of the previous integration"), - refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"), - 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 = _require_specify_project() - 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_keys = _installed_integration_keys(current) - installed_key = _default_integration_key(current) - - if installed_key == target: - if integration_options is not None: - console.print( - "[red]Error:[/red] --integration-options cannot be used when switching " - "to an already installed integration." - ) - console.print( - f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " - "to update managed files/options." - ) - raise typer.Exit(1) - if force: - raw_options, parsed_options = _resolve_integration_options( - target_integration, current, target, None - ) - _set_default_integration_or_exit( - project_root, - current, - target, - target_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - refresh_templates_force=True, - ) - console.print( - f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " - "managed shared templates refreshed." - ) - raise typer.Exit(0) - console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") - raise typer.Exit(0) - - if target in installed_keys: - if integration_options is not None: - console.print( - "[red]Error:[/red] --integration-options cannot be used when switching " - "to an already installed integration." - ) - console.print( - f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " - f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]." - ) - raise typer.Exit(1) - raw_options, parsed_options = _resolve_integration_options( - target_integration, current, target, None - ) - _set_default_integration_or_exit( - project_root, - current, - target, - target_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - refresh_templates_force=force, - ) - console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") - 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 _MANIFEST_READ_ERRORS 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) - current_integration.remove_context_section(project_root) - 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 _MANIFEST_READ_ERRORS 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) - - # Unregister extension commands for the old agent so they don't - # remain as orphans in the old agent's directory. - try: - from .extensions import ExtensionManager - - ext_mgr = ExtensionManager(project_root) - ext_mgr.unregister_agent_artifacts(installed_key) - except Exception as ext_err: - _print_cli_warning( - "clean up extension artifacts for", - "integration", - installed_key, - ext_err, - continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", - ) - - # Clear metadata so a failed Phase 2 doesn't leave stale references - installed_keys = [installed for installed in installed_keys if installed != installed_key] - _clear_init_options_for_integration(project_root, installed_key) - if installed_keys: - fallback_key = installed_keys[0] - fallback_integration = get_integration(fallback_key) - if fallback_integration is not None: - raw_options, parsed_options = _resolve_integration_options( - fallback_integration, current, fallback_key, None - ) - _set_default_integration_or_exit( - project_root, - current, - fallback_key, - fallback_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - ) - else: - _write_integration_json( - project_root, fallback_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - current = _read_integration_json(project_root) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - target_integration, current, target, integration_options - ) - - # Refresh shared infrastructure to the current CLI version. Switching - # integrations is exactly when stale vendored shared scripts (e.g. - # update-agent-context.sh that pre-dates the target integration's - # supported-agent list) would silently break the new integration. - # - # Use refresh_managed=True so only files that match their previously - # recorded hash are overwritten — user customizations are detected via - # hash divergence and preserved with a warning. Pass - # --refresh-shared-infra to overwrite customizations as well. See #2293. - _install_shared_infra_or_exit( - project_root, - selected_script, - force=refresh_shared_infra, - refresh_managed=True, - invoke_separator=_invoke_separator_for_integration( - target_integration, current, target, parsed_options - ), - refresh_hint=( - "To overwrite customizations, re-run with " - "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." - ), - ) - 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() - ) - - try: - target_integration.setup( - project_root, manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - manifest.save() - _set_default_integration( - project_root, - current, - target_integration.key, - target_integration, - _dedupe_integration_keys([*installed_keys, target_integration.key]), - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - - # Re-register extension commands for the new agent so that - # previously-installed extensions are available in the new integration. - try: - from .extensions import ExtensionManager - - ext_mgr = ExtensionManager(project_root) - ext_mgr.register_enabled_extensions_for_agent(target) - except Exception as ext_err: - _print_cli_warning( - "register extension artifacts for", - "integration", - target, - ext_err, - continuing="The integration switch succeeded, but installed extensions may need re-registration.", - ) - - except Exception as exc: - # 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 - _print_cli_warning( - "rollback", - "integration", - target, - rollback_err, - continuing="The original switch failure is still the primary error.", - ) - if installed_keys: - fallback_key = installed_keys[0] - fallback_integration = get_integration(fallback_key) - if fallback_integration is not None: - raw_options, parsed_options = _resolve_integration_options( - fallback_integration, current, fallback_key, None - ) - try: - _set_default_integration( - project_root, - current, - fallback_key, - fallback_integration, - installed_keys, - raw_options=raw_options, - parsed_options=parsed_options, - ) - except _SharedTemplateRefreshError as restore_err: - console.print( - f"[yellow]Warning:[/yellow] Failed to restore default " - f"integration '{fallback_key}': {restore_err}" - ) - else: - _write_integration_json( - project_root, fallback_key, installed_keys, _integration_settings(current) - ) - else: - _remove_integration_json(project_root) - console.print( - f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} " - f"during switch: {_cli_error_detail(exc)}" - ) - raise typer.Exit(1) - - name = (target_integration.config or {}).get("name", target) - console.print(f"\n[green]✓[/green] Switched to integration '{name}'") - - -@integration_app.command("upgrade") -def integration_upgrade( - key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), - force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), - 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"), -): - """Upgrade an integration by reinstalling with diff-aware file handling. - - Compares manifest hashes to detect locally modified files and - blocks the upgrade unless --force is used. - """ - from .integrations import get_integration - from .integrations.manifest import IntegrationManifest - - project_root = _require_specify_project() - current = _read_integration_json(project_root) - installed_key = _default_integration_key(current) - installed_keys = _installed_integration_keys(current) - - 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 key not in installed_keys: - console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") - raise typer.Exit(1) - - integration = get_integration(key) - if integration is None: - console.print(f"[red]Error:[/red] Unknown integration '{key}'") - raise typer.Exit(1) - - 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 upgrade.[/yellow]") - console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") - raise typer.Exit(0) - - try: - old_manifest = IntegrationManifest.load(key, project_root) - except _MANIFEST_READ_ERRORS as exc: - console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") - raise typer.Exit(1) - - # Detect modified files via manifest hashes - modified = old_manifest.check_modified() - if modified and not force: - console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") - for rel in modified: - console.print(f" {rel}") - console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") - raise typer.Exit(1) - - selected_script = _resolve_integration_script_type(project_root, current, key, script) - - # Build parsed options from --integration-options so the integration - # can determine its effective invoke separator before shared infra - # is installed. - raw_options, parsed_options = _resolve_integration_options( - integration, current, key, integration_options - ) - - # Ensure shared infrastructure is up to date; --force overwrites existing files. - infra_integration = integration - infra_key = key - infra_parsed = parsed_options - if installed_key and installed_key != key: - default_integration = get_integration(installed_key) - if default_integration is not None: - infra_integration = default_integration - infra_key = installed_key - _, infra_parsed = _resolve_integration_options( - default_integration, current, installed_key, None - ) - _install_shared_infra_or_exit( - project_root, - selected_script, - force=force, - invoke_separator=_invoke_separator_for_integration( - infra_integration, current, infra_key, infra_parsed - ), - ) - if os.name != "nt": - ensure_executable_scripts(project_root) - - # Phase 1: Install new files (overwrites existing; old-only files remain) - console.print(f"Upgrading integration: [cyan]{key}[/cyan]") - new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) - - try: - integration.setup( - project_root, - new_manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - ) - settings = _with_integration_setting( - current, - key, - integration, - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - ) - if installed_key == key: - try: - _refresh_shared_templates( - project_root, - invoke_separator=_invoke_separator_for_integration( - integration, {"integration_settings": settings}, key, parsed_options - ), - force=force, - ) - except (ValueError, OSError) as exc: - raise _SharedTemplateRefreshError( - f"Failed to refresh shared templates for '{key}': {exc}" - ) from exc - new_manifest.save() - _write_integration_json(project_root, installed_key, installed_keys, settings) - if installed_key == key: - _update_init_options_for_integration(project_root, integration, script_type=selected_script) - else: - _refresh_init_options_speckit_version(project_root) - except Exception as exc: - # Don't teardown — setup overwrites in-place, so teardown would - # delete files that were working before the upgrade. Just report. - console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.") - console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") - console.print("[yellow]The previous integration files may still be in place.[/yellow]") - raise typer.Exit(1) - - # Phase 2: Remove stale files from old manifest that are not in the new one - old_files = old_manifest.files - new_files = new_manifest.files - stale_keys = set(old_files) - set(new_files) - if stale_keys: - stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") - stale_manifest._files = {k: old_files[k] for k in stale_keys} - stale_removed, _ = stale_manifest.uninstall(project_root, force=True) - if stale_removed: - console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") - - name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") - - -# ===== Integration catalog discovery commands ===== -# -# These commands mirror the workflow catalog CLI shape: -# - `search` / `info` for discovery over the active catalog stack -# - `catalog list/add/remove` for managing catalog sources -# -# They deliberately do NOT add `integration add/remove/enable/disable/ -# set-priority`: integrations are single-active (install / uninstall / switch), -# not additive like extensions and presets. - - -@integration_app.command("search") -def integration_search( - query: Optional[str] = typer.Argument(None, help="Search query (optional)"), - tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), - author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), -): - """Search for integrations in the active catalog stack.""" - from .integrations import INTEGRATION_REGISTRY - from .integrations.catalog import ( - IntegrationCatalog, - IntegrationCatalogError, - IntegrationValidationError, - ) - - project_root = _require_specify_project() - integration_config = _read_integration_json(project_root) - installed_key = integration_config.get("integration") - catalog = IntegrationCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except IntegrationValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - console.print( - "\nTip: Check the configuration file path shown above for invalid catalog configuration " - "(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." - ) - raise typer.Exit(1) - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): - console.print( - "\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid " - "catalog URL, or unset it to use the configured catalog files " - "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." - ) - else: - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") - raise typer.Exit(1) - - if not results: - console.print("\n[yellow]No integrations found matching criteria[/yellow]") - if query or tag or author: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify integration search (show all)") - return - - console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n") - for integ in sorted(results, key=lambda e: e.get("id", "")): - iid = integ.get("id", "?") - name = integ.get("name", iid) - version = integ.get("version", "?") - console.print(f"[bold]{name}[/bold] ({iid}) v{version}") - desc = integ.get("description", "") - if desc: - console.print(f" {desc}") - - console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}") - tags = integ.get("tags", []) - if isinstance(tags, list) and tags: - console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") - - cat_name = integ.get("_catalog_name", "") - install_allowed = integ.get("_install_allowed", True) - if cat_name: - if install_allowed: - console.print(f" [dim]Catalog:[/dim] {cat_name}") - else: - console.print( - f" [dim]Catalog:[/dim] {cat_name} " - "[yellow](discovery only — not installable)[/yellow]" - ) - - if iid == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - elif iid in INTEGRATION_REGISTRY: - console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") - elif install_allowed: - console.print( - "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " - "can be installed with 'specify integration install'." - ) - else: - console.print( - f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." - ) - console.print() - - -@integration_app.command("info") -def integration_info( - integration_id: str = typer.Argument(..., help="Integration ID"), -): - """Show catalog details for a single integration.""" - from .integrations import INTEGRATION_REGISTRY - from .integrations.catalog import ( - IntegrationCatalog, - IntegrationCatalogError, - IntegrationValidationError, - ) - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - installed_key = _read_integration_json(project_root).get("integration") - - try: - info = catalog.get_integration_info(integration_id) - except IntegrationCatalogError as exc: - info = None - # Keep the live exception so the fallback branch below can give - # different guidance for local-config vs. network failures. - catalog_error: Optional[IntegrationCatalogError] = exc - else: - catalog_error = None - - if info: - name = info.get("name", integration_id) - version = info.get("version", "?") - console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}") - if info.get("description"): - console.print(f" {info['description']}") - console.print() - - console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}") - if info.get("license"): - console.print(f" [dim]License:[/dim] {info['license']}") - - tags = info.get("tags", []) - if isinstance(tags, list) and tags: - console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") - - cat_name = info.get("_catalog_name", "") - install_allowed = info.get("_install_allowed", True) - if cat_name: - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}") - - if info.get("repository"): - console.print(f" [dim]Repository:[/dim] {info['repository']}") - - if integration_id == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - elif integration_id in INTEGRATION_REGISTRY: - console.print("\n [dim]Built-in integration (not currently active)[/dim]") - return - - if integration_id in INTEGRATION_REGISTRY: - integration = INTEGRATION_REGISTRY[integration_id] - cfg = integration.config or {} - name = cfg.get("name", integration_id) - console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") - console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") - if integration_id == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - if catalog_error: - console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}") - return - - if catalog_error: - console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}") - if isinstance(catalog_error, IntegrationValidationError): - console.print( - "\nCheck the configuration file path shown above " - "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), " - "or use a built-in integration ID directly." - ) - elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): - console.print( - "\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, " - "or unset it to use the configured catalog files, or use a built-in integration ID directly." - ) - else: - console.print("\nTry again when online, or use a built-in integration ID directly.") - else: - console.print(f"[red]Error:[/red] Integration '{integration_id}' not found") - console.print("\nTry: specify integration search") - raise typer.Exit(1) - - -@integration_catalog_app.command("list") -def integration_catalog_list(): - """List configured integration catalog sources.""" - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() - - try: - if env_override: - project_configs = None - configs = catalog.get_catalog_configs() - else: - project_configs = catalog.get_project_catalog_configs() - configs = project_configs if project_configs is not None else catalog.get_catalog_configs() - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n") - if env_override: - console.print( - " SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files." - ) - console.print( - " Project/user catalog sources are not active while the env override is set.\n" - ) - console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n") - elif project_configs is None: - console.print(" No project-level catalog sources configured.\n") - console.print("[bold]Active catalog sources (non-removable here):[/bold]\n") - else: - console.print("[bold]Project catalog sources (removable):[/bold]\n") - - for i, cfg in enumerate(configs): - install_status = ( - "[green]install allowed[/green]" - if cfg.get("install_allowed") - else "[yellow]discovery only[/yellow]" - ) - raw_name = cfg.get("name") - display_name = str(raw_name).strip() if raw_name is not None else "" - if not display_name: - display_name = f"catalog-{i + 1}" - if env_override or project_configs is None: - console.print(f" - [bold]{display_name}[/bold] — {install_status}") - else: - console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}") - console.print(f" {cfg.get('url', '')}") - if cfg.get("description"): - console.print(f" [dim]{cfg['description']}[/dim]") - console.print() - - -@integration_catalog_app.command("add") -def integration_catalog_add( - url: str = typer.Argument( - ..., - help=( - "Catalog URL to add (HTTPS required, except http://localhost, " - "http://127.0.0.1, or http://[::1] for local testing)" - ), - ), - name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), -): - """Add an integration catalog source to the project config.""" - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - - # Normalize once here so the success message reflects what was actually - # stored. ``IntegrationCatalog.add_catalog`` strips again defensively. - normalized_url = url.strip() - - try: - catalog.add_catalog(normalized_url, name) - except IntegrationCatalogError as exc: - # Covers both URL validation (base class) and config-file validation - # (IntegrationValidationError subclass). - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") - - -@integration_catalog_app.command("remove") -def integration_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), -): - """Remove an integration catalog source by 0-based index.""" - from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError - - project_root = _require_specify_project() - catalog = IntegrationCatalog(project_root) - - try: - removed_name = catalog.remove_catalog(index) - except IntegrationCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") - - # ===== Preset Commands ===== diff --git a/src/specify_cli/commands/extension.py b/src/specify_cli/commands/extension.py deleted file mode 100644 index f40a2c8f1f..0000000000 --- a/src/specify_cli/commands/extension.py +++ /dev/null @@ -1,2 +0,0 @@ -"""specify extension * commands — placeholder for future extraction.""" -from __future__ import annotations diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index f04feb4057..517c8da359 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -151,12 +151,14 @@ def init( # Lazy imports to avoid circular dependency — __init__.py imports this module from .. import ( _install_shared_infra_or_exit, - _parse_integration_options, _print_cli_warning, - _write_integration_json, ensure_executable_scripts, save_init_options, ) + from ..integrations._commands import ( + _parse_integration_options, + _write_integration_json, + ) from ..integration_runtime import with_integration_setting as _with_integration_setting show_banner() diff --git a/src/specify_cli/commands/integration.py b/src/specify_cli/commands/integration.py deleted file mode 100644 index a42fbaaea9..0000000000 --- a/src/specify_cli/commands/integration.py +++ /dev/null @@ -1,2 +0,0 @@ -"""specify integration * commands — placeholder for future extraction.""" -from __future__ import annotations diff --git a/src/specify_cli/commands/preset.py b/src/specify_cli/commands/preset.py deleted file mode 100644 index 510415af1c..0000000000 --- a/src/specify_cli/commands/preset.py +++ /dev/null @@ -1,2 +0,0 @@ -"""specify preset * commands — placeholder for future extraction.""" -from __future__ import annotations diff --git a/src/specify_cli/commands/workflow.py b/src/specify_cli/commands/workflow.py deleted file mode 100644 index 3fa1f48ddf..0000000000 --- a/src/specify_cli/commands/workflow.py +++ /dev/null @@ -1,2 +0,0 @@ -"""specify workflow * commands — placeholder for future extraction.""" -from __future__ import annotations diff --git a/src/specify_cli/integrations/_commands.py b/src/specify_cli/integrations/_commands.py new file mode 100644 index 0000000000..26dbcb0417 --- /dev/null +++ b/src/specify_cli/integrations/_commands.py @@ -0,0 +1,34 @@ +"""specify integration * commands — app objects and register() entry point.""" +from __future__ import annotations + +import typer + +from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests + +# Re-export helpers used by commands/init.py and tests +from ._helpers import ( # noqa: F401 + _cli_error_detail, + _cli_phase_label, + _parse_integration_options, + _write_integration_json, +) + +integration_app = typer.Typer( + name="integration", + help="Manage coding agent integrations", + add_completion=False, +) + +integration_catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalog sources", + add_completion=False, +) +integration_app.add_typer(integration_catalog_app, name="catalog") + + +def register(app: typer.Typer) -> None: + from . import _install_commands # noqa: F401 — registers handlers via decorators + from . import _migrate_commands # noqa: F401 + from . import _query_commands # noqa: F401 + app.add_typer(integration_app, name="integration") diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py new file mode 100644 index 0000000000..6a4f984faf --- /dev/null +++ b/src/specify_cli/integrations/_helpers.py @@ -0,0 +1,340 @@ +"""specify integration helpers — internal utilities shared across command modules.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import typer + +from .._agent_config import SCRIPT_TYPE_CHOICES +from .._console import console +from ..integration_runtime import ( + invoke_separator_for_integration as _invoke_separator_for_integration, + resolve_integration_options as _resolve_integration_options_impl, + with_integration_setting as _with_integration_setting, +) +from ..integration_state import ( + INTEGRATION_JSON, + INTEGRATION_STATE_SCHEMA, + integration_setting as _integration_setting, + try_read_integration_json as _try_read_integration_json, + write_integration_json as _write_integration_json_file, +) + + +def _get_speckit_version() -> str: + """Return the current Spec Kit version. + + Resolved lazily through ``_commands.get_speckit_version`` so that tests + that monkeypatch ``specify_cli.integrations._commands.get_speckit_version`` + still affect helpers called from the command handlers. + """ + from . import _commands # noqa: PLC0415 — intentional late import to avoid circular + enable patching + return _commands.get_speckit_version() + + +# --------------------------------------------------------------------------- +# JSON read / write helpers +# --------------------------------------------------------------------------- + +def _read_integration_json(project_root: Path) -> dict[str, Any]: + """Load ``.specify/integration.json``. Returns normalized state when present. + + Delegates the parse / schema-guard logic to the shared + :func:`_try_read_integration_json` helper so the CLI and workflow engine + cannot drift on validation rules. Each error variant is translated into + the existing loud-fail UX (console message + ``typer.Exit(1)``). + """ + path = project_root / INTEGRATION_JSON + state, error = _try_read_integration_json(project_root) + if error is None: + return state or {} + if error.kind == "decode": + console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.") + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + console.print(f"[dim]Details:[/dim] {error.detail}") + elif error.kind == "os": + 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] {error.detail}") + elif error.kind == "not_object": + console.print( + f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}." + ) + console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") + elif error.kind == "schema_too_new": + console.print( + f"[red]Error:[/red] {path} uses integration state schema {error.schema}, " + f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}." + ) + console.print("Please upgrade Spec Kit before modifying integrations.") + raise typer.Exit(1) + + +def _write_integration_json( + project_root: Path, + integration_key: str | None, + installed_integrations: list[str] | None = None, + integration_settings: dict[str, dict[str, Any]] | None = None, +) -> None: + """Write ``.specify/integration.json`` with legacy-compatible state.""" + _write_integration_json_file( + project_root, + version=_get_speckit_version(), + integration_key=integration_key, + installed_integrations=installed_integrations, + settings=integration_settings, + ) + + +# --------------------------------------------------------------------------- +# init-options.json helpers +# --------------------------------------------------------------------------- + +def _refresh_init_options_speckit_version(project_root: Path) -> None: + """Refresh only the Spec Kit version recorded in init-options.json.""" + from .. import load_init_options, save_init_options + opts = load_init_options(project_root) + if not isinstance(opts, dict) or not opts: + return + opts["speckit_version"] = _get_speckit_version() + save_init_options(project_root, opts) + + +def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: + """Clear active integration keys from init-options.json when they match.""" + from .. import load_init_options, save_init_options + opts = load_init_options(project_root) + if opts.get("integration") == integration_key or opts.get("ai") == integration_key: + opts.pop("integration", None) + opts.pop("ai", None) + opts.pop("ai_skills", None) + opts.pop("context_file", None) + save_init_options(project_root, opts) + + +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() + + +# --------------------------------------------------------------------------- +# Error sentinels +# --------------------------------------------------------------------------- + +_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) + + +class _SharedTemplateRefreshError(RuntimeError): + """Raised when default integration metadata should not be persisted.""" + + +# --------------------------------------------------------------------------- +# Script type resolution +# --------------------------------------------------------------------------- + +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.""" + from .. import load_init_options + 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" + + +def _resolve_integration_script_type( + project_root: Path, + state: dict[str, Any], + key: str, + script_type: str | None = None, +) -> str: + """Resolve script type for an integration, preferring stored settings.""" + if script_type: + return _normalize_script_type(script_type, "--script") + + stored = _integration_setting(state, key).get("script") + if isinstance(stored, str) and stored.strip(): + return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script") + + return _resolve_script_type(project_root, None) + + +# --------------------------------------------------------------------------- +# Integration options +# --------------------------------------------------------------------------- + +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 _resolve_integration_options( + integration: Any, + state: dict[str, Any], + key: str, + raw_options: str | None, +) -> tuple[str | None, dict[str, Any] | None]: + """Resolve raw and parsed options for an integration operation.""" + return _resolve_integration_options_impl( + integration, + state, + key, + raw_options, + parse_options=_parse_integration_options, + ) + + +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 .. import load_init_options, save_init_options + from .base import SkillsIntegration + opts = load_init_options(project_root) + opts["integration"] = integration.key + opts["ai"] = integration.key + opts["context_file"] = integration.context_file + opts["speckit_version"] = _get_speckit_version() + if script_type: + opts["script"] = script_type + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): + opts["ai_skills"] = True + else: + opts.pop("ai_skills", None) + save_init_options(project_root, opts) + + +# --------------------------------------------------------------------------- +# Default integration persistence +# --------------------------------------------------------------------------- + +def _set_default_integration( + project_root: Path, + state: dict[str, Any], + key: str, + integration: Any, + installed_keys: list[str], + *, + script_type: str | None = None, + raw_options: str | None = None, + parsed_options: dict[str, Any] | None = None, + refresh_templates: bool = True, + refresh_templates_force: bool = False, +) -> None: + """Persist *key* as default and align active runtime metadata.""" + from .. import _refresh_shared_templates + resolved_script = _resolve_integration_script_type(project_root, state, key, script_type) + settings = _with_integration_setting( + state, + key, + integration, + script_type=resolved_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + + if refresh_templates: + try: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=refresh_templates_force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc + + _write_integration_json(project_root, key, installed_keys, settings) + _update_init_options_for_integration(project_root, integration, script_type=resolved_script) + + +def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None: + try: + _set_default_integration(*args, **kwargs) + except _SharedTemplateRefreshError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + +# --------------------------------------------------------------------------- +# CLI formatting helpers (re-exported from _commands.py) +# --------------------------------------------------------------------------- + +def _cli_error_detail(exc: BaseException) -> str: + """Return a compact one-line exception detail for CLI output.""" + return str(exc).replace("\n", " ").strip() or exc.__class__.__name__ + + +def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str: + """Format a stable operation label for user-visible diagnostics.""" + label = f"{phase} {target_kind}".strip() + if target: + label = f"{label} '{target}'" + return label diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py new file mode 100644 index 0000000000..8e92f04147 --- /dev/null +++ b/src/specify_cli/integrations/_install_commands.py @@ -0,0 +1,306 @@ +"""specify integration install / uninstall command handlers.""" +from __future__ import annotations + +import os + +import typer + +from .._console import console +from .._utils import _display_project_path +from ..integration_runtime import ( + invoke_separator_for_integration as _invoke_separator_for_integration, + with_integration_setting as _with_integration_setting, +) +from ..integration_state import ( + dedupe_integration_keys as _dedupe_integration_keys, + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, + integration_settings as _integration_settings, +) +from ._commands import integration_app +from ._helpers import ( + _MANIFEST_READ_ERRORS, + _clear_init_options_for_integration, + _cli_error_detail, + _cli_phase_label, + _get_speckit_version, + _read_integration_json, + _refresh_init_options_speckit_version, + _remove_integration_json, + _resolve_integration_options, + _resolve_script_type, + _set_default_integration_or_exit, + _update_init_options_for_integration, + _write_integration_json, +) + + +@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)"), + force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), + 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 . import INTEGRATION_REGISTRY, get_integration + from .manifest import IntegrationManifest + from .. import _require_specify_project, _install_shared_infra_or_exit + + project_root = _require_specify_project() + 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) + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key in installed_keys: + console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") + if default_key == key: + console.print("It is already the default integration.") + else: + console.print( + f"To make it the default integration, run " + f"[cyan]specify integration use {key}[/cyan]." + ) + console.print( + f"To refresh its managed files or options, run " + f"[cyan]specify integration upgrade {key}[/cyan]." + ) + console.print("No files were changed.") + raise typer.Exit(0) + + if installed_keys and not force: + unsafe_keys = [] + for installed_key in installed_keys: + installed_integration = get_integration(installed_key) + if not installed_integration or not getattr(installed_integration, "multi_install_safe", False): + unsafe_keys.append(installed_key) + if unsafe_keys or not getattr(integration, "multi_install_safe", False): + console.print( + f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}." + ) + if default_key: + console.print(f"Default integration: [cyan]{default_key}[/cyan].") + console.print( + "Installing multiple integrations is only automatic when all involved " + "integrations are declared multi-install safe." + ) + console.print( + f"To replace the default integration, run " + f"[cyan]specify integration switch {key}[/cyan]." + ) + console.print( + f"To install '{key}' alongside the existing integrations anyway, " + "retry the same install command with [cyan]--force[/cyan]." + ) + raise typer.Exit(1) + + selected_script = _resolve_script_type(project_root, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if default_key: + default_integration = get_integration(default_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = default_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, default_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) + if os.name != "nt": + from .. import ensure_executable_scripts + ensure_executable_scripts(project_root) + + manifest = IntegrationManifest( + integration.key, project_root, version=_get_speckit_version() + ) + + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + manifest.save() + new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) + new_default = default_key or integration.key + settings = _with_integration_setting( + current, + integration.key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + _write_integration_json(project_root, new_default, new_installed, settings) + if new_default == integration.key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + else: + _refresh_init_options_speckit_version(project_root) + + except Exception as exc: + # 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 + from .. import _print_cli_warning + _print_cli_warning( + "rollback", + "integration", + key, + rollback_err, + continuing="The original install failure is still the primary error.", + ) + if installed_keys: + _write_integration_json( + project_root, default_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + console.print( + f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: " + f"{_cli_error_detail(exc)}" + ) + raise typer.Exit(1) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully") + if default_key: + console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") + + +@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 . import get_integration + from .manifest import IntegrationManifest + from .. import _require_specify_project + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key is None: + if not default_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = default_key + + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + 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]") + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + if default_key == key: + _clear_init_options_for_integration(project_root, key) + raise typer.Exit(0) + + try: + manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS 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 managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + + if default_key == key: + _clear_init_options_for_integration(project_root, key) + + 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 = _display_project_path(project_root, path) + console.print(f" {rel}") diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py new file mode 100644 index 0000000000..b0064421f8 --- /dev/null +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -0,0 +1,487 @@ +"""specify integration switch / upgrade command handlers.""" +from __future__ import annotations + +import os + +import typer + +from .._console import console +from ..integration_runtime import ( + invoke_separator_for_integration as _invoke_separator_for_integration, + with_integration_setting as _with_integration_setting, +) +from ..integration_state import ( + dedupe_integration_keys as _dedupe_integration_keys, + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, + integration_settings as _integration_settings, +) +from ._commands import integration_app +from ._helpers import ( + _MANIFEST_READ_ERRORS, + _SharedTemplateRefreshError, + _clear_init_options_for_integration, + _cli_error_detail, + _cli_phase_label, + _get_speckit_version, + _read_integration_json, + _refresh_init_options_speckit_version, + _remove_integration_json, + _resolve_integration_options, + _resolve_integration_script_type, + _resolve_script_type, + _set_default_integration, + _set_default_integration_or_exit, + _update_init_options_for_integration, + _write_integration_json, +) + + +@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 of the previous integration"), + refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"), + 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 . import INTEGRATION_REGISTRY, get_integration + from .manifest import IntegrationManifest + from .. import _print_cli_warning, _require_specify_project, _install_shared_infra_or_exit + + project_root = _require_specify_project() + 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_keys = _installed_integration_keys(current) + installed_key = _default_integration_key(current) + + if installed_key == target: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + "to update managed files/options." + ) + raise typer.Exit(1) + if force: + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=True, + ) + console.print( + f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " + "managed shared templates refreshed." + ) + raise typer.Exit(0) + console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + if target in installed_keys: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]." + ) + raise typer.Exit(1) + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") + 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 _MANIFEST_READ_ERRORS 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) + current_integration.remove_context_section(project_root) + 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 _MANIFEST_READ_ERRORS 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) + + # Unregister extension commands for the old agent so they don't + # remain as orphans in the old agent's directory. + try: + from ..extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.unregister_agent_artifacts(installed_key) + except Exception as ext_err: + _print_cli_warning( + "clean up extension artifacts for", + "integration", + installed_key, + ext_err, + continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", + ) + + # Clear metadata so a failed Phase 2 doesn't leave stale references + installed_keys = [installed for installed in installed_keys if installed != installed_key] + _clear_init_options_for_integration(project_root, installed_key) + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + _set_default_integration_or_exit( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + current = _read_integration_json(project_root) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, integration_options + ) + + # Refresh shared infrastructure to the current CLI version. Switching + # integrations is exactly when stale vendored shared scripts (e.g. + # update-agent-context.sh that pre-dates the target integration's + # supported-agent list) would silently break the new integration. + # + # Use refresh_managed=True so only files that match their previously + # recorded hash are overwritten — user customizations are detected via + # hash divergence and preserved with a warning. Pass + # --refresh-shared-infra to overwrite customizations as well. See #2293. + _install_shared_infra_or_exit( + project_root, + selected_script, + force=refresh_shared_infra, + refresh_managed=True, + invoke_separator=_invoke_separator_for_integration( + target_integration, current, target, parsed_options + ), + refresh_hint=( + "To overwrite customizations, re-run with " + "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." + ), + ) + if os.name != "nt": + from .. import ensure_executable_scripts + 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() + ) + + try: + target_integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + manifest.save() + _set_default_integration( + project_root, + current, + target_integration.key, + target_integration, + _dedupe_integration_keys([*installed_keys, target_integration.key]), + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + + # Re-register extension commands for the new agent so that + # previously-installed extensions are available in the new integration. + try: + from ..extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.register_enabled_extensions_for_agent(target) + except Exception as ext_err: + _print_cli_warning( + "register extension artifacts for", + "integration", + target, + ext_err, + continuing="The integration switch succeeded, but installed extensions may need re-registration.", + ) + + except Exception as exc: + # 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 + _print_cli_warning( + "rollback", + "integration", + target, + rollback_err, + continuing="The original switch failure is still the primary error.", + ) + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + try: + _set_default_integration( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + except _SharedTemplateRefreshError as restore_err: + console.print( + f"[yellow]Warning:[/yellow] Failed to restore default " + f"integration '{fallback_key}': {restore_err}" + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + console.print( + f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} " + f"during switch: {_cli_error_detail(exc)}" + ) + raise typer.Exit(1) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") + + +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + 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"), +): + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from . import get_integration + from .manifest import IntegrationManifest + from .. import _require_specify_project, _install_shared_infra_or_exit, _refresh_shared_templates + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + 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 key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + 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 upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) + + try: + old_manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) + + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) + + selected_script = _resolve_integration_script_type(project_root, current, key, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) + + # Ensure shared infrastructure is up to date; --force overwrites existing files. + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if installed_key and installed_key != key: + default_integration = get_integration(installed_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = installed_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, installed_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + force=force, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) + if os.name != "nt": + from .. import ensure_executable_scripts + ensure_executable_scripts(project_root) + + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version()) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + settings = _with_integration_setting( + current, + key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + if installed_key == key: + try: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc + new_manifest.save() + _write_integration_json(project_root, installed_key, installed_keys, settings) + if installed_key == key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + else: + _refresh_init_options_speckit_version(project_root) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.") + console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) + + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py new file mode 100644 index 0000000000..c4fbacc9c9 --- /dev/null +++ b/src/specify_cli/integrations/_query_commands.py @@ -0,0 +1,460 @@ +"""specify integration list/use/search/info + catalog list/add/remove command handlers.""" +from __future__ import annotations + +import os +from typing import Optional + +import typer +from rich.table import Table + +from .._console import console +from ..integration_state import ( + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, +) +from ._commands import integration_app, integration_catalog_app +from ._helpers import ( + _read_integration_json, + _resolve_integration_options, + _set_default_integration_or_exit, +) + + +@integration_app.command("list") +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), +): + """List available integrations and installed status.""" + from . import INTEGRATION_REGISTRY + from .. import _require_specify_project + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = set(_installed_integration_keys(current)) + + if catalog: + from .catalog import IntegrationCatalog, IntegrationCatalogError + + ic = IntegrationCatalog(project_root) + try: + entries = ic.search() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not entries: + console.print("[yellow]No integrations found in catalog.[/yellow]") + return + + table = Table(title="Integration Catalog") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Source") + table.add_column("Status") + table.add_column("Multi-install Safe") + + for entry in sorted(entries, key=lambda e: e["id"]): + eid = entry["id"] + cat_name = entry.get("_catalog_name", "") + install_allowed = entry.get("_install_allowed", True) + if eid == default_key: + status = "[green]installed (default)[/green]" + elif eid in installed_keys: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + safe = "" + if eid in INTEGRATION_REGISTRY: + reg_integ = INTEGRATION_REGISTRY[eid] + safe = "[green]yes[/green]" if getattr(reg_integ, "multi_install_safe", False) else "[dim]no[/dim]" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", "?"), + cat_name, + status, + safe, + ) + console.print(table) + return + + if not INTEGRATION_REGISTRY: + console.print("[yellow]No integrations available.[/yellow]") + return + + table = Table(title="Coding Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") + table.add_column("Multi-install Safe") + + 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 == default_key: + status = "[green]installed (default)[/green]" + elif key in installed_keys: + status = "[green]installed[/green]" + else: + status = "" + cli_req = "yes" if requires_cli else "no (IDE)" + safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" + table.add_row(key, name, status, cli_req, safe) + + console.print(table) + + if installed_keys: + console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]") + console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]") + else: + console.print("\n[yellow]No integration currently installed.[/yellow]") + console.print("Install one with: [cyan]specify integration install [/cyan]") + + +@integration_app.command("use") +def integration_use( + key: str = typer.Argument(help="Installed integration key to make the default"), + force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), +): + """Set the default integration without uninstalling other integrations.""" + from . import get_integration + from .. import _require_specify_project + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_keys = _installed_integration_keys(current) + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + if installed_keys: + console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}") + else: + console.print("Install one with: [cyan]specify integration install [/cyan]") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + raw_options, parsed_options = _resolve_integration_options(integration, current, key, None) + _set_default_integration_or_exit( + project_root, + current, + key, + integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") + + +# ===== Integration catalog discovery commands ===== +# +# These commands mirror the workflow catalog CLI shape: +# - `search` / `info` for discovery over the active catalog stack +# - `catalog list/add/remove` for managing catalog sources +# +# They deliberately do NOT add `integration add/remove/enable/disable/ +# set-priority`: integrations are single-active (install / uninstall / switch), +# not additive like extensions and presets. +@integration_app.command("search") +def integration_search( + query: Optional[str] = typer.Argument(None, help="Search query (optional)"), + tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), + author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), +): + """Search for integrations in the active catalog stack.""" + from . import INTEGRATION_REGISTRY + from .catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + from .. import _require_specify_project + + project_root = _require_specify_project() + integration_config = _read_integration_json(project_root) + installed_key = _default_integration_key(integration_config) + catalog = IntegrationCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except IntegrationValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print( + "\nTip: Check the configuration file path shown above for invalid catalog configuration " + "(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + raise typer.Exit(1) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid " + "catalog URL, or unset it to use the configured catalog files " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + else: + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + if not results: + console.print("\n[yellow]No integrations found matching criteria[/yellow]") + if query or tag or author: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify integration search (show all)") + return + + console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n") + for integ in sorted(results, key=lambda e: e.get("id", "")): + iid = integ.get("id", "?") + name = integ.get("name", iid) + version = integ.get("version", "?") + console.print(f"[bold]{name}[/bold] ({iid}) v{version}") + desc = integ.get("description", "") + if desc: + console.print(f" {desc}") + + console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}") + tags = integ.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = integ.get("_catalog_name", "") + install_allowed = integ.get("_install_allowed", True) + if cat_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {cat_name}") + else: + console.print( + f" [dim]Catalog:[/dim] {cat_name} " + "[yellow](discovery only — not installable)[/yellow]" + ) + + if iid == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif iid in INTEGRATION_REGISTRY: + console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") + elif install_allowed: + console.print( + "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " + "can be installed with 'specify integration install'." + ) + else: + console.print( + f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." + ) + console.print() + + +@integration_app.command("info") +def integration_info( + integration_id: str = typer.Argument(..., help="Integration ID"), +): + """Show catalog details for a single integration.""" + from . import INTEGRATION_REGISTRY + from .catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + from .. import _require_specify_project + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + installed_key = _default_integration_key(_read_integration_json(project_root)) + + try: + info = catalog.get_integration_info(integration_id) + except IntegrationCatalogError as exc: + info = None + # Keep the live exception so the fallback branch below can give + # different guidance for local-config vs. network failures. + catalog_error: Optional[IntegrationCatalogError] = exc + else: + catalog_error = None + + if info: + name = info.get("name", integration_id) + version = info.get("version", "?") + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}") + if info.get("description"): + console.print(f" {info['description']}") + console.print() + + console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}") + if info.get("license"): + console.print(f" [dim]License:[/dim] {info['license']}") + + tags = info.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = info.get("_catalog_name", "") + install_allowed = info.get("_install_allowed", True) + if cat_name: + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}") + + if info.get("repository"): + console.print(f" [dim]Repository:[/dim] {info['repository']}") + + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif integration_id in INTEGRATION_REGISTRY: + console.print("\n [dim]Built-in integration (not currently active)[/dim]") + return + + if integration_id in INTEGRATION_REGISTRY: + integration = INTEGRATION_REGISTRY[integration_id] + cfg = integration.config or {} + name = cfg.get("name", integration_id) + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") + console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + if catalog_error: + console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}") + return + + if catalog_error: + console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}") + if isinstance(catalog_error, IntegrationValidationError): + console.print( + "\nCheck the configuration file path shown above " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), " + "or use a built-in integration ID directly." + ) + elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, " + "or unset it to use the configured catalog files, or use a built-in integration ID directly." + ) + else: + console.print("\nTry again when online, or use a built-in integration ID directly.") + else: + console.print(f"[red]Error:[/red] Integration '{integration_id}' not found") + console.print("\nTry: specify integration search") + raise typer.Exit(1) + + +@integration_catalog_app.command("list") +def integration_catalog_list(): + """List configured integration catalog sources.""" + from .catalog import IntegrationCatalog, IntegrationCatalogError + from .. import _require_specify_project + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + + try: + if env_override: + project_configs = None + configs = catalog.get_catalog_configs() + else: + project_configs = catalog.get_project_catalog_configs() + configs = project_configs if project_configs is not None else catalog.get_catalog_configs() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n") + if env_override: + console.print( + " SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files." + ) + console.print( + " Project/user catalog sources are not active while the env override is set.\n" + ) + console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n") + elif project_configs is None: + console.print(" No project-level catalog sources configured.\n") + console.print("[bold]Active catalog sources (non-removable here):[/bold]\n") + else: + console.print("[bold]Project catalog sources (removable):[/bold]\n") + + for i, cfg in enumerate(configs): + install_status = ( + "[green]install allowed[/green]" + if cfg.get("install_allowed") + else "[yellow]discovery only[/yellow]" + ) + raw_name = cfg.get("name") + display_name = str(raw_name).strip() if raw_name is not None else "" + if not display_name: + display_name = f"catalog-{i + 1}" + if env_override or project_configs is None: + console.print(f" - [bold]{display_name}[/bold] — {install_status}") + else: + console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}") + console.print(f" {cfg.get('url', '')}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@integration_catalog_app.command("add") +def integration_catalog_add( + url: str = typer.Argument( + ..., + help=( + "Catalog URL to add (HTTPS required, except http://localhost, " + "http://127.0.0.1, or http://[::1] for local testing)" + ), + ), + name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), +): + """Add an integration catalog source to the project config.""" + from .catalog import IntegrationCatalog, IntegrationCatalogError + from .. import _require_specify_project + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + # Normalize once here so the success message reflects what was actually + # stored. ``IntegrationCatalog.add_catalog`` strips again defensively. + normalized_url = url.strip() + + try: + catalog.add_catalog(normalized_url, name) + except IntegrationCatalogError as exc: + # Covers both URL validation (base class) and config-file validation + # (IntegrationValidationError subclass). + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") + + +@integration_catalog_app.command("remove") +def integration_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove an integration catalog source by 0-based index.""" + from .catalog import IntegrationCatalog, IntegrationCatalogError + from .. import _require_specify_project + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + try: + removed_name = catalog.remove_catalog(index) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index bd06f1281b..c645fa539e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -237,9 +237,9 @@ def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, opts["speckit_version"] = "0.6.1" init_options.write_text(json.dumps(opts), encoding="utf-8") - import specify_cli + import specify_cli.integrations._commands as _int_cmds - monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11") + monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") result = _run_in_project(project, [ "integration", "install", "codex", @@ -1174,9 +1174,9 @@ def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypa opts["speckit_version"] = "0.6.1" init_options.write_text(json.dumps(opts), encoding="utf-8") - import specify_cli + import specify_cli.integrations._commands as _int_cmds - monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11") + monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") result = _run_in_project(project, [ "integration", "upgrade", "claude", @@ -1200,9 +1200,9 @@ def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, opts["speckit_version"] = "0.6.1" init_options.write_text(json.dumps(opts), encoding="utf-8") - import specify_cli + import specify_cli.integrations._commands as _int_cmds - monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11") + monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") result = _run_in_project(project, [ "integration", "upgrade", "claude", @@ -1398,7 +1398,7 @@ def test_valid_script_types_accepted(self, tmp_path): 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._commands import _parse_integration_options from specify_cli.integrations import get_integration integration = get_integration("generic") diff --git a/tests/test_commands_package.py b/tests/test_commands_package.py index e5aeb4fbf4..2e51ff4974 100644 --- a/tests/test_commands_package.py +++ b/tests/test_commands_package.py @@ -13,12 +13,6 @@ def test_commands_init_importable(): assert callable(mod.register) -def test_commands_stubs_importable(): - for name in ("integration", "preset", "extension", "workflow"): - mod = importlib.import_module(f"specify_cli.commands.{name}") - assert mod is not None - - def test_agent_config_importable(): from specify_cli._agent_config import ( AGENT_CONFIG,