From 1c22a1a37f62cd559f2efc45c79b1e01817e5881 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:23:51 -0500 Subject: [PATCH 1/3] refactor: organize integration CLI commands Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/cli.md | 6 + src/specify_cli/extensions/__init__.py | 8 +- src/specify_cli/integrations/__init__.py | 5 +- .../integrations/_command_upgrade_layout.py | 188 + src/specify_cli/integrations/_commands.py | 36 +- .../integrations/_install_commands.py | 331 +- .../integrations/_migrate_commands.py | 924 +--- .../integrations/_query_commands.py | 595 +-- .../integrations/_scaffold_commands.py | 61 +- src/specify_cli/integrations/bob/__init__.py | 2 +- .../{catalog.py => catalog/__init__.py} | 26 +- .../integrations/catalog/command_add.py | 42 + .../integrations/catalog/command_list.py | 67 + .../integrations/catalog/command_remove.py | 28 + src/specify_cli/integrations/command_info.py | 114 + .../integrations/command_install.py | 199 + src/specify_cli/integrations/command_list.py | 114 + .../integrations/command_scaffold.py | 54 + .../integrations/command_search.py | 109 + .../integrations/command_status.py | 91 + .../integrations/command_switch.py | 369 ++ .../integrations/command_uninstall.py | 126 + .../integrations/command_upgrade.py | 374 ++ src/specify_cli/integrations/command_use.py | 69 + src/specify_cli/presets/__init__.py | 8 +- .../_integration_scaffold_helpers.py | 16 + tests/integrations/test_cli.py | 816 +-- tests/integrations/test_events.py | 2 +- .../integrations/test_integration_catalog.py | 292 +- .../integrations/test_integration_scaffold.py | 109 +- .../test_integration_subcommand.py | 4499 ----------------- tests/specify_cli/integrations/__init__.py | 1 + .../integrations/_catalog_helpers.py | 107 + tests/specify_cli/integrations/_helpers.py | 77 + .../integrations/catalog/__init__.py | 1 + .../integrations/catalog/test_command_add.py | 110 + .../integrations/catalog/test_command_list.py | 146 + .../catalog/test_command_remove.py | 83 + tests/specify_cli/integrations/conftest.py | 22 + .../integrations/test_command_info.py | 120 + .../integrations/test_command_install.py | 702 +++ .../integrations/test_command_list.py | 175 + .../integrations/test_command_scaffold.py | 93 + .../integrations/test_command_search.py | 199 + .../integrations/test_command_status.py | 835 +++ .../integrations/test_command_switch.py | 875 ++++ .../integrations/test_command_uninstall.py | 222 + .../integrations/test_command_upgrade.py | 1532 ++++++ .../test_command_upgrade_layout.py | 120 + .../integrations/test_command_use.py | 343 ++ .../integrations/test_lifecycle.py | 59 + .../integrations/test_registration.py | 209 + tests/test_extensions.py | 6 +- tests/test_presets.py | 6 +- 54 files changed, 8128 insertions(+), 7595 deletions(-) create mode 100644 src/specify_cli/integrations/_command_upgrade_layout.py rename src/specify_cli/integrations/{catalog.py => catalog/__init__.py} (97%) create mode 100644 src/specify_cli/integrations/catalog/command_add.py create mode 100644 src/specify_cli/integrations/catalog/command_list.py create mode 100644 src/specify_cli/integrations/catalog/command_remove.py create mode 100644 src/specify_cli/integrations/command_info.py create mode 100644 src/specify_cli/integrations/command_install.py create mode 100644 src/specify_cli/integrations/command_list.py create mode 100644 src/specify_cli/integrations/command_scaffold.py create mode 100644 src/specify_cli/integrations/command_search.py create mode 100644 src/specify_cli/integrations/command_status.py create mode 100644 src/specify_cli/integrations/command_switch.py create mode 100644 src/specify_cli/integrations/command_uninstall.py create mode 100644 src/specify_cli/integrations/command_upgrade.py create mode 100644 src/specify_cli/integrations/command_use.py create mode 100644 tests/integrations/_integration_scaffold_helpers.py delete mode 100644 tests/integrations/test_integration_subcommand.py create mode 100644 tests/specify_cli/integrations/__init__.py create mode 100644 tests/specify_cli/integrations/_catalog_helpers.py create mode 100644 tests/specify_cli/integrations/_helpers.py create mode 100644 tests/specify_cli/integrations/catalog/__init__.py create mode 100644 tests/specify_cli/integrations/catalog/test_command_add.py create mode 100644 tests/specify_cli/integrations/catalog/test_command_list.py create mode 100644 tests/specify_cli/integrations/catalog/test_command_remove.py create mode 100644 tests/specify_cli/integrations/conftest.py create mode 100644 tests/specify_cli/integrations/test_command_info.py create mode 100644 tests/specify_cli/integrations/test_command_install.py create mode 100644 tests/specify_cli/integrations/test_command_list.py create mode 100644 tests/specify_cli/integrations/test_command_scaffold.py create mode 100644 tests/specify_cli/integrations/test_command_search.py create mode 100644 tests/specify_cli/integrations/test_command_status.py create mode 100644 tests/specify_cli/integrations/test_command_switch.py create mode 100644 tests/specify_cli/integrations/test_command_uninstall.py create mode 100644 tests/specify_cli/integrations/test_command_upgrade.py create mode 100644 tests/specify_cli/integrations/test_command_upgrade_layout.py create mode 100644 tests/specify_cli/integrations/test_command_use.py create mode 100644 tests/specify_cli/integrations/test_lifecycle.py create mode 100644 tests/specify_cli/integrations/test_registration.py diff --git a/design/cli.md b/design/cli.md index eb563da736..78f7c67813 100644 --- a/design/cli.md +++ b/design/cli.md @@ -157,6 +157,12 @@ subcommand. For example, an `update/` directory would incorrectly suggest an `extension update ...` subcommand group. Use `_command_update_.py` instead. +When a nested CLI namespace has the same name as an existing domain module, +convert that module into a package and keep its established domain exports in +the package `__init__.py`. This preserves imports such as +`from package.catalog import Catalog` while allowing +`package/catalog/command_.py` to mirror the CLI namespace. + ## Registration Command registration remains centralized at the command-group boundary. diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 775ade731c..50acfe0d00 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3820,7 +3820,7 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: here and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_extensions``. The sibling integration catalog reader already guards both the root - object and the nested mapping (see ``integrations/catalog.py``); + object and the nested mapping (see ``integrations/catalog/__init__.py``); the extension catalog must stay consistent so a malformed payload surfaces as the user-facing ``Invalid catalog format`` error instead of a raw Python traceback. @@ -4039,7 +4039,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Save to cache. Both files are explicitly UTF-8 to match the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog.py`` precedent (see the cache write + # ``integrations/catalog/__init__.py`` precedent (see the cache write # helpers in ``CatalogCache`` there). Without this, platforms # whose default encoding isn't UTF-8 would write locale-encoded # bytes that the read path can't decode, forcing an unnecessary @@ -4119,7 +4119,7 @@ def _get_merged_extensions( # catalog. Skip non-mapping entries here so a payload like # ``{"extensions": {"foo": [], "bar": {...}}}`` still merges # the valid entries without crashing on ``**ext_data``. - # Mirrors ``integrations/catalog.py:245``. + # Mirrors ``integrations/catalog/__init__.py:245``. if not isinstance(ext_data, dict): continue if ext_id not in merged: # Higher-priority catalog wins @@ -4237,7 +4237,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Save to cache. Explicit UTF-8 on both writes mirrors the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog.py`` precedent — otherwise platforms + # ``integrations/catalog/__init__.py`` precedent — otherwise platforms # whose default encoding isn't UTF-8 would write locale-encoded # bytes the read path can't decode, forcing an unnecessary # refetch on every invocation. Like the read side, the write diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index ad38366938..a7ed9d0e59 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -1,7 +1,8 @@ -"""Integration registry for AI coding assistants. +"""Integration registry and domain API for AI coding assistants. Each integration is a self-contained subpackage that handles setup/teardown -for a specific AI assistant (Copilot, Claude, Gemini, etc.). +for a specific AI assistant (Copilot, Claude, Gemini, etc.). CLI adapters live +in ``command_*.py`` modules, with nested catalog commands under ``catalog/``. """ from __future__ import annotations diff --git a/src/specify_cli/integrations/_command_upgrade_layout.py b/src/specify_cli/integrations/_command_upgrade_layout.py new file mode 100644 index 0000000000..caf4ba52d1 --- /dev/null +++ b/src/specify_cli/integrations/_command_upgrade_layout.py @@ -0,0 +1,188 @@ +"""Layout-migration guards for ``specify integration upgrade``.""" + +from __future__ import annotations + +import json +from pathlib import Path, PurePath + +def _manifest_tracks_skill_layout(manifest) -> bool: + """Return True when *manifest* tracks any skills-layout artifact. + + A skill scaffold is written as ``.../speckit-/SKILL.md``, so a + manifest whose tracked files include a ``/SKILL.md`` key is in the skills + layout; otherwise it is in the command layout. Used by ``upgrade`` to + detect a dual-mode agent (e.g. Bob) flipping between the legacy commands + layout and the skills layout so orphaned extension artifacts from the old + layout can be reconciled. + """ + return any(str(rel).endswith("/SKILL.md") for rel in manifest.files) + + +def _manifest_path_under(rel_path: str, root: str) -> bool: + """Return True when manifest key *rel_path* is inside project-relative *root*.""" + normalized_root = PurePath(root).as_posix().strip("/") + normalized_rel = PurePath(rel_path).as_posix().strip("/") + if not normalized_root: + return False + return normalized_rel == normalized_root or normalized_rel.startswith( + f"{normalized_root}/" + ) + + +def _legacy_command_root_changed( + integration, + project_root: Path, + old_manifest, + new_manifest, +) -> bool: + """Return True when command artifacts moved from legacy_dir to canonical dir.""" + config = integration.registrar_config or {} + canonical = config.get("dir") + legacy = config.get("legacy_dir") + if ( + not isinstance(canonical, str) + or not canonical.strip() + or not isinstance(legacy, str) + or not legacy.strip() + or PurePath(canonical).as_posix() == PurePath(legacy).as_posix() + ): + return False + + canonical_dir = project_root / canonical + legacy_dir = project_root / legacy + if not canonical_dir.is_dir() or not legacy_dir.is_dir(): + return False + + old_had_legacy = any( + _manifest_path_under(rel, legacy) for rel in old_manifest.files + ) + new_has_canonical = any( + _manifest_path_under(rel, canonical) for rel in new_manifest.files + ) + return old_had_legacy and new_has_canonical + + +def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool: + """Return True when the old manifest tracks command files under legacy_dir.""" + config = integration.registrar_config or {} + canonical = config.get("dir") + legacy = config.get("legacy_dir") + if ( + not isinstance(canonical, str) + or not canonical.strip() + or not isinstance(legacy, str) + or not legacy.strip() + or PurePath(canonical).as_posix() == PurePath(legacy).as_posix() + ): + return False + return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files) + + +class _PresetRegistryUnreadableError(Exception): + """Raised when an existing preset registry cannot be read or parsed. + + Distinct from a *genuinely absent* registry (no presets installed): an + unreadable registry means we cannot verify whether preset overrides would + be orphaned by a layout change, so the migration must be rejected rather + than proceeding on a false "no presets" assumption. + """ + + +def _installed_presets_affecting_agent( + project_root, + agent_key: str, + *, + include_skills: bool = True, +) -> list[str]: + """Return IDs of installed presets with artifacts registered for *agent_key*. + + Preset registration is active-agent-only (#2948): command overrides are + written for the active non-skills agent and skills for the active skills + agent, tracked per preset in ``registered_commands`` / + ``registered_skills``. Entries for *other* agents may still exist from + when those agents were active. Callers use this to reject command-root or + command↔skills layout migrations before mutation: preset rescaffolding is + best-effort and cannot guarantee every tracked artifact has a replacement. + + Fails **closed**: a genuinely absent registry (no presets ever installed) + returns an empty list, but if the registry file exists and cannot be read + or parsed (e.g. a permission error or corruption) this raises + :class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that + case would let a ``--force`` layout-changing upgrade delete + preset-overridden files while their registry state can't be reconciled — + the exact inconsistency the guard exists to prevent. + """ + from ..presets import PresetRegistry + + registry_path = ( + Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE + ) + # Genuinely absent registry → no presets installed → safe to proceed. + if not registry_path.exists(): + return [] + + # The registry exists: any failure to read or parse it must surface as an + # error, not be swallowed into an empty ("no presets") result. + try: + data = json.loads(registry_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise _PresetRegistryUnreadableError(str(exc)) from exc + if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict): + raise _PresetRegistryUnreadableError( + "preset registry structure is malformed" + ) + + affected: list[str] = [] + for preset_id, meta in data.get("presets", {}).items(): + # A malformed entry means we cannot verify whether this preset owns + # artifacts for the agent, so fail closed rather than skip it. + if not isinstance(meta, dict): + raise _PresetRegistryUnreadableError( + f"preset '{preset_id}' entry is malformed" + ) + registered_commands = meta.get("registered_commands", {}) + if not isinstance(registered_commands, dict) or not all( + isinstance(names, list) for names in registered_commands.values() + ): + raise _PresetRegistryUnreadableError( + f"preset '{preset_id}' registered_commands is malformed" + ) + registered_skills = meta.get("registered_skills", []) + if isinstance(registered_skills, dict): + # Per-agent provenance ({agent: [skill names]}): only entries for + # *this* agent make the preset affect it. Values must be lists — + # anything else (e.g. null) leaves ownership undecidable, so fail + # closed rather than read it as "no artifacts". + if not all( + isinstance(names, list) for names in registered_skills.values() + ): + raise _PresetRegistryUnreadableError( + f"preset '{preset_id}' registered_skills is malformed" + ) + has_skills = include_skills and bool( + registered_skills.get(agent_key) + ) + elif isinstance(registered_skills, (list, tuple)): + # Legacy flat list: not agent-scoped, so any recorded skill may + # belong to this agent — fail closed and count it as affecting. + has_skills = include_skills and bool(registered_skills) + else: + raise _PresetRegistryUnreadableError( + f"preset '{preset_id}' registered_skills is malformed" + ) + has_commands = bool(registered_commands.get(agent_key)) + if has_commands or has_skills: + affected.append(preset_id) + return affected + + +def _installed_command_presets_affecting_agent( + project_root, + agent_key: str, +) -> list[str]: + """Return installed presets with command artifacts registered for *agent_key*.""" + return _installed_presets_affecting_agent( + project_root, + agent_key, + include_skills=False, + ) diff --git a/src/specify_cli/integrations/_commands.py b/src/specify_cli/integrations/_commands.py index 978adaff41..41171729f7 100644 --- a/src/specify_cli/integrations/_commands.py +++ b/src/specify_cli/integrations/_commands.py @@ -1,9 +1,15 @@ -"""specify integration * commands — app objects and register() entry point.""" +"""Shared infrastructure and registration for ``specify integration`` commands. + +Command handlers belong in ``command_*.py`` modules. Thin compatibility +re-exports preserve established direct-import paths in the former grouped +command modules. +""" from __future__ import annotations import typer from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests +from .catalog import catalog_app as integration_catalog_app # noqa: F401 — compatibility alias # Re-export helpers used by commands/init.py and tests from ._helpers import ( # noqa: F401 @@ -19,17 +25,23 @@ 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: + """Attach the integration command group to the root Typer app.""" + from .catalog import register as register_catalog + register_catalog(integration_app) + + # isort: off + from . import command_install # noqa: F401 — registers handler via decorator + from . import command_uninstall # noqa: F401 — registers handler via decorator + from . import command_switch # noqa: F401 — registers handler via decorator + from . import command_upgrade # noqa: F401 — registers handler via decorator + from . import command_list # noqa: F401 — registers handler via decorator + from . import command_status # noqa: F401 — registers handler via decorator + from . import command_use # noqa: F401 — registers handler via decorator + from . import command_search # noqa: F401 — registers handler via decorator + from . import command_info # noqa: F401 — registers handler via decorator + from . import command_scaffold # noqa: F401 — registers handler via decorator + # isort: on -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 - from . import _scaffold_commands # noqa: F401 app.add_typer(integration_app, name="integration") diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index fc39dc8863..23052805a0 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -1,329 +1,6 @@ -"""specify integration install / uninstall command handlers.""" -from __future__ import annotations +"""Compatibility imports for the extracted install and uninstall commands.""" -import os +from .command_install import integration_install +from .command_uninstall import integration_uninstall -import typer - -from .._console import console -from .._utils import _display_project_path -from ..integration_runtime import ( - invoke_prefix_for_integration as _invoke_prefix_for_integration, - 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, ps, or py (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, - project_root=project_root, - ), - invoke_prefix=_invoke_prefix_for_integration( - infra_integration, infra_key, infra_parsed, project_root - ), - ) - if os.name != "nt": - from .. import ensure_executable_scripts - ensure_executable_scripts(project_root) - - manifest = IntegrationManifest( - integration.key, project_root, version=_get_speckit_version() - ) - - from ..events import resolve_events - events_map = resolve_events( - integration.key, - integration.config, - project_root, - parsed_options, - ) - - try: - integration.setup( - project_root, manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - events=events_map, - ) - 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, - project_root=project_root, - ) - _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, - parsed_options=parsed_options, - ) - 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) - - if not integration: - console.print( - f"[yellow]Warning:[/yellow] Integration '{key}' not found " - "in registry. Falling back to manifest-based cleanup." - ) - removed, skipped = manifest.uninstall(project_root, force=force) - else: - removed, skipped = integration.teardown(project_root, manifest, force=force) - - 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}") +__all__ = ["integration_install", "integration_uninstall"] diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 2e71c26e94..c4470a3227 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -1,903 +1,25 @@ -"""specify integration switch / upgrade command handlers.""" -from __future__ import annotations - -import json -import os -from pathlib import Path, PurePath - -import typer - -from .._console import console -from ..integration_runtime import ( - invoke_prefix_for_integration as _invoke_prefix_for_integration, - 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, - _register_extensions_for_agent, - _register_presets_for_agent, - _remove_integration_json, - _resolve_integration_options, - _resolve_integration_script_type, - _resolve_script_type, - _set_default_integration, - _set_default_integration_or_exit, - _unregister_enabled_extension_commands_for_agent, - _unregister_extensions_for_agent, - _unregister_presets_for_agent, - _update_init_options_for_integration, - _write_integration_json, +"""Compatibility imports for the extracted switch and upgrade commands.""" + +from .command_switch import integration_switch +from .command_upgrade import integration_upgrade +from ._command_upgrade_layout import ( + _PresetRegistryUnreadableError, + _installed_command_presets_affecting_agent, + _installed_presets_affecting_agent, + _legacy_command_root_changed, + _legacy_command_root_upgrade_pending, + _manifest_path_under, + _manifest_tracks_skill_layout, ) - -def _manifest_tracks_skill_layout(manifest) -> bool: - """Return True when *manifest* tracks any skills-layout artifact. - - A skill scaffold is written as ``.../speckit-/SKILL.md``, so a - manifest whose tracked files include a ``/SKILL.md`` key is in the skills - layout; otherwise it is in the command layout. Used by ``upgrade`` to - detect a dual-mode agent (e.g. Bob) flipping between the legacy commands - layout and the skills layout so orphaned extension artifacts from the old - layout can be reconciled. - """ - return any(str(rel).endswith("/SKILL.md") for rel in manifest.files) - - -def _manifest_path_under(rel_path: str, root: str) -> bool: - """Return True when manifest key *rel_path* is inside project-relative *root*.""" - normalized_root = PurePath(root).as_posix().strip("/") - normalized_rel = PurePath(rel_path).as_posix().strip("/") - if not normalized_root: - return False - return normalized_rel == normalized_root or normalized_rel.startswith( - f"{normalized_root}/" - ) - - -def _legacy_command_root_changed( - integration, - project_root: Path, - old_manifest, - new_manifest, -) -> bool: - """Return True when command artifacts moved from legacy_dir to canonical dir.""" - config = integration.registrar_config or {} - canonical = config.get("dir") - legacy = config.get("legacy_dir") - if ( - not isinstance(canonical, str) - or not canonical.strip() - or not isinstance(legacy, str) - or not legacy.strip() - or PurePath(canonical).as_posix() == PurePath(legacy).as_posix() - ): - return False - - canonical_dir = project_root / canonical - legacy_dir = project_root / legacy - if not canonical_dir.is_dir() or not legacy_dir.is_dir(): - return False - - old_had_legacy = any( - _manifest_path_under(rel, legacy) for rel in old_manifest.files - ) - new_has_canonical = any( - _manifest_path_under(rel, canonical) for rel in new_manifest.files - ) - return old_had_legacy and new_has_canonical - - -def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool: - """Return True when the old manifest tracks command files under legacy_dir.""" - config = integration.registrar_config or {} - canonical = config.get("dir") - legacy = config.get("legacy_dir") - if ( - not isinstance(canonical, str) - or not canonical.strip() - or not isinstance(legacy, str) - or not legacy.strip() - or PurePath(canonical).as_posix() == PurePath(legacy).as_posix() - ): - return False - return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files) - - -class _PresetRegistryUnreadableError(Exception): - """Raised when an existing preset registry cannot be read or parsed. - - Distinct from a *genuinely absent* registry (no presets installed): an - unreadable registry means we cannot verify whether preset overrides would - be orphaned by a layout change, so the migration must be rejected rather - than proceeding on a false "no presets" assumption. - """ - - -def _installed_presets_affecting_agent( - project_root, - agent_key: str, - *, - include_skills: bool = True, -) -> list[str]: - """Return IDs of installed presets with artifacts registered for *agent_key*. - - Preset registration is active-agent-only (#2948): command overrides are - written for the active non-skills agent and skills for the active skills - agent, tracked per preset in ``registered_commands`` / - ``registered_skills``. Entries for *other* agents may still exist from - when those agents were active. Callers use this to reject command-root or - command↔skills layout migrations before mutation: preset rescaffolding is - best-effort and cannot guarantee every tracked artifact has a replacement. - - Fails **closed**: a genuinely absent registry (no presets ever installed) - returns an empty list, but if the registry file exists and cannot be read - or parsed (e.g. a permission error or corruption) this raises - :class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that - case would let a ``--force`` layout-changing upgrade delete - preset-overridden files while their registry state can't be reconciled — - the exact inconsistency the guard exists to prevent. - """ - from ..presets import PresetRegistry - - registry_path = ( - Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE - ) - # Genuinely absent registry → no presets installed → safe to proceed. - if not registry_path.exists(): - return [] - - # The registry exists: any failure to read or parse it must surface as an - # error, not be swallowed into an empty ("no presets") result. - try: - data = json.loads(registry_path.read_text(encoding="utf-8")) - except (OSError, ValueError) as exc: - raise _PresetRegistryUnreadableError(str(exc)) from exc - if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict): - raise _PresetRegistryUnreadableError( - "preset registry structure is malformed" - ) - - affected: list[str] = [] - for preset_id, meta in data.get("presets", {}).items(): - # A malformed entry means we cannot verify whether this preset owns - # artifacts for the agent, so fail closed rather than skip it. - if not isinstance(meta, dict): - raise _PresetRegistryUnreadableError( - f"preset '{preset_id}' entry is malformed" - ) - registered_commands = meta.get("registered_commands", {}) - if not isinstance(registered_commands, dict) or not all( - isinstance(names, list) for names in registered_commands.values() - ): - raise _PresetRegistryUnreadableError( - f"preset '{preset_id}' registered_commands is malformed" - ) - registered_skills = meta.get("registered_skills", []) - if isinstance(registered_skills, dict): - # Per-agent provenance ({agent: [skill names]}): only entries for - # *this* agent make the preset affect it. Values must be lists — - # anything else (e.g. null) leaves ownership undecidable, so fail - # closed rather than read it as "no artifacts". - if not all( - isinstance(names, list) for names in registered_skills.values() - ): - raise _PresetRegistryUnreadableError( - f"preset '{preset_id}' registered_skills is malformed" - ) - has_skills = include_skills and bool( - registered_skills.get(agent_key) - ) - elif isinstance(registered_skills, (list, tuple)): - # Legacy flat list: not agent-scoped, so any recorded skill may - # belong to this agent — fail closed and count it as affecting. - has_skills = include_skills and bool(registered_skills) - else: - raise _PresetRegistryUnreadableError( - f"preset '{preset_id}' registered_skills is malformed" - ) - has_commands = bool(registered_commands.get(agent_key)) - if has_commands or has_skills: - affected.append(preset_id) - return affected - - -def _installed_command_presets_affecting_agent( - project_root, - agent_key: str, -) -> list[str]: - """Return installed presets with command artifacts registered for *agent_key*.""" - return _installed_presets_affecting_agent( - project_root, - agent_key, - include_skills=False, - ) - - -@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, ps, or py (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]; " - "shared infrastructure 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, - ) - _register_extensions_for_agent( - project_root, - target, - continuing=( - "The integration switch succeeded, but installed extensions may " - "need re-registration." - ), - ) - _register_presets_for_agent( - project_root, - target, - continuing=( - "The integration switch succeeded, but installed presets may " - "need re-registration." - ), - ) - 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) - - # Resolve and validate target options before uninstalling the current - # integration. Invalid options must not leave the project partially - # switched with the previous integration already removed. - target_raw_options, target_parsed_options = _resolve_integration_options( - target_integration, current, target, integration_options - ) - target_integration.is_skills_mode(target_parsed_options, project_root) - - # 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 = current_integration.teardown( - project_root, old_manifest, force=force, - ) - if removed: - console.print(f" Removed {len(removed)} file(s)") - if skipped: - console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - elif not current_integration and manifest_path.exists(): - # Integration removed from registry but manifest exists — use manifest-only uninstall - console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") - try: - old_manifest = IntegrationManifest.load(installed_key, project_root) - removed, skipped = old_manifest.uninstall(project_root, force=force) - if removed: - console.print(f" Removed {len(removed)} file(s)") - if skipped: - console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - except _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. - _unregister_extensions_for_agent( - project_root, - installed_key, - continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", - ) - - # Unregister preset commands/skills for the old agent for the same - # reason: without this, a preset's command overrides (including - # custom preset commands) and skill mirrors rendered for - # installed_key would remain orphaned in its directory once a - # different, possibly not-yet-installed integration becomes active - # (#2948). Scoped strictly to installed_key; other agents' files, - # tracking, and the preset packs themselves are untouched. - _unregister_presets_for_agent( - project_root, - installed_key, - continuing="Continuing with integration switch; old preset 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: - ( - fallback_raw_options, - fallback_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=fallback_raw_options, - parsed_options=fallback_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) - - # 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, target_parsed_options, - project_root=project_root, - ), - invoke_prefix=_invoke_prefix_for_integration( - target_integration, target, target_parsed_options, project_root - ), - 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() - ) - - from ..events import resolve_events - events_map = resolve_events( - target_integration.key, - target_integration.config, - project_root, - target_parsed_options, - ) - try: - target_integration.setup( - project_root, manifest, - parsed_options=target_parsed_options, - script_type=selected_script, - raw_options=target_raw_options, - events=events_map, - ) - 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=target_raw_options, - parsed_options=target_parsed_options, - ) - - 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: - # Under active-only registration the fallback may never - # have received any extension/preset artifacts (it was - # installed while another integration was active), and - # Phase 1 already unregistered the outgoing agent's - # artifacts. Rescaffold so the restored default is - # actually usable. Both helpers are best-effort and - # cannot raise past this point. - _register_extensions_for_agent( - project_root, - fallback_key, - continuing="The switch was rolled back; installed extensions may need re-registration.", - ) - _register_presets_for_agent( - project_root, - fallback_key, - continuing="The switch was rolled back; installed presets may need re-registration.", - ) - 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) - - # Re-register extension commands for the new agent so previously-installed - # extensions are available in it. Done after the try/except (the switch has - # committed) so this best-effort step can never trigger the rollback above. - _register_extensions_for_agent( - project_root, - target, - continuing="The integration switch succeeded, but installed extensions may need re-registration.", - ) - _register_presets_for_agent( - project_root, - target, - continuing="The integration switch succeeded, but installed presets may need re-registration.", - ) - - 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, ps, or py (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, _install_shared_infra - - 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 - ) - - legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending( - integration, - old_manifest, - ) - - # Guard: Kilo's legacy command root moves from .kilocode/workflows to - # .kilo/commands. Preset command artifacts are tracked outside the - # integration manifest, and their agent-scoped rescaffold is best-effort, - # not transactional with command-root cleanup. Refuse before setup writes - # .kilo/commands rather than risking orphaned legacy files or missing - # registry-tracked overrides in the canonical directory. - if key == "kilocode" and legacy_command_root_upgrade_pending: - config = integration.registrar_config or {} - legacy = config.get("legacy_dir", "legacy command directory") - canonical = config.get("dir", "canonical command directory") - try: - affected_presets = _installed_command_presets_affecting_agent( - project_root, - key, - ) - except _PresetRegistryUnreadableError as exc: - console.print( - f"[red]Error:[/red] Cannot migrate '{key}' command directory " - f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: " - "the preset registry could not be read to verify installed presets." - ) - console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") - console.print( - "A command directory migration cannot reconcile preset command " - "artifacts while the preset registry state is unknown. Fix or " - "restore [cyan].specify/presets/.registry[/cyan] and retry." - ) - raise typer.Exit(1) - if affected_presets: - preset_list = ", ".join(sorted(affected_presets)) - console.print( - f"[red]Error:[/red] Cannot migrate '{key}' command directory " - f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while " - f"preset override(s) are installed: [bold]{preset_list}[/bold]." - ) - console.print( - "Preset command artifacts cannot yet be reconciled across this " - "command directory migration, so the upgrade is refused before " - "changing files." - ) - console.print( - "Remove the preset(s), run the upgrade, then reinstall them:\n" - f" [cyan]specify preset remove [/cyan]\n" - f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n" - f" [cyan]specify preset add [/cyan]" - ) - raise typer.Exit(1) - - # Reject command↔skills layout changes while preset artifacts are tracked - # for the integration (review #3415). Preset rescaffolding is best-effort: - # an enabled preset can still have a missing/corrupt manifest or command - # source, or fail during a write. Phase 2 would otherwise delete the - # old-layout file before a replacement is known to exist. Refuse before - # any mutation; same-layout upgrades still rescaffold the active agent. - if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode( - parsed_options, project_root - ): - try: - affected_presets = _installed_presets_affecting_agent(project_root, key) - except _PresetRegistryUnreadableError as exc: - console.print( - f"[red]Error:[/red] Cannot change '{key}' command layout: the " - f"preset registry could not be read to verify installed presets." - ) - console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") - console.print( - "A layout change cannot reconcile preset artifacts, so the " - "migration is refused while the preset registry state is " - "unknown. Fix or restore " - "[cyan].specify/presets/.registry[/cyan] and retry." - ) - raise typer.Exit(1) - if affected_presets: - preset_list = ", ".join(sorted(affected_presets)) - console.print( - f"[red]Error:[/red] Cannot change '{key}' command layout while " - f"preset override(s) are installed: [bold]{preset_list}[/bold]." - ) - console.print( - "Preset artifacts cannot be safely reconciled across a " - "command↔skills layout change, so the migration is refused " - "before changing files." - ) - console.print( - "Remove the preset(s), run the upgrade, then reinstall them:\n" - f" [cyan]specify preset remove [/cyan]\n" - f" [cyan]specify integration upgrade {key} " - f"--integration-options \"...\"[/cyan]\n" - f" [cyan]specify preset add [/cyan]" - ) - raise typer.Exit(1) - - # 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, - project_root=project_root, - ), - invoke_prefix=_invoke_prefix_for_integration( - infra_integration, infra_key, infra_parsed, project_root - ), - ) - 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()) - - from ..events import resolve_events - events_map = resolve_events( - key, - integration.config, - project_root, - parsed_options, - ) - try: - integration.setup( - project_root, - new_manifest, - parsed_options=parsed_options, - script_type=selected_script, - raw_options=raw_options, - events=events_map, - ) - settings = _with_integration_setting( - current, - key, - integration, - script_type=selected_script, - raw_options=raw_options, - parsed_options=parsed_options, - project_root=project_root, - ) - if installed_key == key: - try: - _install_shared_infra( - project_root, - selected_script, - invoke_separator=_invoke_separator_for_integration( - integration, {"integration_settings": settings}, key, parsed_options, - project_root=project_root, - ), - invoke_prefix=_invoke_prefix_for_integration( - integration, key, parsed_options, project_root - ), - force=force, - refresh_managed=True, - ) - except (ValueError, OSError) as exc: - raise _SharedTemplateRefreshError( - f"Failed to refresh shared infrastructure for '{key}': {exc}" - ) from exc - if os.name != "nt": - from .. import ensure_executable_scripts - ensure_executable_scripts(project_root) - 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, - parsed_options=parsed_options, - ) - 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 - # Exclude integration-declared paths that use conditional manifest tracking - # (e.g. merge targets like .vscode/settings.json) so they are never deleted - # as "stale" while still being actively managed. Manifest keys are stored - # in POSIX form, so normalize the exclusions the same way before subtracting - # (an integration may build paths with os.path.join / backslashes). - exclusions = {PurePath(p).as_posix() for p in integration.stale_cleanup_exclusions()} - stale_keys = (set(old_files) - set(new_files)) - exclusions - if stale_keys: - stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") - stale_manifest._files = {k: old_files[k] for k in stale_keys} - # remove_manifest=False: this throwaway manifest shares ``key`` with the - # real one just saved above (new_manifest.save()). Letting uninstall() - # delete ``{key}.manifest.json`` would wipe the freshly-written manifest - # whenever an upgrade shrinks the tracked file set (e.g. Bob migrating - # from the legacy commands layout to skills), leaving the integration - # untracked and un-upgradeable. - stale_removed, _ = stale_manifest.uninstall( - project_root, force=True, remove_manifest=False - ) - if stale_removed: - console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") - - legacy_command_root_changed = _legacy_command_root_changed( - integration, - project_root, - old_manifest, - new_manifest, - ) - if legacy_command_root_changed: - _unregister_enabled_extension_commands_for_agent( - project_root, - key, - continuing=( - "The integration command directory changed, but legacy enabled " - "extension artifacts may need manual cleanup." - ), - ) - - # Re-register enabled extensions and presets only when upgrading the - # active integration. Inactive integrations remain untouched until - # `use` or `switch` activates and rescaffolds them (#2948). This runs - # after the core upgrade transaction, so failures remain best-effort. - if key == installed_key: - _register_extensions_for_agent( - project_root, - key, - force=True, - continuing="The integration was upgraded, but installed extensions may need re-registration.", - ) - _register_presets_for_agent( - project_root, - key, - continuing="The integration was upgraded, but installed presets may need re-registration.", - ) - - name = (integration.config or {}).get("name", key) - console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") +__all__ = [ + "_PresetRegistryUnreadableError", + "_installed_command_presets_affecting_agent", + "_installed_presets_affecting_agent", + "_legacy_command_root_changed", + "_legacy_command_root_upgrade_pending", + "_manifest_path_under", + "_manifest_tracks_skill_layout", + "integration_switch", + "integration_upgrade", +] diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py index 0cd254879a..292ff79d4b 100644 --- a/src/specify_cli/integrations/_query_commands.py +++ b/src/specify_cli/integrations/_query_commands.py @@ -1,573 +1,22 @@ -"""specify integration list/status/use/search/info + catalog list/add/remove command handlers.""" -from __future__ import annotations - -import json -import os -from typing import Any, Optional - -import typer -from rich.markup import escape as _rich_escape -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, - _register_extensions_for_agent, - _register_presets_for_agent, - _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 = "yes" if getattr(reg_integ, "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 - - 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]") - - -def _print_integration_status_report(report: dict[str, Any]) -> None: - status = report["status"] - status_label = { - "ok": "[green]OK[/green]", - "warning": "[yellow]WARNING[/yellow]", - "error": "[red]ERROR[/red]", - }.get(str(status), str(status).upper()) - installed = report.get("installed_integrations") or [] - installed_display = ", ".join(_rich_escape(str(item)) for item in installed) - - console.print(f"Integration status: {status_label}") - console.print( - f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}" - ) - console.print(f"Installed integrations: {installed_display if installed else 'none'}") - multi_install_safe = report.get("multi_install_safe") - if multi_install_safe is None: - multi_install_safe_display = "unknown" - else: - multi_install_safe_display = "yes" if multi_install_safe else "no" - console.print(f"Multi-install safe: {multi_install_safe_display}") - console.print( - f"Shared templates target alignment: " - f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}" - ) - console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}") - console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}") - console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}") - console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}") - - findings = report.get("findings") or [] - if not findings: - return - - console.print() - console.print("[bold]Findings:[/bold]") - for item in findings: - severity = item.get("severity", "") - severity_label = { - "error": "[red]error[/red]", - "warning": "[yellow]warning[/yellow]", - }.get(severity, severity) - prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}" - if item.get("integration"): - prefix += f" ({_rich_escape(str(item['integration']))})" - console.print( - f"{prefix}: {_rich_escape(str(item.get('message', '')))}", - soft_wrap=True, - ) - if item.get("suggestion"): - console.print( - f" Suggestion: {_rich_escape(str(item['suggestion']))}", - soft_wrap=True, - ) - - -@integration_app.command("status") -def integration_status( - json_output: bool = typer.Option( - False, - "--json", - help="Emit machine-readable integration status.", - ), -): - """Report the current project's integration status without changing files.""" - from .. import _require_specify_project - from ..integration_status import build_integration_status_report - - project_root = _require_specify_project() - report = build_integration_status_report(project_root) - - if json_output: - typer.echo(json.dumps(report, indent=2)) - else: - _print_integration_status_report(report) - - if report["status"] == "error": - raise typer.Exit(1) - - -@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 existing shared infrastructure files, including customizations, 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, - refresh_hint=( - "To overwrite customizations, re-run with " - f"[cyan]specify integration use {key} --force[/cyan]." - ), - ) - _register_extensions_for_agent( - project_root, - key, - continuing="The integration was selected, but installed extensions may need re-registration.", - ) - _register_presets_for_agent( - project_root, - key, - continuing="The integration was selected, but installed presets may need re-registration.", - ) - 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_value = str(integ.get("id", "?")) - iid = _rich_escape(iid_value) - name = _rich_escape(str(integ.get("name", iid_value))) - version = _rich_escape(str(integ.get("version", "?"))) - console.print(f"[bold]{name}[/bold] ({iid}) v{version}") - desc = integ.get("description", "") - if desc: - console.print(f" {_rich_escape(str(desc))}") - - author_value = _rich_escape(str(integ.get("author", "Unknown"))) - console.print(f"\n [dim]Author:[/dim] {author_value}") - tags = integ.get("tags", []) - if isinstance(tags, list) and tags: - safe_tags = _rich_escape(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags:[/dim] {safe_tags}") - - cat_name_value = integ.get("_catalog_name", "") - cat_name = _rich_escape(str(cat_name_value)) - install_allowed = integ.get("_install_allowed", True) - if cat_name_value: - 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_value == installed_key: - console.print("\n [green]✓ Installed[/green] (currently active)") - elif iid_value 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)) - safe_integration_id = _rich_escape(str(integration_id)) - - 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 = _rich_escape(str(info.get("name", integration_id))) - version = _rich_escape(str(info.get("version", "?"))) - console.print( - f"\n[bold cyan]{name}[/bold cyan] ({safe_integration_id}) v{version}" - ) - if info.get("description"): - console.print(f" {_rich_escape(str(info['description']))}") - console.print() - - author_value = _rich_escape(str(info.get("author", "Unknown"))) - console.print(f" [dim]Author:[/dim] {author_value}") - if info.get("license"): - console.print( - f" [dim]License:[/dim] {_rich_escape(str(info['license']))}" - ) - - tags = info.get("tags", []) - if isinstance(tags, list) and tags: - safe_tags = _rich_escape(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags:[/dim] {safe_tags}") - - cat_name_value = info.get("_catalog_name", "") - cat_name = _rich_escape(str(cat_name_value)) - install_allowed = info.get("_install_allowed", True) - if cat_name_value: - 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] {_rich_escape(str(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 '{safe_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}" - safe_name = _rich_escape(display_name) - if env_override or project_configs is None: - console.print(f" - [bold]{safe_name}[/bold] — {install_status}") - else: - console.print(f" [{i}] [bold]{safe_name}[/bold] — {install_status}") - console.print(f" {_rich_escape(str(cfg.get('url', '')))}") - if cfg.get("description"): - console.print(f" [dim]{_rich_escape(str(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") +"""Compatibility imports for extracted query and catalog commands.""" + +from .catalog.command_add import integration_catalog_add +from .catalog.command_list import integration_catalog_list +from .catalog.command_remove import integration_catalog_remove +from .command_info import integration_info +from .command_list import integration_list +from .command_search import integration_search +from .command_status import _print_integration_status_report, integration_status +from .command_use import integration_use + +__all__ = [ + "_print_integration_status_report", + "integration_catalog_add", + "integration_catalog_list", + "integration_catalog_remove", + "integration_info", + "integration_list", + "integration_search", + "integration_status", + "integration_use", +] diff --git a/src/specify_cli/integrations/_scaffold_commands.py b/src/specify_cli/integrations/_scaffold_commands.py index 4a5d392dca..470edfdaf9 100644 --- a/src/specify_cli/integrations/_scaffold_commands.py +++ b/src/specify_cli/integrations/_scaffold_commands.py @@ -1,54 +1,13 @@ -"""specify integration scaffold command handler.""" -from __future__ import annotations +"""Compatibility imports for the extracted scaffold command.""" -from enum import Enum -from pathlib import Path - -import typer - -from .._console import console -from ..integration_scaffold import supported_integration_scaffold_types -from ._commands import integration_app - - -INTEGRATION_SCAFFOLD_TYPES = supported_integration_scaffold_types() -_IntegrationScaffoldType = Enum( - "_IntegrationScaffoldType", - {name: name for name in INTEGRATION_SCAFFOLD_TYPES}, - type=str, +from .command_scaffold import ( + INTEGRATION_SCAFFOLD_TYPES, + _IntegrationScaffoldType, + integration_scaffold, ) - -@integration_app.command("scaffold") -def integration_scaffold( - key: str = typer.Argument(help="Integration key in lowercase kebab-case, e.g. my-agent"), - integration_type: _IntegrationScaffoldType = typer.Option( - _IntegrationScaffoldType.markdown, - "--type", - case_sensitive=False, - help=f"Scaffold type: {', '.join(INTEGRATION_SCAFFOLD_TYPES)}", - ), -): - """Create a minimal built-in integration package and test skeleton.""" - from ..integration_scaffold import scaffold_integration - - # scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root), - # not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here. - project_root = Path.cwd() - try: - result = scaffold_integration(project_root, key, integration_type.value) - except (OSError, ValueError) as exc: - # OSError covers filesystem failures during mkdir()/write_text() - # (permission denied, read-only checkout, a path component that is a - # file, ...) as well as FileExistsError; surface them as a clean CLI - # error instead of a traceback. - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - - console.print(f"[green]Created integration scaffold:[/green] {result.key}") - console.print(f" {result.integration_file.relative_to(project_root).as_posix()}") - console.print(f" {result.test_file.relative_to(project_root).as_posix()}") - console.print() - console.print("[bold]Next steps:[/bold]") - for index, step in enumerate(result.next_steps, start=1): - console.print(f"{index}. {step}") +__all__ = [ + "INTEGRATION_SCAFFOLD_TYPES", + "_IntegrationScaffoldType", + "integration_scaffold", +] diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index b1b5ce9a54..821f03dd66 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -201,7 +201,7 @@ def is_skills_mode( *parsed_options* is typically empty: no flag was passed, and existing Bob 1.x installs never persisted a ``legacy_commands`` option to recover. This is independent of whether ``setup()`` runs — ``upgrade`` - *does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``), + *does* call :meth:`setup` (see ``command_upgrade.integration_upgrade``), but it passes those same empty *parsed_options*, so without disk detection the mode would resolve to the skills default. Defaulting to skills there would rewrite such a project's ``ai_skills`` flag to diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog/__init__.py similarity index 97% rename from src/specify_cli/integrations/catalog.py rename to src/specify_cli/integrations/catalog/__init__.py index b8d76cb9c6..57feb7f01d 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog/__init__.py @@ -1,10 +1,13 @@ -"""Integration catalog — discovery, validation, and upgrade support. +"""Integration catalog domain API and nested CLI registration. Provides: - ``IntegrationCatalogEntry`` — single catalog source metadata. - ``IntegrationCatalog`` — fetches, caches, and searches integration catalogs (built-in + community). - ``IntegrationDescriptor`` — loads and validates ``integration.yml``. + +The ``specify integration catalog`` handlers live in adjacent +``command_*.py`` modules. """ from __future__ import annotations @@ -18,11 +21,12 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +import typer import yaml from packaging import version as pkg_version -from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited -from ..catalogs import CatalogEntry, CatalogStackBase +from ..._download_security import MAX_JSON_METADATA_BYTES, read_response_limited +from ...catalogs import CatalogEntry, CatalogStackBase # --------------------------------------------------------------------------- @@ -876,3 +880,19 @@ def get_hash(self) -> str: for chunk in iter(lambda: fh.read(8192), b""): h.update(chunk) return f"sha256:{h.hexdigest()}" + + +catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalog sources", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the catalog command group to the integration Typer app.""" + from . import command_list # noqa: F401 — registers handler via decorator + from . import command_add # noqa: F401 — registers handler via decorator + from . import command_remove # noqa: F401 — registers handler via decorator + + app.add_typer(catalog_app, name="catalog") diff --git a/src/specify_cli/integrations/catalog/command_add.py b/src/specify_cli/integrations/catalog/command_add.py new file mode 100644 index 0000000000..d901307c03 --- /dev/null +++ b/src/specify_cli/integrations/catalog/command_add.py @@ -0,0 +1,42 @@ +"""The ``specify integration catalog add`` command.""" +from __future__ import annotations + +from typing import Optional + +import typer + +from ..._console import console +from . import catalog_app + + +@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 . 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}") diff --git a/src/specify_cli/integrations/catalog/command_list.py b/src/specify_cli/integrations/catalog/command_list.py new file mode 100644 index 0000000000..0a1cd0b5b3 --- /dev/null +++ b/src/specify_cli/integrations/catalog/command_list.py @@ -0,0 +1,67 @@ +"""The ``specify integration catalog list`` command.""" +from __future__ import annotations + +import os + +import typer +from rich.markup import escape as _rich_escape + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("list") +def integration_catalog_list(): + """List configured integration catalog sources.""" + from . 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}" + safe_name = _rich_escape(display_name) + if env_override or project_configs is None: + console.print(f" - [bold]{safe_name}[/bold] — {install_status}") + else: + console.print(f" [{i}] [bold]{safe_name}[/bold] — {install_status}") + console.print(f" {_rich_escape(str(cfg.get('url', '')))}") + if cfg.get("description"): + console.print(f" [dim]{_rich_escape(str(cfg['description']))}[/dim]") + console.print() diff --git a/src/specify_cli/integrations/catalog/command_remove.py b/src/specify_cli/integrations/catalog/command_remove.py new file mode 100644 index 0000000000..16c9ad8aff --- /dev/null +++ b/src/specify_cli/integrations/catalog/command_remove.py @@ -0,0 +1,28 @@ +"""The ``specify integration catalog remove`` command.""" +from __future__ import annotations + + +import typer + +from ..._console import console +from . import catalog_app + + +@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 . 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/src/specify_cli/integrations/command_info.py b/src/specify_cli/integrations/command_info.py new file mode 100644 index 0000000000..77a2185da7 --- /dev/null +++ b/src/specify_cli/integrations/command_info.py @@ -0,0 +1,114 @@ +"""The ``specify integration info`` command.""" +from __future__ import annotations + +import os +from typing import Optional + +import typer +from rich.markup import escape as _rich_escape + +from .._console import console +from ..integration_state import default_integration_key as _default_integration_key +from ._commands import integration_app +from ._helpers import _read_integration_json + + +@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)) + safe_integration_id = _rich_escape(str(integration_id)) + + 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 = _rich_escape(str(info.get("name", integration_id))) + version = _rich_escape(str(info.get("version", "?"))) + console.print( + f"\n[bold cyan]{name}[/bold cyan] ({safe_integration_id}) v{version}" + ) + if info.get("description"): + console.print(f" {_rich_escape(str(info['description']))}") + console.print() + + author_value = _rich_escape(str(info.get("author", "Unknown"))) + console.print(f" [dim]Author:[/dim] {author_value}") + if info.get("license"): + console.print( + f" [dim]License:[/dim] {_rich_escape(str(info['license']))}" + ) + + tags = info.get("tags", []) + if isinstance(tags, list) and tags: + safe_tags = _rich_escape(", ".join(str(t) for t in tags)) + console.print(f" [dim]Tags:[/dim] {safe_tags}") + + cat_name_value = info.get("_catalog_name", "") + cat_name = _rich_escape(str(cat_name_value)) + install_allowed = info.get("_install_allowed", True) + if cat_name_value: + 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] {_rich_escape(str(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 '{safe_integration_id}' not found") + console.print("\nTry: specify integration search") + raise typer.Exit(1) diff --git a/src/specify_cli/integrations/command_install.py b/src/specify_cli/integrations/command_install.py new file mode 100644 index 0000000000..f666928c39 --- /dev/null +++ b/src/specify_cli/integrations/command_install.py @@ -0,0 +1,199 @@ +"""The ``specify integration install`` command.""" +from __future__ import annotations + +import os + +import typer + +from .._console import console +from ..integration_runtime import ( + invoke_prefix_for_integration as _invoke_prefix_for_integration, + 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 _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, _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, ps, or py (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, + project_root=project_root, + ), + invoke_prefix=_invoke_prefix_for_integration( + infra_integration, infra_key, infra_parsed, project_root + ), + ) + if os.name != "nt": + from .. import ensure_executable_scripts + ensure_executable_scripts(project_root) + + manifest = IntegrationManifest( + integration.key, project_root, version=_get_speckit_version() + ) + + from ..events import resolve_events + events_map = resolve_events( + integration.key, + integration.config, + project_root, + parsed_options, + ) + + try: + integration.setup( + project_root, manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + events=events_map, + ) + 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, + project_root=project_root, + ) + _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, + parsed_options=parsed_options, + ) + 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]") diff --git a/src/specify_cli/integrations/command_list.py b/src/specify_cli/integrations/command_list.py new file mode 100644 index 0000000000..151ba831a2 --- /dev/null +++ b/src/specify_cli/integrations/command_list.py @@ -0,0 +1,114 @@ +"""The ``specify integration list`` command.""" +from __future__ import annotations + + +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 +from ._helpers import _read_integration_json + + +@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 = "yes" if getattr(reg_integ, "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 + + 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]") diff --git a/src/specify_cli/integrations/command_scaffold.py b/src/specify_cli/integrations/command_scaffold.py new file mode 100644 index 0000000000..fe4877a042 --- /dev/null +++ b/src/specify_cli/integrations/command_scaffold.py @@ -0,0 +1,54 @@ +"""The ``specify integration scaffold`` command.""" +from __future__ import annotations + +from enum import Enum +from pathlib import Path + +import typer + +from .._console import console +from ..integration_scaffold import supported_integration_scaffold_types +from ._commands import integration_app + + +INTEGRATION_SCAFFOLD_TYPES = supported_integration_scaffold_types() +_IntegrationScaffoldType = Enum( + "_IntegrationScaffoldType", + {name: name for name in INTEGRATION_SCAFFOLD_TYPES}, + type=str, +) + + +@integration_app.command("scaffold") +def integration_scaffold( + key: str = typer.Argument(help="Integration key in lowercase kebab-case, e.g. my-agent"), + integration_type: _IntegrationScaffoldType = typer.Option( + _IntegrationScaffoldType.markdown, + "--type", + case_sensitive=False, + help=f"Scaffold type: {', '.join(INTEGRATION_SCAFFOLD_TYPES)}", + ), +): + """Create a minimal built-in integration package and test skeleton.""" + from ..integration_scaffold import scaffold_integration + + # scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root), + # not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here. + project_root = Path.cwd() + try: + result = scaffold_integration(project_root, key, integration_type.value) + except (OSError, ValueError) as exc: + # OSError covers filesystem failures during mkdir()/write_text() + # (permission denied, read-only checkout, a path component that is a + # file, ...) as well as FileExistsError; surface them as a clean CLI + # error instead of a traceback. + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]Created integration scaffold:[/green] {result.key}") + console.print(f" {result.integration_file.relative_to(project_root).as_posix()}") + console.print(f" {result.test_file.relative_to(project_root).as_posix()}") + console.print() + console.print("[bold]Next steps:[/bold]") + for index, step in enumerate(result.next_steps, start=1): + console.print(f"{index}. {step}") diff --git a/src/specify_cli/integrations/command_search.py b/src/specify_cli/integrations/command_search.py new file mode 100644 index 0000000000..1611cfce70 --- /dev/null +++ b/src/specify_cli/integrations/command_search.py @@ -0,0 +1,109 @@ +"""The ``specify integration search`` command.""" +from __future__ import annotations + +import os +from typing import Optional + +import typer +from rich.markup import escape as _rich_escape + +from .._console import console +from ..integration_state import default_integration_key as _default_integration_key +from ._commands import integration_app +from ._helpers import _read_integration_json + + +@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_value = str(integ.get("id", "?")) + iid = _rich_escape(iid_value) + name = _rich_escape(str(integ.get("name", iid_value))) + version = _rich_escape(str(integ.get("version", "?"))) + console.print(f"[bold]{name}[/bold] ({iid}) v{version}") + desc = integ.get("description", "") + if desc: + console.print(f" {_rich_escape(str(desc))}") + + author_value = _rich_escape(str(integ.get("author", "Unknown"))) + console.print(f"\n [dim]Author:[/dim] {author_value}") + tags = integ.get("tags", []) + if isinstance(tags, list) and tags: + safe_tags = _rich_escape(", ".join(str(t) for t in tags)) + console.print(f" [dim]Tags:[/dim] {safe_tags}") + + cat_name_value = integ.get("_catalog_name", "") + cat_name = _rich_escape(str(cat_name_value)) + install_allowed = integ.get("_install_allowed", True) + if cat_name_value: + 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_value == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif iid_value 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() diff --git a/src/specify_cli/integrations/command_status.py b/src/specify_cli/integrations/command_status.py new file mode 100644 index 0000000000..ff43662caa --- /dev/null +++ b/src/specify_cli/integrations/command_status.py @@ -0,0 +1,91 @@ +"""The ``specify integration status`` command.""" +from __future__ import annotations + +import json +from typing import Any + +import typer +from rich.markup import escape as _rich_escape + +from .._console import console +from ._commands import integration_app + + +def _print_integration_status_report(report: dict[str, Any]) -> None: + status = report["status"] + status_label = { + "ok": "[green]OK[/green]", + "warning": "[yellow]WARNING[/yellow]", + "error": "[red]ERROR[/red]", + }.get(str(status), str(status).upper()) + installed = report.get("installed_integrations") or [] + installed_display = ", ".join(_rich_escape(str(item)) for item in installed) + + console.print(f"Integration status: {status_label}") + console.print( + f"Default integration: {_rich_escape(str(report.get('default_integration') or 'none'))}" + ) + console.print(f"Installed integrations: {installed_display if installed else 'none'}") + multi_install_safe = report.get("multi_install_safe") + if multi_install_safe is None: + multi_install_safe_display = "unknown" + else: + multi_install_safe_display = "yes" if multi_install_safe else "no" + console.print(f"Multi-install safe: {multi_install_safe_display}") + console.print( + f"Shared templates target alignment: " + f"{_rich_escape(str(report.get('shared_templates_target_alignment') or 'none'))}" + ) + console.print(f"Modified managed files: {report.get('modified_managed_files', 0)}") + console.print(f"Missing managed files: {report.get('missing_managed_files', 0)}") + console.print(f"Invalid manifest paths: {report.get('invalid_manifest_paths', 0)}") + console.print(f"Unchecked manifests: {report.get('unchecked_manifests', 0)}") + + findings = report.get("findings") or [] + if not findings: + return + + console.print() + console.print("[bold]Findings:[/bold]") + for item in findings: + severity = item.get("severity", "") + severity_label = { + "error": "[red]error[/red]", + "warning": "[yellow]warning[/yellow]", + }.get(severity, severity) + prefix = f"- {severity_label} {_rich_escape(str(item.get('code', '')))}" + if item.get("integration"): + prefix += f" ({_rich_escape(str(item['integration']))})" + console.print( + f"{prefix}: {_rich_escape(str(item.get('message', '')))}", + soft_wrap=True, + ) + if item.get("suggestion"): + console.print( + f" Suggestion: {_rich_escape(str(item['suggestion']))}", + soft_wrap=True, + ) + + +@integration_app.command("status") +def integration_status( + json_output: bool = typer.Option( + False, + "--json", + help="Emit machine-readable integration status.", + ), +): + """Report the current project's integration status without changing files.""" + from .. import _require_specify_project + from ..integration_status import build_integration_status_report + + project_root = _require_specify_project() + report = build_integration_status_report(project_root) + + if json_output: + typer.echo(json.dumps(report, indent=2)) + else: + _print_integration_status_report(report) + + if report["status"] == "error": + raise typer.Exit(1) diff --git a/src/specify_cli/integrations/command_switch.py b/src/specify_cli/integrations/command_switch.py new file mode 100644 index 0000000000..d2e85602f5 --- /dev/null +++ b/src/specify_cli/integrations/command_switch.py @@ -0,0 +1,369 @@ +"""The ``specify integration switch`` command.""" +from __future__ import annotations + +import os + +import typer + +from .._console import console +from ..integration_runtime import invoke_prefix_for_integration as _invoke_prefix_for_integration, invoke_separator_for_integration as _invoke_separator_for_integration +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, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _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, ps, or py (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]; " + "shared infrastructure 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, + ) + _register_extensions_for_agent( + project_root, + target, + continuing=( + "The integration switch succeeded, but installed extensions may " + "need re-registration." + ), + ) + _register_presets_for_agent( + project_root, + target, + continuing=( + "The integration switch succeeded, but installed presets may " + "need re-registration." + ), + ) + 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) + + # Resolve and validate target options before uninstalling the current + # integration. Invalid options must not leave the project partially + # switched with the previous integration already removed. + target_raw_options, target_parsed_options = _resolve_integration_options( + target_integration, current, target, integration_options + ) + target_integration.is_skills_mode(target_parsed_options, project_root) + + # 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 = current_integration.teardown( + project_root, old_manifest, force=force, + ) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + elif not current_integration and manifest_path.exists(): + # Integration removed from registry but manifest exists — use manifest-only uninstall + console.print(f"Uninstalling unknown integration '{installed_key}' via manifest") + try: + old_manifest = IntegrationManifest.load(installed_key, project_root) + removed, skipped = old_manifest.uninstall(project_root, force=force) + if removed: + console.print(f" Removed {len(removed)} file(s)") + if skipped: + console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") + except _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. + _unregister_extensions_for_agent( + project_root, + installed_key, + continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.", + ) + + # Unregister preset commands/skills for the old agent for the same + # reason: without this, a preset's command overrides (including + # custom preset commands) and skill mirrors rendered for + # installed_key would remain orphaned in its directory once a + # different, possibly not-yet-installed integration becomes active + # (#2948). Scoped strictly to installed_key; other agents' files, + # tracking, and the preset packs themselves are untouched. + _unregister_presets_for_agent( + project_root, + installed_key, + continuing="Continuing with integration switch; old preset 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: + ( + fallback_raw_options, + fallback_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=fallback_raw_options, + parsed_options=fallback_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) + + # 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, target_parsed_options, + project_root=project_root, + ), + invoke_prefix=_invoke_prefix_for_integration( + target_integration, target, target_parsed_options, project_root + ), + 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() + ) + + from ..events import resolve_events + events_map = resolve_events( + target_integration.key, + target_integration.config, + project_root, + target_parsed_options, + ) + try: + target_integration.setup( + project_root, manifest, + parsed_options=target_parsed_options, + script_type=selected_script, + raw_options=target_raw_options, + events=events_map, + ) + 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=target_raw_options, + parsed_options=target_parsed_options, + ) + + 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: + # Under active-only registration the fallback may never + # have received any extension/preset artifacts (it was + # installed while another integration was active), and + # Phase 1 already unregistered the outgoing agent's + # artifacts. Rescaffold so the restored default is + # actually usable. Both helpers are best-effort and + # cannot raise past this point. + _register_extensions_for_agent( + project_root, + fallback_key, + continuing="The switch was rolled back; installed extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + fallback_key, + continuing="The switch was rolled back; installed presets may need re-registration.", + ) + 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) + + # Re-register extension commands for the new agent so previously-installed + # extensions are available in it. Done after the try/except (the switch has + # committed) so this best-effort step can never trigger the rollback above. + _register_extensions_for_agent( + project_root, + target, + continuing="The integration switch succeeded, but installed extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + target, + continuing="The integration switch succeeded, but installed presets may need re-registration.", + ) + + name = (target_integration.config or {}).get("name", target) + console.print(f"\n[green]✓[/green] Switched to integration '{name}'") diff --git a/src/specify_cli/integrations/command_uninstall.py b/src/specify_cli/integrations/command_uninstall.py new file mode 100644 index 0000000000..0bd37ad7d1 --- /dev/null +++ b/src/specify_cli/integrations/command_uninstall.py @@ -0,0 +1,126 @@ +"""The ``specify integration uninstall`` command.""" +from __future__ import annotations + + +import typer + +from .._console import console +from .._utils import _display_project_path +from ..integration_state import 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, _read_integration_json, _remove_integration_json, _resolve_integration_options, _set_default_integration_or_exit, _write_integration_json + + +@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) + + if not integration: + console.print( + f"[yellow]Warning:[/yellow] Integration '{key}' not found " + "in registry. Falling back to manifest-based cleanup." + ) + removed, skipped = manifest.uninstall(project_root, force=force) + else: + removed, skipped = integration.teardown(project_root, manifest, force=force) + + 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/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py new file mode 100644 index 0000000000..6138f38a8e --- /dev/null +++ b/src/specify_cli/integrations/command_upgrade.py @@ -0,0 +1,374 @@ +"""The ``specify integration upgrade`` command and its layout guards.""" +from __future__ import annotations + +import os +from pathlib import PurePath + +import typer + +from .._console import console +from ..integration_runtime import ( + invoke_prefix_for_integration as _invoke_prefix_for_integration, + invoke_separator_for_integration as _invoke_separator_for_integration, + with_integration_setting as _with_integration_setting, +) +from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys +from ._command_upgrade_layout import ( + _PresetRegistryUnreadableError, +) +from ._commands import integration_app +from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json + + +def _legacy_layout_helper(name: str): + """Resolve a layout helper through the former module for patch compatibility.""" + from . import _migrate_commands + + return getattr(_migrate_commands, name) + + +def _manifest_tracks_skill_layout(*args, **kwargs): + return _legacy_layout_helper("_manifest_tracks_skill_layout")(*args, **kwargs) + + +def _legacy_command_root_changed(*args, **kwargs): + return _legacy_layout_helper("_legacy_command_root_changed")(*args, **kwargs) + + +def _legacy_command_root_upgrade_pending(*args, **kwargs): + return _legacy_layout_helper("_legacy_command_root_upgrade_pending")( + *args, **kwargs + ) + + +def _installed_presets_affecting_agent(*args, **kwargs): + return _legacy_layout_helper("_installed_presets_affecting_agent")(*args, **kwargs) + + +def _installed_command_presets_affecting_agent(*args, **kwargs): + return _legacy_layout_helper("_installed_command_presets_affecting_agent")( + *args, **kwargs + ) + + +@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, ps, or py (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, _install_shared_infra + + 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 + ) + + legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending( + integration, + old_manifest, + ) + + # Guard: Kilo's legacy command root moves from .kilocode/workflows to + # .kilo/commands. Preset command artifacts are tracked outside the + # integration manifest, and their agent-scoped rescaffold is best-effort, + # not transactional with command-root cleanup. Refuse before setup writes + # .kilo/commands rather than risking orphaned legacy files or missing + # registry-tracked overrides in the canonical directory. + if key == "kilocode" and legacy_command_root_upgrade_pending: + config = integration.registrar_config or {} + legacy = config.get("legacy_dir", "legacy command directory") + canonical = config.get("dir", "canonical command directory") + try: + affected_presets = _installed_command_presets_affecting_agent( + project_root, + key, + ) + except _PresetRegistryUnreadableError as exc: + console.print( + f"[red]Error:[/red] Cannot migrate '{key}' command directory " + f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: " + "the preset registry could not be read to verify installed presets." + ) + console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") + console.print( + "A command directory migration cannot reconcile preset command " + "artifacts while the preset registry state is unknown. Fix or " + "restore [cyan].specify/presets/.registry[/cyan] and retry." + ) + raise typer.Exit(1) + if affected_presets: + preset_list = ", ".join(sorted(affected_presets)) + console.print( + f"[red]Error:[/red] Cannot migrate '{key}' command directory " + f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while " + f"preset override(s) are installed: [bold]{preset_list}[/bold]." + ) + console.print( + "Preset command artifacts cannot yet be reconciled across this " + "command directory migration, so the upgrade is refused before " + "changing files." + ) + console.print( + "Remove the preset(s), run the upgrade, then reinstall them:\n" + f" [cyan]specify preset remove [/cyan]\n" + f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n" + f" [cyan]specify preset add [/cyan]" + ) + raise typer.Exit(1) + + # Reject command↔skills layout changes while preset artifacts are tracked + # for the integration (review #3415). Preset rescaffolding is best-effort: + # an enabled preset can still have a missing/corrupt manifest or command + # source, or fail during a write. Phase 2 would otherwise delete the + # old-layout file before a replacement is known to exist. Refuse before + # any mutation; same-layout upgrades still rescaffold the active agent. + if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode( + parsed_options, project_root + ): + try: + affected_presets = _installed_presets_affecting_agent(project_root, key) + except _PresetRegistryUnreadableError as exc: + console.print( + f"[red]Error:[/red] Cannot change '{key}' command layout: the " + f"preset registry could not be read to verify installed presets." + ) + console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}") + console.print( + "A layout change cannot reconcile preset artifacts, so the " + "migration is refused while the preset registry state is " + "unknown. Fix or restore " + "[cyan].specify/presets/.registry[/cyan] and retry." + ) + raise typer.Exit(1) + if affected_presets: + preset_list = ", ".join(sorted(affected_presets)) + console.print( + f"[red]Error:[/red] Cannot change '{key}' command layout while " + f"preset override(s) are installed: [bold]{preset_list}[/bold]." + ) + console.print( + "Preset artifacts cannot be safely reconciled across a " + "command↔skills layout change, so the migration is refused " + "before changing files." + ) + console.print( + "Remove the preset(s), run the upgrade, then reinstall them:\n" + f" [cyan]specify preset remove [/cyan]\n" + f" [cyan]specify integration upgrade {key} " + f"--integration-options \"...\"[/cyan]\n" + f" [cyan]specify preset add [/cyan]" + ) + raise typer.Exit(1) + + # 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, + project_root=project_root, + ), + invoke_prefix=_invoke_prefix_for_integration( + infra_integration, infra_key, infra_parsed, project_root + ), + ) + 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()) + + from ..events import resolve_events + events_map = resolve_events( + key, + integration.config, + project_root, + parsed_options, + ) + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + events=events_map, + ) + settings = _with_integration_setting( + current, + key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + project_root=project_root, + ) + if installed_key == key: + try: + _install_shared_infra( + project_root, + selected_script, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options, + project_root=project_root, + ), + invoke_prefix=_invoke_prefix_for_integration( + integration, key, parsed_options, project_root + ), + force=force, + refresh_managed=True, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared infrastructure for '{key}': {exc}" + ) from exc + if os.name != "nt": + from .. import ensure_executable_scripts + ensure_executable_scripts(project_root) + 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, + parsed_options=parsed_options, + ) + 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 + # Exclude integration-declared paths that use conditional manifest tracking + # (e.g. merge targets like .vscode/settings.json) so they are never deleted + # as "stale" while still being actively managed. Manifest keys are stored + # in POSIX form, so normalize the exclusions the same way before subtracting + # (an integration may build paths with os.path.join / backslashes). + exclusions = {PurePath(p).as_posix() for p in integration.stale_cleanup_exclusions()} + stale_keys = (set(old_files) - set(new_files)) - exclusions + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + # remove_manifest=False: this throwaway manifest shares ``key`` with the + # real one just saved above (new_manifest.save()). Letting uninstall() + # delete ``{key}.manifest.json`` would wipe the freshly-written manifest + # whenever an upgrade shrinks the tracked file set (e.g. Bob migrating + # from the legacy commands layout to skills), leaving the integration + # untracked and un-upgradeable. + stale_removed, _ = stale_manifest.uninstall( + project_root, force=True, remove_manifest=False + ) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + legacy_command_root_changed = _legacy_command_root_changed( + integration, + project_root, + old_manifest, + new_manifest, + ) + if legacy_command_root_changed: + _unregister_enabled_extension_commands_for_agent( + project_root, + key, + continuing=( + "The integration command directory changed, but legacy enabled " + "extension artifacts may need manual cleanup." + ), + ) + + # Re-register enabled extensions and presets only when upgrading the + # active integration. Inactive integrations remain untouched until + # `use` or `switch` activates and rescaffolds them (#2948). This runs + # after the core upgrade transaction, so failures remain best-effort. + if key == installed_key: + _register_extensions_for_agent( + project_root, + key, + force=True, + continuing="The integration was upgraded, but installed extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + key, + continuing="The integration was upgraded, but installed presets may need re-registration.", + ) + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") diff --git a/src/specify_cli/integrations/command_use.py b/src/specify_cli/integrations/command_use.py new file mode 100644 index 0000000000..d26eeb1926 --- /dev/null +++ b/src/specify_cli/integrations/command_use.py @@ -0,0 +1,69 @@ +"""The ``specify integration use`` command.""" +from __future__ import annotations + + +import typer + +from .._console import console +from ..integration_state import installed_integration_keys as _installed_integration_keys +from ._commands import integration_app +from ._helpers import ( + _read_integration_json, + _register_extensions_for_agent, + _register_presets_for_agent, + _resolve_integration_options, + _set_default_integration_or_exit, +) + + +@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 existing shared infrastructure files, including customizations, 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, + refresh_hint=( + "To overwrite customizations, re-run with " + f"[cyan]specify integration use {key} --force[/cyan]." + ), + ) + _register_extensions_for_agent( + project_root, + key, + continuing="The integration was selected, but installed extensions may need re-registration.", + ) + _register_presets_for_agent( + project_root, + key, + continuing="The integration was selected, but installed presets may need re-registration.", + ) + console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index b22a440661..3d1dba4139 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4565,7 +4565,7 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_packs``. The sibling integration catalog reader already guards both the root object and - the nested mapping (see ``integrations/catalog.py``); the preset + the nested mapping (see ``integrations/catalog/__init__.py``); the preset catalog must stay consistent so a malformed payload surfaces as the user-facing ``Invalid preset catalog format`` error instead of a raw Python traceback. @@ -4868,7 +4868,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Both files are written explicitly as UTF-8 to match the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog.py`` precedent. Without this, + # ``integrations/catalog/__init__.py`` precedent. Without this, # platforms whose default encoding isn't UTF-8 would write # locale-encoded bytes the read path can't decode, forcing an # unnecessary refetch on every invocation. The write itself @@ -4923,7 +4923,7 @@ def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, # so a payload like ``{"presets": {"foo": [], "bar": # {...}}}`` still merges the valid entries without # crashing on ``**pack_data``. Mirrors - # ``integrations/catalog.py:245``. + # ``integrations/catalog/__init__.py:245``. if not isinstance(pack_data, dict): continue pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} @@ -5040,7 +5040,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Save to cache. Explicit UTF-8 on both writes mirrors the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog.py`` precedent — otherwise platforms + # ``integrations/catalog/__init__.py`` precedent — otherwise platforms # whose default encoding isn't UTF-8 would write # locale-encoded bytes the read path can't decode, forcing an # unnecessary refetch on every invocation. Like the read diff --git a/tests/integrations/_integration_scaffold_helpers.py b/tests/integrations/_integration_scaffold_helpers.py new file mode 100644 index 0000000000..6f6401844d --- /dev/null +++ b/tests/integrations/_integration_scaffold_helpers.py @@ -0,0 +1,16 @@ +"""Shared setup for integration scaffold domain and command tests.""" + +from pathlib import Path + + +def integration_repo_root(tmp_path: Path) -> Path: + root = tmp_path / "spec-kit" + (root / "src" / "specify_cli" / "integrations").mkdir(parents=True) + (root / "tests" / "integrations").mkdir(parents=True) + (root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8") + (root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8") + (root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text( + "", + encoding="utf-8", + ) + return root diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 21c3fffc5e..678b24702d 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -10,6 +10,7 @@ from rich.console import Console from tests.conftest import strip_ansi +from tests.specify_cli.integrations._catalog_helpers import IntegrationCatalogCliTestBase class _NoopConsole: @@ -1665,264 +1666,8 @@ def test_full_init_copilot_commands_resolves_page_templates(self, tmp_path): assert "/speckit-specify" not in script_content -class TestIntegrationCatalogDiscoveryCLI: - """End-to-end CLI tests for `integration search`, `info`, and `catalog …`. - - All tests patch `IntegrationCatalog._get_merged_integrations` so no network - or on-disk cache is touched. Adds #2344 coverage without affecting any - existing integration install/switch/uninstall/upgrade behavior. - """ - - FAKE_INTEGRATIONS = [ - { - "id": "acme-coder", - "name": "Acme Coder", - "version": "2.0.0", - "description": "Community integration for Acme Coder", - "author": "acme-org", - "tags": ["cli", "acme"], - "_catalog_name": "community", - "_install_allowed": False, - }, - { - "id": "stellar-agent", - "name": "Stellar Agent", - "version": "1.3.0", - "description": "First-party Stellar agent integration", - "author": "stellar-labs", - "tags": ["ide"], - "_catalog_name": "default", - "_install_allowed": True, - }, - ] - MARKUP_INTEGRATION = { - "id": "[red]markup-id[/red]", - "name": "[green]Markup Name[/green]", - "version": "[blue]1.0.0[/blue]", - "description": "[yellow]Markup Description[/yellow]", - "author": "[magenta]Markup Author[/magenta]", - "license": "[cyan]Markup License[/cyan]", - "repository": "[bold]Markup Repository[/bold]", - "tags": ["[italic]markup-tag[/italic]"], - "_catalog_name": "[underline]markup-catalog[/underline]", - "_install_allowed": False, - } - - def _make_project(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - return project - - def _patch_catalog(self, monkeypatch, integrations=None): - """Return a stubbed `_get_merged_integrations` that yields *integrations*.""" - from specify_cli.integrations.catalog import IntegrationCatalog - - data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS) - - def fake_merged(self, force_refresh=False): - return data - - monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged) - - def _invoke(self, argv, cwd): - from typer.testing import CliRunner - from specify_cli import app - - runner = CliRunner() - old = os.getcwd() - try: - os.chdir(cwd) - return runner.invoke(app, argv, catch_exceptions=False) - finally: - os.chdir(old) - - def test_integration_install_failure_reports_phase_target_and_rollback( - self, tmp_path, monkeypatch - ): - from specify_cli.integrations import INTEGRATION_REGISTRY - from specify_cli.integrations.base import IntegrationBase - - class BrokenIntegration(IntegrationBase): - key = "broken-test" - config = { - "name": "Broken Test", - "folder": ".broken/", - "commands_subdir": "commands", - "install_url": None, - "requires_cli": False, - } - registrar_config = { - "dir": ".broken/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md", - } - - def setup(self, project_root, manifest, **kwargs): - raise OSError("setup exploded\nwith context") - - def teardown(self, project_root, manifest, force=False): - raise OSError("rollback exploded") - - project = self._make_project(tmp_path) - monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration()) - - result = self._invoke(["integration", "install", "broken-test"], project) - normalized = _normalize_cli_output(result.output) - - assert result.exit_code == 1, result.output - assert "Failed to rollback integration 'broken-test'" in normalized - assert "rollback exploded" in normalized - assert "Failed to install integration 'broken-test'" in normalized - assert "setup exploded with context" in normalized - - def test_integration_upgrade_failure_reports_phase_and_target( - self, tmp_path, monkeypatch - ): - from specify_cli.integrations import INTEGRATION_REGISTRY - from specify_cli.integrations.copilot import CopilotIntegration - - class UpgradeBrokenIntegration(CopilotIntegration): - key = "upgrade-broken" - config = dict(CopilotIntegration.config) - config["name"] = "Upgrade Broken" - - def setup(self, project_root, manifest, **kwargs): - raise OSError("upgrade exploded\nwith context") - - project = self._make_project(tmp_path) - monkeypatch.setitem( - INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration() - ) - - (project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True) - (project / ".specify" / "integration.json").write_text( - json.dumps( - { - "version": 1, - "integration": "upgrade-broken", - "integrations": ["upgrade-broken"], - "integration_settings": {"upgrade-broken": {"script": "sh"}}, - } - ), - encoding="utf-8", - ) - ( - project / ".specify" / "integrations" / "upgrade-broken.manifest.json" - ).write_text( - json.dumps( - { - "integration": "upgrade-broken", - "version": "0.0.0", - "installed_at": "2026-05-16T00:00:00+00:00", - "files": {}, - } - ), - encoding="utf-8", - ) - - result = self._invoke(["integration", "upgrade", "upgrade-broken"], project) - normalized = _normalize_cli_output(result.output) - - assert result.exit_code == 1, result.output - assert "Failed to upgrade integration 'upgrade-broken'" in normalized - assert "upgrade exploded with context" in normalized - assert "previous integration files may still be in place" in normalized - - def test_integration_switch_cleanup_warning_reports_phase_and_targets( - self, tmp_path, monkeypatch - ): - from specify_cli.extensions import ExtensionManager - - project = self._make_project(tmp_path) - (project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True) - (project / ".specify" / "integration.json").write_text( - json.dumps( - { - "version": 1, - "integration": "copilot", - "integrations": ["copilot"], - "integration_settings": {"copilot": {"script": "sh"}}, - } - ), - encoding="utf-8", - ) - (project / ".specify" / "integrations" / "copilot.manifest.json").write_text( - json.dumps( - { - "integration": "copilot", - "version": "0.0.0", - "installed_at": "2026-05-16T00:00:00+00:00", - "files": {}, - } - ), - encoding="utf-8", - ) - - def fail_cleanup(self, integration_key): - raise OSError("cleanup exploded") - - monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup) - - result = self._invoke(["integration", "switch", "claude"], project) - normalized = _normalize_cli_output(result.output) - - assert result.exit_code == 0, result.output - assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized - assert "cleanup exploded" in normalized - assert "Switched to integration" in normalized - - # -- Project guard ----------------------------------------------------- - - def test_search_requires_specify_project(self, tmp_path): - project = tmp_path / "bare" - project.mkdir() - result = self._invoke(["integration", "search"], project) - assert result.exit_code == 1 - assert "Not a Spec Kit project" in result.output - - def test_catalog_list_requires_specify_project(self, tmp_path): - project = tmp_path / "bare" - project.mkdir() - result = self._invoke(["integration", "catalog", "list"], project) - assert result.exit_code == 1 - assert "Not a Spec Kit project" in result.output - - def test_primary_integration_commands_require_specify_project(self, tmp_path): - project = tmp_path / "bare" - project.mkdir() - commands = [ - ["integration", "list"], - ["integration", "install", "codex"], - ["integration", "use", "codex"], - ["integration", "uninstall"], - ["integration", "switch", "codex"], - ["integration", "upgrade"], - ] - - for command in commands: - result = self._invoke(command, project) - failure_context = ( - f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" - ) - assert result.exit_code == 1, failure_context - assert "Not a Spec Kit project" in result.output, failure_context - - def test_integration_commands_require_specify_directory(self, tmp_path): - project = tmp_path / "bad" - project.mkdir() - (project / ".specify").write_text("not a directory") - - commands = [ - ["integration", "list"], - ["integration", "use", "codex"], - ] - - for command in commands: - result = self._invoke(command, project) - assert result.exit_code == 1, result.output - assert "Not a Spec Kit project" in result.output +class TestProjectScopedCliContracts(IntegrationCatalogCliTestBase): + """Cross-domain project guard and catalog path contracts.""" def test_project_scoped_commands_require_specify_directory(self, tmp_path): project = tmp_path / "bad-feature-commands" @@ -2002,561 +1747,6 @@ def test_catalog_config_output_uses_posix_paths(self, tmp_path): assert extension_list.exit_code == 0, extension_list.output assert "Config: .specify/extension-catalogs.yml" in extension_list.output - - - - - # -- search ------------------------------------------------------------ - - def test_search_lists_all(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke(["integration", "search"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 0, result.output - assert "Found 2 integration(s)" in result.output - assert "acme-coder" in result.output - assert "stellar-agent" in result.output - assert "specify integration install stellar-agent" not in normalized_output - assert "Only built-in integration IDs can be installed" in normalized_output - - def test_search_validates_integration_json_before_catalog_lookup( - self, tmp_path, monkeypatch - ): - project = self._make_project(tmp_path) - (project / ".specify" / "integration.json").write_text( - "{bad json\n", encoding="utf-8" - ) - - from specify_cli.integrations.catalog import IntegrationCatalog - - def fail_search(self, **kwargs): - raise AssertionError("catalog search should not be called") - - monkeypatch.setattr(IntegrationCatalog, "search", fail_search) - - result = self._invoke(["integration", "search"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1 - assert "contains invalid JSON" in normalized_output - assert "integration.json" in normalized_output - - def test_search_rejects_non_utf8_integration_json_before_catalog_lookup( - self, tmp_path, monkeypatch - ): - """A non-UTF8 ``integration.json`` must surface a clear error and - avoid falling through to the catalog lookup, mirroring the malformed-JSON - case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``.""" - project = self._make_project(tmp_path) - # 0xFF is invalid as the leading byte of any UTF-8 sequence, so - # ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``. - (project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00") - - from specify_cli.integrations.catalog import IntegrationCatalog - - def fail_search(self, **kwargs): - raise AssertionError("catalog search should not be called") - - monkeypatch.setattr(IntegrationCatalog, "search", fail_search) - - result = self._invoke(["integration", "search"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1 - assert "not valid UTF-8" in normalized_output - assert "integration.json" in normalized_output - - def test_search_filters_by_tag(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke(["integration", "search", "--tag", "acme"], project) - assert result.exit_code == 0, result.output - assert "Found 1 integration(s)" in result.output - assert "acme-coder" in result.output - assert "stellar-agent" not in result.output - - def test_search_filters_by_author(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke( - ["integration", "search", "--author", "stellar-labs"], project - ) - assert result.exit_code == 0, result.output - assert "Found 1 integration(s)" in result.output - assert "stellar-agent" in result.output - - def test_search_no_match_hint(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke( - ["integration", "search", "--tag", "nope"], project - ) - assert result.exit_code == 0, result.output - assert "No integrations found" in result.output - assert "specify integration search" in result.output - - def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke(["integration", "search", "acme"], project) - assert result.exit_code == 0, result.output - # acme-coder is flagged _install_allowed=False, so we should warn - assert "Not directly installable" in result.output - - def test_search_escapes_catalog_markup(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION]) - - result = self._invoke(["integration", "search"], project) - - assert result.exit_code == 0, result.output - output = _normalize_cli_output(result.output) - for value in ( - self.MARKUP_INTEGRATION["id"], - self.MARKUP_INTEGRATION["name"], - self.MARKUP_INTEGRATION["version"], - self.MARKUP_INTEGRATION["description"], - self.MARKUP_INTEGRATION["author"], - self.MARKUP_INTEGRATION["tags"][0], - self.MARKUP_INTEGRATION["_catalog_name"], - ): - assert value in output - - # -- info -------------------------------------------------------------- - - def test_info_found(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke( - ["integration", "info", "stellar-agent"], project - ) - assert result.exit_code == 0, result.output - assert "Stellar Agent" in result.output - assert "stellar-agent" in result.output - assert "v1.3.0" in result.output - - def test_info_not_found(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - result = self._invoke( - ["integration", "info", "does-not-exist"], project - ) - assert result.exit_code == 1 - assert "not found" in result.output - - def test_info_not_found_escapes_query_markup(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch) - integration_id = "[red]does-not-exist[/red]" - - result = self._invoke( - ["integration", "info", integration_id], - project, - ) - - assert result.exit_code == 1 - assert integration_id in _normalize_cli_output(result.output) - - def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - # Empty catalog, but copilot is a registered built-in. - self._patch_catalog(monkeypatch, integrations=[]) - result = self._invoke(["integration", "info", "copilot"], project) - assert result.exit_code == 0, result.output - assert "Built-in integration" in result.output - - def test_info_escapes_catalog_markup(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION]) - - result = self._invoke( - ["integration", "info", self.MARKUP_INTEGRATION["id"]], - project, - ) - - assert result.exit_code == 0, result.output - output = _normalize_cli_output(result.output) - for value in ( - self.MARKUP_INTEGRATION["id"], - self.MARKUP_INTEGRATION["name"], - self.MARKUP_INTEGRATION["version"], - self.MARKUP_INTEGRATION["description"], - self.MARKUP_INTEGRATION["author"], - self.MARKUP_INTEGRATION["license"], - self.MARKUP_INTEGRATION["repository"], - self.MARKUP_INTEGRATION["tags"][0], - self.MARKUP_INTEGRATION["_catalog_name"], - ): - assert value in output - - # -- validation vs network guidance ------------------------------------ - - def test_search_local_config_error_shows_local_config_tip( - self, tmp_path, monkeypatch - ): - """`integration search` must point at .specify/integration-catalogs.yml - for local-config errors (not the generic 'temporarily unavailable').""" - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - # Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError. - cfg = project / ".specify" / "integration-catalogs.yml" - invalid_yaml = "catalogs:\n - [bad\n" - cfg.write_text(invalid_yaml, encoding="utf-8") - - result = self._invoke(["integration", "search"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1, result.output - assert "configuration file path shown above" in normalized_output - assert ".specify/integration-catalogs.yml" in normalized_output - assert "~/.specify/integration-catalogs.yml" in normalized_output - assert "temporarily unavailable" not in normalized_output - - def test_search_invalid_env_catalog_url_shows_env_tip( - self, tmp_path, monkeypatch - ): - project = self._make_project(tmp_path) - monkeypatch.setenv( - "SPECKIT_INTEGRATION_CATALOG_URL", - "http://insecure.example.com/catalog.json", - ) - - result = self._invoke(["integration", "search"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1, result.output - assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output - assert "unset it to use the configured catalog files" in normalized_output - assert ".specify/integration-catalogs.yml" in normalized_output - assert "~/.specify/integration-catalogs.yml" in normalized_output - assert "temporarily unavailable" not in normalized_output - - def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip( - self, tmp_path, monkeypatch - ): - project = self._make_project(tmp_path) - monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ") - - from specify_cli.integrations.catalog import ( - IntegrationCatalog, - IntegrationCatalogError, - ) - - def fail_search(self, **kwargs): - raise IntegrationCatalogError("catalog offline") - - monkeypatch.setattr(IntegrationCatalog, "search", fail_search) - - result = self._invoke(["integration", "search"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1, result.output - assert "temporarily unavailable" in normalized_output - assert ( - "SPECKIT_INTEGRATION_CATALOG_URL environment variable" - not in normalized_output - ) - - def test_info_unknown_with_local_config_error_shows_local_config_tip( - self, tmp_path, monkeypatch - ): - """`integration info ` falls back to the catalog-error branch - and must show local-config guidance, not 'Try again when online'.""" - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - cfg = project / ".specify" / "integration-catalogs.yml" - invalid_yaml = "catalogs:\n - [bad\n" - cfg.write_text(invalid_yaml, encoding="utf-8") - - result = self._invoke( - ["integration", "info", "definitely-not-real"], project - ) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1, result.output - assert "configuration file path shown above" in normalized_output - assert ".specify/integration-catalogs.yml" in normalized_output - assert "~/.specify/integration-catalogs.yml" in normalized_output - assert "Try again when online" not in normalized_output - - def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip( - self, tmp_path, monkeypatch - ): - project = self._make_project(tmp_path) - monkeypatch.setenv( - "SPECKIT_INTEGRATION_CATALOG_URL", - "http://insecure.example.com/catalog.json", - ) - - result = self._invoke( - ["integration", "info", "definitely-not-real"], project - ) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 1, result.output - assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output - assert "unset it to use the configured catalog files" in normalized_output - assert "Try again when online" not in normalized_output - - # -- catalog list / add / remove --------------------------------------- - - def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - result = self._invoke(["integration", "catalog", "list"], project) - assert result.exit_code == 0, result.output - assert "Integration Catalog Sources" in result.output - assert "No project-level catalog sources configured" in result.output - assert "Active catalog sources" in result.output - assert "non-removable" in result.output - assert "default" in result.output - assert "community" in result.output - # Built-in defaults are active, but not removable project entries. - assert "[0]" not in result.output - assert "[1]" not in result.output - - def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - - add_result = self._invoke( - [ - "integration", - "catalog", - "add", - "https://new.example.com/catalog.json", - "--name", - "mine", - ], - project, - ) - assert add_result.exit_code == 0, add_result.output - assert "Catalog source added" in add_result.output - - cfg_path = project / ".specify" / "integration-catalogs.yml" - assert cfg_path.exists() - - list_result = self._invoke(["integration", "catalog", "list"], project) - assert list_result.exit_code == 0, list_result.output - assert "Project catalog sources" in list_result.output - assert "[0]" in list_result.output - assert "mine" in list_result.output - assert "default" not in list_result.output - assert "community" not in list_result.output - - remove_result = self._invoke( - ["integration", "catalog", "remove", "0"], project - ) - assert remove_result.exit_code == 0, remove_result.output - assert "'mine' removed" in remove_result.output - - def test_catalog_list_normalizes_blank_project_catalog_names( - self, tmp_path, monkeypatch - ): - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - cfg_path = project / ".specify" / "integration-catalogs.yml" - cfg_path.write_text( - yaml.dump( - { - "catalogs": [ - { - "url": "https://null-name.example.com/catalog.json", - "name": None, - }, - { - "url": "https://blank-name.example.com/catalog.json", - "name": " ", - }, - ] - } - ), - encoding="utf-8", - ) - - result = self._invoke(["integration", "catalog", "list"], project) - normalized_output = _normalize_cli_output(result.output) - - assert result.exit_code == 0, result.output - assert "[0] catalog-1" in normalized_output - assert "[1] catalog-2" in normalized_output - assert "None" not in normalized_output - - def test_catalog_list_env_override_supersedes_project_config( - self, tmp_path, monkeypatch - ): - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.setenv( - "SPECKIT_INTEGRATION_CATALOG_URL", - "https://env.example.com/catalog.json", - ) - cfg_path = project / ".specify" / "integration-catalogs.yml" - cfg_path.write_text( - yaml.dump( - { - "catalogs": [ - { - "url": "https://project.example.com/catalog.json", - "name": "project", - "priority": 1, - } - ] - } - ), - encoding="utf-8", - ) - - result = self._invoke(["integration", "catalog", "list"], project) - normalized_output = _normalize_cli_output(result.output) - assert result.exit_code == 0, result.output - assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output - assert "supersedes configured catalog files" in normalized_output - assert "non-removable" in normalized_output - assert "https://env.example.com/catalog.json" in normalized_output - assert "https://project.example.com/catalog.json" not in normalized_output - assert "[0]" not in normalized_output - - def test_catalog_add_strips_whitespace_in_success_output_and_storage( - self, tmp_path, monkeypatch - ): - """Surrounding whitespace in the URL must not appear in the success - message or be persisted to the YAML config.""" - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - - padded_url = " https://padded.example.com/catalog.json " - clean_url = "https://padded.example.com/catalog.json" - - add_result = self._invoke( - [ - "integration", - "catalog", - "add", - padded_url, - "--name", - "padded", - ], - project, - ) - assert add_result.exit_code == 0, add_result.output - assert clean_url in add_result.output - assert padded_url not in add_result.output - - cfg_path = project / ".specify" / "integration-catalogs.yml" - import yaml as _yaml - data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8")) - urls = [c["url"] for c in data["catalogs"]] - assert clean_url in urls - assert padded_url not in urls - - def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - result = self._invoke( - [ - "integration", - "catalog", - "add", - "http://insecure.example.com/catalog.json", - ], - project, - ) - assert result.exit_code == 1 - assert "HTTPS" in result.output - - def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - url = "https://dup.example.com/catalog.json" - first = self._invoke( - ["integration", "catalog", "add", url], project - ) - assert first.exit_code == 0, first.output - second = self._invoke( - ["integration", "catalog", "add", url], project - ) - assert second.exit_code == 1 - assert "already configured" in second.output - - def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - # Need a config file for remove to attempt an index lookup - self._invoke( - [ - "integration", - "catalog", - "add", - "https://only.example.com/catalog.json", - ], - project, - ) - result = self._invoke( - ["integration", "catalog", "remove", "9"], project - ) - assert result.exit_code == 1 - assert "out of range" in result.output - - def test_catalog_remove_without_config(self, tmp_path, monkeypatch): - project = self._make_project(tmp_path) - result = self._invoke( - ["integration", "catalog", "remove", "0"], project - ) - assert result.exit_code == 1 - assert "No catalog config" in result.output - - def test_catalog_remove_final_entry_restores_defaults( - self, tmp_path, monkeypatch - ): - """End-to-end: add → remove-last-entry → list should not error. - - Regression for the flow where a user adds a catalog, removes it, then - runs any follow-up integration command. Without the fix the config - file would be left as `catalogs: []` and every subsequent - `integration` call would fail with "contains no 'catalogs' entries". - """ - project = self._make_project(tmp_path) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("USERPROFILE", str(tmp_path)) - monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) - - add = self._invoke( - [ - "integration", - "catalog", - "add", - "https://only.example.com/catalog.json", - "--name", - "only", - ], - project, - ) - assert add.exit_code == 0, add.output - - remove = self._invoke( - ["integration", "catalog", "remove", "0"], project - ) - assert remove.exit_code == 0, remove.output - assert "'only' removed" in remove.output - - cfg_path = project / ".specify" / "integration-catalogs.yml" - assert not cfg_path.exists(), ( - "config file should be deleted when the final catalog is removed" - ) - - # Follow-up command must succeed and show the built-in defaults, - # not error out on "contains no 'catalogs' entries". - listing = self._invoke(["integration", "catalog", "list"], project) - assert listing.exit_code == 0, listing.output - assert "default" in listing.output - assert "community" in listing.output - - def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path): """refresh_shared_templates must not overwrite a recovered (pre-existing user) template without --force, matching install_shared_infra's gate (#2918). diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 16304c78a4..63e4c58e9d 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -2996,7 +2996,7 @@ def test_fresh_manifest_upgrade_deletes_dispatcher_when_last(self, tmp_path): # Simulate the upgrade path: a fresh manifest (like # IntegrationManifest(key, project_root, version=...) in - # _migrate_commands) that never recorded the dispatcher. + # command_upgrade) that never recorded the dispatcher. fresh = IntegrationManifest(claude.key, tmp_path, version="test") assert EVENTS_DISPATCHER_REL not in fresh.files install_integration_events(claude, tmp_path, fresh, {}) diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index c414c3d8ea..316158c791 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -1,7 +1,6 @@ -"""Tests for the integration catalog system (catalog.py).""" +"""Tests for the integration catalog domain API.""" import json -import os import pytest import yaml @@ -766,142 +765,6 @@ def test_tools_accessor(self, tmp_path): # --------------------------------------------------------------------------- -class TestIntegrationListCatalog: - """Test ``specify integration list --catalog``.""" - - def _init_project(self, tmp_path): - """Create a minimal spec-kit project.""" - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = tmp_path / "proj" - project.mkdir() - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "init", "--here", - "--integration", "copilot", - "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old) - assert result.exit_code == 0, result.output - return project - - def test_list_catalog_flag(self, tmp_path, monkeypatch): - """--catalog should show catalog entries.""" - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path) - - catalog = { - "schema_version": "1.0", - "updated_at": "2026-01-01T00:00:00Z", - "integrations": { - "test-agent": { - "id": "test-agent", - "name": "Test Agent", - "version": "1.0.0", - "description": "A test agent", - "tags": ["cli"], - }, - }, - } - - import specify_cli.authentication.http as _auth_http - - class FakeResponse: - def __init__(self, data, url=""): - self._data = json.dumps(data).encode() - self._url = url if isinstance(url, str) else url.full_url - self._offset = 0 - - def read(self, size=-1): - if size == -1: - chunk = self._data[self._offset:] - self._offset = len(self._data) - else: - chunk = self._data[self._offset:self._offset + size] - self._offset += len(chunk) - return chunk - - def geturl(self): - return self._url - - def __enter__(self): - return self - - def __exit__(self, *a): - pass - - monkeypatch.setattr(_auth_http.urllib.request, "urlopen", - lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url)) - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "list", "--catalog"]) - finally: - os.chdir(old) - - assert result.exit_code == 0 - assert "test-agent" in result.output - assert "Test Agent" in result.output - - def test_list_without_catalog_still_works(self, tmp_path): - """Default list (no --catalog) works as before.""" - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path) - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "list"]) - finally: - os.chdir(old) - - assert result.exit_code == 0 - assert "copilot" in result.output - assert "installed" in result.output - - def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch): - """User-editable catalog name/url/description must not be parsed as Rich markup.""" - from typer.testing import CliRunner - from specify_cli import app - from specify_cli.integrations.catalog import IntegrationCatalog - runner = CliRunner() - project = self._init_project(tmp_path) - - configs = [ - { - "name": "Bracket [Catalog]", - "url": "https://example.com/[cat].json", - "description": "desc [with] brackets", - "install_allowed": True, - }, - ] - monkeypatch.setattr( - IntegrationCatalog, - "get_project_catalog_configs", - lambda self: [dict(c) for c in configs], - ) - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "catalog", "list"]) - finally: - os.chdir(old) - - assert result.exit_code == 0, result.output - assert "Bracket [Catalog]" in result.output - assert "https://example.com/[cat].json" in result.output - assert "desc [with] brackets" in result.output # --------------------------------------------------------------------------- @@ -909,159 +772,6 @@ def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -class TestIntegrationUpgrade: - """Test ``specify integration upgrade``.""" - - def _init_project(self, tmp_path, integration="copilot"): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = tmp_path / "proj" - project.mkdir() - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "init", "--here", - "--integration", integration, - "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old) - assert result.exit_code == 0, result.output - return project - - def test_upgrade_requires_speckit_project(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - old = os.getcwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["integration", "upgrade"]) - finally: - os.chdir(old) - assert result.exit_code != 0 - assert "Not a Spec Kit project" in result.output - - def test_upgrade_no_integration_installed(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade"]) - finally: - os.chdir(old) - assert result.exit_code == 0 - assert "No integration is currently installed" in result.output - - def test_upgrade_succeeds(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path, "copilot") - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False) - finally: - os.chdir(old) - assert result.exit_code == 0 - assert "upgraded successfully" in result.output - - def test_upgrade_blocks_on_modified_files(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path, "copilot") - - # Modify a tracked file so the manifest hash won't match - manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" - assert manifest_path.exists(), "Manifest should exist after init" - manifest_data = json.loads(manifest_path.read_text()) - tracked_files = manifest_data.get("files", {}) - assert tracked_files, "Manifest should track at least one file" - first_rel = next(iter(tracked_files)) - target_file = project / first_rel - assert target_file.exists(), f"Tracked file {first_rel} should exist" - target_file.write_text("MODIFIED CONTENT\n") - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade"]) - finally: - os.chdir(old) - assert result.exit_code != 0 - assert "modified" in result.output.lower() - - def test_upgrade_force_overwrites_modified(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path, "copilot") - - # Modify a tracked file - manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" - manifest_data = json.loads(manifest_path.read_text()) - tracked_files = manifest_data.get("files", {}) - assert tracked_files, "Manifest should track at least one file" - first_rel = next(iter(tracked_files)) - target_file = project / first_rel - assert target_file.exists(), f"Tracked file {first_rel} should exist" - target_file.write_text("MODIFIED CONTENT\n") - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False) - finally: - os.chdir(old) - assert result.exit_code == 0 - assert "upgraded successfully" in result.output - - def test_upgrade_wrong_integration_key(self, tmp_path): - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path, "copilot") - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade", "claude"]) - finally: - os.chdir(old) - assert result.exit_code != 0 - assert "not installed" in result.output - - def test_upgrade_no_manifest(self, tmp_path): - """Upgrade with missing manifest suggests fresh install.""" - from typer.testing import CliRunner - from specify_cli import app - runner = CliRunner() - project = self._init_project(tmp_path, "copilot") - - # Remove manifest - manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" - if manifest_path.exists(): - manifest_path.unlink() - - old = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade"]) - finally: - os.chdir(old) - assert result.exit_code == 0 - assert "Nothing to upgrade" in result.output # --------------------------------------------------------------------------- diff --git a/tests/integrations/test_integration_scaffold.py b/tests/integrations/test_integration_scaffold.py index f38ea824d5..c858b5b6af 100644 --- a/tests/integrations/test_integration_scaffold.py +++ b/tests/integrations/test_integration_scaffold.py @@ -1,62 +1,11 @@ -"""Tests for integration scaffolding commands.""" +"""Tests for the integration scaffolding domain API.""" from pathlib import Path import pytest -from typer.testing import CliRunner -from specify_cli import app from specify_cli.integration_scaffold import scaffold_integration -from tests.conftest import strip_ansi - - -runner = CliRunner() - - -def _repo_root(tmp_path: Path) -> Path: - root = tmp_path / "spec-kit" - (root / "src" / "specify_cli" / "integrations").mkdir(parents=True) - (root / "tests" / "integrations").mkdir(parents=True) - (root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8") - (root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8") - (root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text( - "", - encoding="utf-8", - ) - return root - - -def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch): - root = _repo_root(tmp_path) - monkeypatch.chdir(root) - - result = runner.invoke(app, [ - "integration", "scaffold", "my-agent", - "--type", "markdown", - ], catch_exceptions=False) - - output = strip_ansi(result.output) - integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py" - test_file = root / "tests" / "integrations" / "test_integration_my_agent.py" - - assert result.exit_code == 0 - assert integration_file.exists() - assert test_file.exists() - assert "Created integration scaffold: my-agent" in output - assert "Register MyAgentIntegration" in output - - content = integration_file.read_text(encoding="utf-8") - assert "class MyAgentIntegration(MarkdownIntegration):" in content - assert 'key = "my-agent"' in content - assert '"folder": ".my-agent/"' in content - assert '"extension": ".md"' in content - assert "multi_install_safe = False" in content - - test_content = test_file.read_text(encoding="utf-8") - assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content - assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content - assert "assert integration.multi_install_safe is False" in test_content - +from tests.integrations._integration_scaffold_helpers import integration_repo_root as _repo_root @pytest.mark.parametrize( ("integration_type", "base_class", "commands_subdir", "args", "extension"), @@ -86,44 +35,6 @@ def test_scaffold_type_templates( assert f'"extension": "{extension}"' in content assert "multi_install_safe = False" in content - -def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch): - root = _repo_root(tmp_path) - monkeypatch.chdir(root) - - result = runner.invoke(app, [ - "integration", "scaffold", "my-agent", - "--type", "xml", - ]) - - output = strip_ansi(result.output) - assert result.exit_code == 2 - assert "Invalid value for '--type'" in output - assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists() - - -def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch): - root = _repo_root(tmp_path) - monkeypatch.chdir(root) - - import specify_cli.integration_scaffold as scaffold_module - - def boom(*args, **kwargs): - raise PermissionError("Permission denied: read-only checkout") - - monkeypatch.setattr(scaffold_module, "scaffold_integration", boom) - - result = runner.invoke(app, [ - "integration", "scaffold", "my-agent", - "--type", "markdown", - ], catch_exceptions=False) - - output = strip_ansi(result.output) - assert result.exit_code == 1 - assert "Error:" in output - assert "Permission denied" in output - - def test_scaffold_refuses_invalid_key(tmp_path): root = _repo_root(tmp_path) @@ -220,19 +131,3 @@ def test_scaffold_refuses_symlinked_target_directory(tmp_path): scaffold_integration(root, "my-agent", "markdown") assert not (outside / "my_agent").exists() - - -def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch): - root = _repo_root(tmp_path) - monkeypatch.chdir(root) - - result = runner.invoke(app, [ - "integration", "scaffold", "my-agent", - "--type", "YAML", - ], catch_exceptions=False) - - assert result.exit_code == 0, strip_ansi(result.output) - content = ( - root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py" - ).read_text(encoding="utf-8") - assert "class MyAgentIntegration(YamlIntegration):" in content diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py deleted file mode 100644 index eaeecc6740..0000000000 --- a/tests/integrations/test_integration_subcommand.py +++ /dev/null @@ -1,4499 +0,0 @@ -"""Tests for ``specify integration`` subcommand (list, install, uninstall, switch).""" - -import json -import os -import shutil -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from specify_cli import app -from tests.conftest import strip_ansi - - -runner = CliRunner() - - -@pytest.mark.parametrize( - "args", - [ - ["init", "--help"], - ["integration", "install", "--help"], - ["integration", "switch", "--help"], - ["integration", "upgrade", "--help"], - ], -) -def test_script_help_includes_python_variant(args): - result = runner.invoke(app, args) - - assert result.exit_code == 0 - assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split()) - - -def _init_project(tmp_path, integration="copilot", integration_options=None): - """Helper: init a spec-kit project with the given integration.""" - project = tmp_path / "proj" - project.mkdir() - args = [ - "init", "--here", - "--integration", integration, - "--script", "sh", - "--ignore-agent-tools", - ] - if integration_options: - args += ["--integration-options", integration_options] - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, args, catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, f"init failed: {result.output}" - return project - - -def _run_in_project(project, args): - """Run a CLI command from inside a generated project.""" - old_cwd = os.getcwd() - try: - os.chdir(project) - return runner.invoke(app, args, catch_exceptions=False) - finally: - os.chdir(old_cwd) - - -def _write_invalid_manifest(project, key): - manifest = project / ".specify" / "integrations" / f"{key}.manifest.json" - manifest.write_bytes(b"\xff\xfe\x00") - return manifest - - -def _move_kilocode_install_to_legacy_layout(project): - """Simulate a pre-.kilo Kilo install tracked under .kilocode/workflows.""" - canonical = project / ".kilo" / "commands" - legacy = project / ".kilocode" / "workflows" - assert canonical.is_dir(), "init should have created .kilo/commands/" - legacy.parent.mkdir(parents=True, exist_ok=True) - canonical.rename(legacy) - assert legacy.is_dir() - assert not canonical.exists() - - manifest_path = project / ".specify" / "integrations" / "kilocode.manifest.json" - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - manifest_data["files"] = { - path.replace(".kilo/commands/", ".kilocode/workflows/"): info - for path, info in manifest_data.get("files", {}).items() - } - manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") - return canonical, legacy - - -def _copy_project_template(tmp_path, template): - project = tmp_path / "proj" - shutil.copytree(template, project) - return project - - -@pytest.fixture(scope="module") -def status_copilot_template(tmp_path_factory): - return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot") - - -@pytest.fixture(scope="module") -def status_claude_template(tmp_path_factory): - return _init_project(tmp_path_factory.mktemp("status-claude"), "claude") - - -@pytest.fixture -def copilot_project(tmp_path, status_copilot_template): - return _copy_project_template(tmp_path, status_copilot_template) - - -@pytest.fixture -def claude_project(tmp_path, status_claude_template): - return _copy_project_template(tmp_path, status_claude_template) - - -def _integration_list_row_cells(output: str, key: str) -> list[str]: - plain = strip_ansi(output) - row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}")) - return [cell.strip() for cell in row.split("│")[1:-1]] - - -# ── list ───────────────────────────────────────────────────────────── - - -class TestIntegrationList: - def test_list_requires_speckit_project(self, tmp_path): - old_cwd = os.getcwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["integration", "list"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Not a Spec Kit project" in result.output - - def test_list_shows_installed(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "list"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "copilot" in result.output - assert "installed" in result.output - - def test_list_shows_available_integrations(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "list"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - # Should show multiple integrations - assert "claude" in result.output - assert "gemini" in result.output - assert "zed" in result.output - - def test_list_shows_multi_install_safe_status(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "list"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "Multi-install" in result.output - assert "Safe" in result.output - assert _integration_list_row_cells(result.output, "claude")[-1] == "yes" - assert _integration_list_row_cells(result.output, "copilot")[-1] == "no" - - def test_list_rejects_newer_integration_state_schema(self, tmp_path): - project = _init_project(tmp_path, "claude") - int_json = project / ".specify" / "integration.json" - data = json.loads(int_json.read_text(encoding="utf-8")) - data["integration_state_schema"] = 99 - int_json.write_text(json.dumps(data), encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "list"]) - finally: - os.chdir(old_cwd) - - assert result.exit_code != 0 - normalized = " ".join(result.output.split()) - assert "schema 99" in normalized - assert "only supports schema 1" in normalized - - -# ── status ─────────────────────────────────────────────────────────── - - -class TestIntegrationStatus: - def test_status_requires_speckit_project(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["integration", "status"]) - assert result.exit_code != 0 - assert "Not a Spec Kit project" in result.output - - def test_status_reports_healthy_project(self, copilot_project): - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code == 0 - assert "Integration status: OK" in result.output - assert "Default integration: copilot" in result.output - assert "Installed integrations: copilot" in result.output - assert "Shared templates target alignment: copilot" in result.output - assert "Modified managed files: 0" in result.output - assert "Missing managed files: 0" in result.output - - def test_status_json_reports_healthy_project(self, copilot_project): - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["status"] == "ok" - assert payload["default_integration"] == "copilot" - assert payload["installed_integrations"] == ["copilot"] - assert payload["recorded_installed_integrations"] == ["copilot"] - assert payload["manifest_checked_integrations"] == ["copilot", "speckit"] - assert payload["multi_install_safe"] is True - assert payload["shared_templates_target_alignment"] == "copilot" - assert "shared_templates_aligned_to" not in payload - assert payload["findings"] == [] - - def test_status_reports_invalid_integration_json(self, copilot_project): - (copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "integration-state-unreadable" in result.output - assert "invalid JSON" in result.output - assert "Detail:" in result.output - assert "Multi-install safe: unknown" in result.output - assert "Traceback" not in result.output - - def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable( - self, - copilot_project, - ): - (copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["status"] == "error" - assert payload["multi_install_safe"] is None - assert payload["manifest_checked_integrations"] == [] - assert payload["findings"][0]["code"] == "integration-state-unreadable" - assert "Detail:" in payload["findings"][0]["message"] - - def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project): - state_path = copilot_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["integration_state_schema"] = 99 - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["findings"][0]["code"] == "integration-state-unreadable" - assert "schema 99" in payload["findings"][0]["message"] - assert "supported schema: 1" in payload["findings"][0]["message"] - - def test_status_reports_missing_integration_json(self, copilot_project): - (copilot_project / ".specify" / "integration.json").unlink() - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "integration-state-missing" in result.output - assert ".specify/integration.json is missing" in result.output - assert "Multi-install safe: unknown" in result.output - - def test_status_json_reports_unknown_multi_install_safety_when_state_missing( - self, - copilot_project, - ): - (copilot_project / ".specify" / "integration.json").unlink() - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["status"] == "error" - assert payload["multi_install_safe"] is None - assert payload["manifest_checked_integrations"] == [] - assert payload["findings"][0]["code"] == "integration-state-missing" - - def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project): - state_path = copilot_project / ".specify" / "integration.json" - state_path.write_text( - json.dumps({ - "version": "test", - "integration_state_schema": 1, - "installed_integrations": [], - }), - encoding="utf-8", - ) - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["status"] == "warning" - assert payload["installed_integrations"] == [] - assert payload["multi_install_safe"] is None - assert payload["manifest_checked_integrations"] == ["speckit"] - assert payload["findings"][0]["code"] == "no-installed-integrations" - assert "speckit" in payload["manifests"] - assert payload["manifests"]["speckit"]["readable"] is True - - def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project): - state_path = copilot_project / ".specify" / "integration.json" - state_path.write_text( - json.dumps({ - "version": "test", - "integration_state_schema": 1, - "installed_integrations": [], - }), - encoding="utf-8", - ) - (copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink() - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["status"] == "error" - assert payload["installed_integrations"] == [] - assert payload["manifest_checked_integrations"] == ["speckit"] - assert payload["unchecked_manifests"] == 1 - assert any( - item["code"] == "no-installed-integrations" - for item in payload["findings"] - ) - assert any( - item["code"] == "manifest-missing" - and item["integration"] == "speckit" - for item in payload["findings"] - ) - - def test_status_json_reports_missing_default_integration_as_error(self, claude_project): - state_path = claude_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state.pop("default_integration", None) - state.pop("integration", None) - state["installed_integrations"] = ["claude"] - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["status"] == "error" - assert payload["default_integration"] is None - assert any( - item["code"] == "default-integration-missing" - for item in payload["findings"] - ) - - def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project): - state_path = copilot_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state.pop("default_integration", None) - state.pop("integration", None) - state["installed_integrations"] = "copilot" - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["status"] == "warning" - assert payload["installed_integrations"] == [] - assert payload["recorded_installed_integrations"] == [] - assert payload["manifest_checked_integrations"] == ["speckit"] - assert payload["multi_install_safe"] is None - assert [item["code"] for item in payload["findings"]] == [ - "installed-integrations-invalid", - "no-installed-integrations", - ] - - def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project): - state_path = copilot_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["default_integration"] = "copilot" - state["integration"] = "copilot" - state["installed_integrations"] = "copilot" - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["status"] == "warning" - assert payload["installed_integrations"] == ["copilot"] - assert payload["recorded_installed_integrations"] == [] - assert payload["manifest_checked_integrations"] == ["copilot", "speckit"] - assert payload["multi_install_safe"] is None - assert [item["code"] for item in payload["findings"]] == [ - "installed-integrations-invalid", - ] - - def test_status_reports_default_integration_not_installed(self, claude_project): - state_path = claude_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["default_integration"] = "codex" - state["integration"] = "codex" - state["installed_integrations"] = ["claude"] - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["default_integration"] == "codex" - assert payload["installed_integrations"] == ["codex", "claude"] - assert payload["recorded_installed_integrations"] == ["claude"] - assert payload["manifest_checked_integrations"] == ["claude", "speckit"] - assert any( - item["code"] == "default-integration-not-installed" - and "Default integration 'codex' is not listed" in item["message"] - for item in payload["findings"] - ) - assert "codex" not in payload["manifests"] - assert not any( - item["code"] == "manifest-missing" and item.get("integration") == "codex" - for item in payload["findings"] - ) - - def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project): - state_path = claude_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["installed_integrations"] = [] - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["installed_integrations"] == ["claude"] - assert payload["recorded_installed_integrations"] == [] - assert payload["manifest_checked_integrations"] == ["claude", "speckit"] - assert payload["multi_install_safe"] is None - assert payload["manifests"]["claude"]["readable"] is True - assert any( - item["code"] == "default-integration-not-installed" - for item in payload["findings"] - ) - - def test_status_reports_missing_manifest(self, copilot_project): - (copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink() - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "manifest-missing" in result.output - assert "Manifest for integration 'copilot' is missing" in result.output - - def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project): - _write_invalid_manifest(copilot_project, "copilot") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["unchecked_manifests"] == 1 - assert payload["manifests"]["copilot"]["readable"] is False - assert payload["manifests"]["copilot"]["missing_files"] == [] - assert payload["manifests"]["copilot"]["modified_files"] == [] - - def test_status_reports_modified_managed_files_without_failing(self, copilot_project): - manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" - tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] - first_rel = next(iter(tracked_files)) - (copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code == 0 - assert "Integration status: WARNING" in result.output - assert "managed-files-modified" in result.output - assert "Modified managed files: 1" in result.output - - def test_status_reports_missing_managed_files(self, copilot_project): - manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" - tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] - first_rel = next(iter(tracked_files)) - (copilot_project / first_rel).unlink() - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "managed-files-missing" in result.output - assert "Missing managed files: 1" in result.output - - def test_status_reports_missing_shared_managed_files(self, copilot_project): - shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh" - assert shared_file.exists() - shared_file.unlink() - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "managed-files-missing" in result.output - assert "shared Spec Kit infrastructure" in result.output - assert "Missing managed files: 1" in result.output - - def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch): - from specify_cli.integration_status import _manifest_file_status - from specify_cli.integrations.manifest import IntegrationManifest - - project = tmp_path / "proj" - project.mkdir() - tracked = project / "tracked.md" - tracked.write_text("content\n", encoding="utf-8") - manifest = IntegrationManifest("test", project, version="test") - manifest.record_existing("tracked.md") - - def fail_exists(self): - raise AssertionError(f"Path.exists() should not be used for {self}") - - monkeypatch.setattr(Path, "exists", fail_exists) - - missing, modified, invalid, valid = _manifest_file_status( - manifest, - project.resolve(), - ) - - assert missing == [] - assert modified == [] - assert invalid == [] - assert valid == ["tracked.md"] - - def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch): - def fail_exists(self): - raise AssertionError(f"Path.exists() should not be used for {self}") - - monkeypatch.setattr(Path, "exists", fail_exists) - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["status"] == "ok" - assert payload["manifests"]["copilot"]["readable"] is True - - def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch): - original_resolve = Path.resolve - failed = {"done": False} - - def fail_first_project_root_resolve(self, *args, **kwargs): - if self == copilot_project and not failed["done"]: - failed["done"] = True - raise RuntimeError("symlink loop") - return original_resolve(self, *args, **kwargs) - - monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve) - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["status"] == "warning" - assert any(item["code"] == "project-root-unresolved" for item in payload["findings"]) - - def test_status_loads_manifests_when_project_root_resolution_keeps_failing( - self, - copilot_project, - monkeypatch, - ): - original_resolve = Path.resolve - - def fail_project_root_resolve(self, *args, **kwargs): - if self == copilot_project: - raise RuntimeError("symlink loop") - return original_resolve(self, *args, **kwargs) - - monkeypatch.setattr(Path, "resolve", fail_project_root_resolve) - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["status"] == "warning" - assert payload["manifests"]["copilot"]["readable"] is True - assert payload["manifests"]["speckit"]["readable"] is True - assert any(item["code"] == "project-root-unresolved" for item in payload["findings"]) - - def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path): - from specify_cli.integration_status import _manifest_file_status - from specify_cli.integrations.manifest import IntegrationManifest - - real_project = tmp_path / "real-project" - real_project.mkdir() - tracked = real_project / "tracked.md" - tracked.write_text("content\n", encoding="utf-8") - symlinked_project = tmp_path / "symlinked-project" - try: - symlinked_project.symlink_to(real_project, target_is_directory=True) - except OSError as exc: - pytest.skip(f"symlinks unavailable: {exc}") - - manifest = IntegrationManifest("test", real_project, version="test") - manifest.record_existing("tracked.md") - manifest.project_root = symlinked_project.absolute() - - missing, modified, invalid, valid = _manifest_file_status( - manifest, - symlinked_project.absolute(), - project_root_is_resolved=False, - ) - - assert missing == [] - assert modified == [] - assert invalid == [] - assert valid == ["tracked.md"] - - def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch): - from specify_cli.integration_status import _manifest_file_status - from specify_cli.integrations.manifest import IntegrationManifest - - project = tmp_path / "proj" - project.mkdir() - tracked = project / "tracked.md" - tracked.write_text("content\n", encoding="utf-8") - manifest = IntegrationManifest("test", project, version="test") - manifest.record_existing("tracked.md") - project_root_resolved = project.resolve() - original_resolve = Path.resolve - - def fail_project_parent_resolve(self, *args, **kwargs): - if self == project: - raise RuntimeError("symlink loop") - return original_resolve(self, *args, **kwargs) - - monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve) - - missing, modified, invalid, valid = _manifest_file_status( - manifest, - project_root_resolved, - ) - - assert missing == [] - assert modified == [] - assert invalid == ["tracked.md"] - assert valid == [] - - def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch): - from specify_cli import integration_status as status_module - - def fail_load(key, project_root, **kwargs): - raise RuntimeError(f"unexpected manifest loader bug for {key}") - - monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load) - - with pytest.raises(RuntimeError, match="unexpected manifest loader bug"): - status_module.build_integration_status_report(copilot_project) - - def test_status_treats_dangling_symlink_as_missing(self, copilot_project): - manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" - tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] - first_rel = next(iter(tracked_files)) - target = copilot_project / first_rel - target.unlink() - try: - target.symlink_to(copilot_project / "missing-target") - except OSError as exc: - pytest.skip(f"symlinks unavailable: {exc}") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert first_rel in payload["manifests"]["copilot"]["missing_files"] - assert first_rel not in payload["manifests"]["copilot"]["modified_files"] - - def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch): - from specify_cli.integration_status import _manifest_file_status - from specify_cli.integrations.manifest import IntegrationManifest - - project = tmp_path / "proj" - project.mkdir() - tracked = project / "tracked.md" - tracked.write_text("content\n", encoding="utf-8") - regular_stat = tracked.lstat() - - manifest = IntegrationManifest("test", project, version="test") - manifest.record_existing("tracked.md") - - tracked.unlink() - try: - tracked.symlink_to(project / "missing-target") - except OSError as exc: - pytest.skip(f"symlinks unavailable: {exc}") - - original_lstat = Path.lstat - original_is_symlink = Path.is_symlink - - def windows_style_lstat(self): - if self == tracked: - return regular_stat - return original_lstat(self) - - def windows_style_is_symlink(self): - if self == tracked: - return True - return original_is_symlink(self) - - monkeypatch.setattr(Path, "lstat", windows_style_lstat) - monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink) - - missing, modified, invalid, valid = _manifest_file_status( - manifest, - project.resolve(), - ) - - assert missing == ["tracked.md"] - assert modified == [] - assert invalid == [] - assert valid == ["tracked.md"] - - def test_strip_extended_length_prefix_normalizes_windows_paths(self): - from specify_cli.integration_status import _strip_extended_length_prefix - - # Build the prefixed strings explicitly so the test is meaningful on - # every platform (POSIX won't parse backslash separators, but the - # helper operates on the string form). Compare Path objects rather than - # their str() form: on Windows pathlib renders a UNC root with a - # trailing separator (``\\server\share\``), so an exact string match is - # brittle, whereas Path equality captures the intended semantics on - # both POSIX and Windows. - bs = "\\" - assert _strip_extended_length_prefix( - Path(f"{bs}{bs}?{bs}C:{bs}proj") - ) == Path(f"C:{bs}proj") - assert _strip_extended_length_prefix( - Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share") - ) == Path(f"{bs}{bs}server{bs}share") - # Paths without the prefix are returned unchanged. - assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path") - - def test_is_within_project_tolerates_extended_length_prefix(self): - from specify_cli.integration_status import _is_within_project - - # A readlink result on POSIX never carries the prefix, so an in-project - # child is contained and an outside path is not. The Windows - # prefix-stripping branch is exercised by the dangling-symlink tests on - # Windows CI; here we lock in the cross-platform containment contract. - root = Path("/tmp/project").resolve() - assert _is_within_project(root, root / "child") - assert not _is_within_project(root, Path("/tmp/other").resolve()) - - def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project): - outside = tmp_path / "outside" - outside.mkdir() - (outside / "secret.txt").write_text("outside project\n", encoding="utf-8") - link = copilot_project / "outside-link" - try: - link.symlink_to(outside, target_is_directory=True) - except OSError as exc: - pytest.skip(f"symlinks unavailable: {exc}") - - manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - manifest_data["files"]["outside-link/secret.txt"] = "wrong" - manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["invalid_manifest_paths"] == 1 - assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"] - assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"] - - def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch): - outside = tmp_path / "outside" - outside.mkdir() - outside_file = outside / "secret.txt" - outside_file.write_text("outside project\n", encoding="utf-8") - - manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" - tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] - first_rel = next(iter(tracked_files)) - tracked_path = copilot_project / first_rel - tracked_path.unlink() - try: - tracked_path.symlink_to(outside_file) - except OSError as exc: - pytest.skip(f"symlinks unavailable: {exc}") - - original_stat = Path.stat - - def fail_tracked_symlink_stat(self, *args, **kwargs): - follows_symlinks = kwargs.get("follow_symlinks", True) - if self == tracked_path and follows_symlinks: - raise AssertionError("Path.stat() should not follow tracked symlinks") - return original_stat(self, *args, **kwargs) - - monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat) - - result = _run_in_project(copilot_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["invalid_manifest_paths"] == 1 - assert first_rel in payload["manifests"]["copilot"]["invalid_files"] - assert first_rel not in payload["manifests"]["copilot"]["modified_files"] - - def test_status_reports_unsafe_multi_install_combination(self, copilot_project): - from specify_cli.integrations.manifest import IntegrationManifest - - state_path = copilot_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["installed_integrations"] = ["copilot", "claude"] - state["default_integration"] = "copilot" - state["integration"] = "copilot" - state_path.write_text(json.dumps(state), encoding="utf-8") - IntegrationManifest("claude", copilot_project, version="test").save() - - result = _run_in_project(copilot_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "unsafe-multi-install" in result.output - assert "Multi-install safe: no" in result.output - assert "specify integration switch " in result.output - - def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project): - from specify_cli.integrations.manifest import IntegrationManifest - - state_path = claude_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["installed_integrations"] = ["claude", "mystery"] - state["default_integration"] = "claude" - state["integration"] = "claude" - state_path.write_text(json.dumps(state), encoding="utf-8") - IntegrationManifest("mystery", claude_project, version="test").save() - - result = _run_in_project(claude_project, ["integration", "status"]) - - assert result.exit_code != 0 - assert "unknown-integration" in result.output - assert "unsafe-multi-install" in result.output - assert "remove the stale integration entry" in result.output - assert "Multi-install safe: no" in result.output - - def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project): - state_path = claude_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["installed_integrations"] = ["mystery"] - state["default_integration"] = "mystery" - state["integration"] = "mystery" - state_path.write_text(json.dumps(state), encoding="utf-8") - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - manifest_finding = next( - item for item in payload["findings"] - if item["code"] == "manifest-missing" and item["integration"] == "mystery" - ) - assert "remove the stale integration entry" in manifest_finding["suggestion"] - assert "integration upgrade mystery" not in manifest_finding["suggestion"] - - def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project): - state_path = claude_project / ".specify" / "integration.json" - unsafe_key = "../../../escape" - state_path.write_text( - json.dumps({ - "integration": unsafe_key, - "default_integration": unsafe_key, - "installed_integrations": [unsafe_key], - }), - encoding="utf-8", - ) - outside_manifest = tmp_path / "escape.manifest.json" - outside_manifest.write_text( - json.dumps({"integration": unsafe_key, "files": {}}), - encoding="utf-8", - ) - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert unsafe_key not in payload["manifests"] - assert payload["manifest_checked_integrations"] == ["speckit"] - assert any( - item["code"] == "integration-key-invalid" - and item["integration"] == unsafe_key - for item in payload["findings"] - ) - - def test_status_rejects_filename_invalid_integration_keys(self, claude_project): - state_path = claude_project / ".specify" / "integration.json" - unsafe_key = "bad:key" - state_path.write_text( - json.dumps({ - "integration": unsafe_key, - "default_integration": unsafe_key, - "installed_integrations": [unsafe_key], - }), - encoding="utf-8", - ) - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert any( - item["code"] == "integration-key-invalid" - and item["integration"] == unsafe_key - for item in payload["findings"] - ) - - def test_status_rejects_windows_reserved_integration_keys(self, claude_project): - state_path = claude_project / ".specify" / "integration.json" - unsafe_key = "CON" - state_path.write_text( - json.dumps({ - "integration": unsafe_key, - "default_integration": unsafe_key, - "installed_integrations": [unsafe_key], - }), - encoding="utf-8", - ) - - result = _run_in_project(claude_project, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert any( - item["code"] == "integration-key-invalid" - and item["integration"] == unsafe_key - for item in payload["findings"] - ) - - def test_status_reports_managed_file_collisions(self, claude_project): - from specify_cli.integrations.manifest import IntegrationManifest - - state_path = claude_project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - state["installed_integrations"] = ["claude", "codex"] - state["default_integration"] = "claude" - state["integration"] = "claude" - state_path.write_text(json.dumps(state), encoding="utf-8") - - claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json" - tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"] - shared_rel = next(iter(tracked_files)) - codex_manifest = IntegrationManifest("codex", claude_project, version="test") - codex_manifest.record_existing(shared_rel) - codex_manifest.save() - - result = _run_in_project(claude_project, ["integration", "status"]) - - assert result.exit_code == 0 - assert "managed-file-collision" in result.output - assert "Integration status: WARNING" in result.output - - def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch): - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - (project / ".specify" / "integration.json").write_text( - json.dumps({ - "integration": "[red]x[/red]", - "installed_integrations": ["[red]x[/red]"], - }), - encoding="utf-8", - ) - monkeypatch.chdir(project) - - result = runner.invoke(app, ["integration", "status", "--json"]) - - assert result.exit_code != 0 - payload = json.loads(result.output) - assert payload["default_integration"] == "[red]x[/red]" - assert payload["installed_integrations"] == ["[red]x[/red]"] - - def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch): - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - (project / ".specify" / "integration.json").write_text( - json.dumps({ - "integration": "[red]x[/red]", - "installed_integrations": ["[red]x[/red]"], - }), - encoding="utf-8", - ) - monkeypatch.chdir(project) - - result = runner.invoke(app, ["integration", "status"]) - - assert result.exit_code != 0 - assert "Default integration: [red]x[/red]" in result.output - assert "Installed integrations: [red]x[/red]" in result.output - - -# ── install ────────────────────────────────────────────────────────── - - -class TestIntegrationInstall: - def test_install_requires_speckit_project(self, tmp_path): - old_cwd = os.getcwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["integration", "install", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Not a Spec Kit project" in result.output - - def test_install_unknown_integration(self, tmp_path): - project = _init_project(tmp_path) - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "install", "nonexistent"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Unknown integration" in result.output - - def test_install_already_installed(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "install", "copilot"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - plain = strip_ansi(result.output) - assert "already installed" in plain - normalized = " ".join(plain.split()) - assert "specify integration upgrade copilot" in normalized - assert "already the default integration" in normalized - assert "No files were changed" in normalized - assert "specify integration uninstall copilot" not in normalized - - def test_install_already_installed_non_default_guides_use(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, ["integration", "install", "codex"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - output = strip_ansi(result.output) - normalized = " ".join(output.split()) - assert "already installed" in normalized - assert "specify integration use codex" in normalized - assert "specify integration upgrade codex" in normalized - assert "specify integration uninstall codex" not in normalized - - def test_install_different_when_one_exists(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "install", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - plain = strip_ansi(result.output) - assert "Installed integrations: copilot" in plain - assert "Default integration: copilot" in plain - normalized = " ".join(plain.split()) - assert "To replace the default integration" in normalized - assert "specify integration switch claude" in normalized - assert "To install 'claude' alongside" in normalized - assert "retry the same install command with --force" in normalized - - def test_install_multi_safe_integration(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - assert "installed successfully" in result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "claude" - assert data["default_integration"] == "claude" - assert data["integration_state_schema"] == 1 - assert data["installed_integrations"] == ["claude", "codex"] - assert data["integration_settings"]["claude"]["invoke_separator"] == "-" - assert data["integration_settings"]["codex"]["invoke_separator"] == "-" - - assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() - - def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch): - project = _init_project(tmp_path, "claude") - init_options = project / ".specify" / "init-options.json" - opts = json.loads(init_options.read_text(encoding="utf-8")) - opts["speckit_version"] = "0.6.1" - init_options.write_text(json.dumps(opts), encoding="utf-8") - - import specify_cli.integrations._commands as _int_cmds - - monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - - assert result.exit_code == 0, result.output - updated = json.loads(init_options.read_text(encoding="utf-8")) - assert updated["speckit_version"] == "0.8.11" - assert updated["integration"] == "claude" - assert updated["ai"] == "claude" - assert "context_file" not in updated - - def test_install_additional_preserves_shared_manifest(self, tmp_path): - project = _init_project(tmp_path, "claude") - shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" - before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) - assert before - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) - assert before <= after - - def test_install_multi_safe_migrates_legacy_state(self, tmp_path): - project = _init_project(tmp_path, "claude") - int_json = project / ".specify" / "integration.json" - int_json.write_text(json.dumps({ - "integration": "claude", - "version": "0.0.0", - }), encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - data = json.loads(int_json.read_text(encoding="utf-8")) - assert data["integration"] == "claude" - assert data["default_integration"] == "claude" - assert data["installed_integrations"] == ["claude", "codex"] - - def test_install_multi_unsafe_requires_force(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - plain = strip_ansi(result.output) - assert "Installed integrations: copilot" in plain - assert "multi-install safe" in plain - normalized = " ".join(plain.split()) - assert "To replace the default integration" in normalized - assert "specify integration switch claude" in normalized - assert "To install 'claude' alongside" in normalized - assert "retry the same install command with --force" in normalized - - def test_install_multi_unsafe_allowed_with_force(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - "--force", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "copilot" - assert data["installed_integrations"] == ["copilot", "claude"] - - def test_install_into_bare_project(self, tmp_path): - """Install into a project with .specify/ but no integration.""" - project = tmp_path / "bare" - project.mkdir() - (project / ".specify").mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - assert "installed successfully" in result.output - - # integration.json written - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "claude" - - # Manifest created - assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() - - # Claude uses skills directory (not commands) - assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - - def test_install_bare_project_gets_shared_infra(self, tmp_path): - """Installing into a bare project should create shared scripts and templates.""" - project = tmp_path / "bare" - project.mkdir() - (project / ".specify").mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - # Shared infrastructure should be present - assert (project / ".specify" / "scripts").is_dir() - assert (project / ".specify" / "templates").is_dir() - script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - script_content = script.read_text(encoding="utf-8") - assert "/speckit-specify" in script_content - assert "/speckit.specify" not in script_content - - def test_install_dollar_skill_into_bare_project_gets_native_shared_refs( - self, tmp_path - ): - """A dollar-style integration supplies its prefix without a default.""" - project = tmp_path / "bare-codex" - project.mkdir() - (project / ".specify").mkdir() - - result = _run_in_project( - project, ["integration", "install", "codex", "--script", "sh"] - ) - - assert result.exit_code == 0, result.output - plan = project / ".specify" / "templates" / "plan-template.md" - plan_content = plan.read_text(encoding="utf-8") - assert "$speckit-plan" in plan_content - assert "/speckit-plan" not in plan_content - - def test_install_defers_extension_commands_until_use(self, tmp_path): - """Installing a second integration does not register enabled extensions. - - Maintainer-requested behavior for #2886: extension command back-fill is - limited to ``integration use`` / ``switch`` / ``upgrade``. Plain - ``install`` only adds the integration; selecting it with ``use`` then - registers the enabled extensions for that agent. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "claude" in registered - assert "codex" not in registered, "precondition: codex not yet installed" - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - # Install alone does not back-fill the git extension for the secondary - # agent. - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "claude" in registered, "existing agent registration preserved" - assert "codex" not in registered - assert not ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - result = _run_in_project(project, ["integration", "use", "codex"]) - assert result.exit_code == 0, result.output - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" in registered, "use should register extension commands (#2886)" - assert ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - def test_install_does_not_register_disabled_extensions(self, tmp_path): - """A disabled extension must not be registered for a newly installed agent.""" - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - result = _run_in_project(project, ["extension", "disable", "git"]) - assert result.exit_code == 0, result.output - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - registry_path = project / ".specify" / "extensions" / ".registry" - git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"] - assert git_meta["enabled"] is False - assert "codex" not in git_meta["registered_commands"] - assert not ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path): - """A non-active skills-mode agent gets extension artifacts only on use. - - Plain ``install`` has no extension side effects. Once the secondary - Copilot ``--skills`` integration is selected with ``use``, it becomes the - active agent and receives extension skills. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - # Copilot is not multi_install_safe, so --force is required to add it - # alongside the existing default integration. - result = _run_in_project(project, [ - "integration", "install", "copilot", - "--script", "sh", - "--integration-options", "--skills", - "--force", - ]) - assert result.exit_code == 0, result.output - - # Precondition that makes --skills load-bearing: copilot IS in skills - # mode, so its own core commands are scaffolded as skills. - assert ( - project / ".github" / "skills" / "speckit-specify" / "SKILL.md" - ).exists(), "precondition: copilot installed in skills mode" - - # The git extension is not registered for the non-active copilot agent - # during install. - git_meta = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - )["extensions"]["git"] - assert "copilot" not in git_meta["registered_commands"] - assert not ( - project / ".github" / "agents" / "speckit.git.feature.agent.md" - ).exists() - assert not ( - project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - result = _run_in_project(project, ["integration", "use", "copilot"]) - assert result.exit_code == 0, result.output - - git_meta = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - )["extensions"]["git"] - # `use` makes copilot active, so extension artifacts follow copilot's - # skills-mode layout. - assert "copilot" not in git_meta["registered_commands"] - assert "speckit-git-feature" in git_meta["registered_skills"] - assert not ( - project / ".github" / "agents" / "speckit.git.feature.agent.md" - ).exists() - assert ( - project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - def test_extension_add_registers_active_integration_only(self, tmp_path): - """``extension add`` registers commands for the active integration only. - - Maintainer-requested behavior for #2948: with multiple integrations - installed, ``extension add`` must treat the project as single-active — - only the current integration gets the new extension's commands. - Non-active integrations receive them when selected via - ``integration use`` / ``switch`` (rescaffold). - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "claude" in registered, "active integration gets the extension" - assert "codex" not in registered, ( - "non-active integration must not be registered on add (#2948)" - ) - assert ( - project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - assert not ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - # Selecting the other integration rescaffolds it with the extension. - result = _run_in_project(project, ["integration", "use", "codex"]) - assert result.exit_code == 0, result.output - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" in registered, "use registers extensions for the new active agent" - assert ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - def test_extension_add_generic_active_does_not_backfill_other_agents(self, tmp_path): - """A recorded but unsupported active key (``generic``) must not - fall back to registering every detected agent. - - ``generic`` is deliberately excluded from ``AGENT_CONFIGS`` because - its output directory is only known via ``--commands-dir``, not a - static config. Before the fix, treating that active key like "no - active integration recorded" made the fallback register the - extension for every other detected agent — exactly the multi-target - behavior #2948 is meant to stop. - """ - project = _init_project( - tmp_path, "generic", - integration_options="--commands-dir .myagent/commands", - ) - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, result.output - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" not in registered, ( - "a recorded but unsupported active key must not target other " - "detected agents (#2948)" - ) - - def test_extension_add_malformed_ai_value_fails_closed(self, tmp_path): - """A recorded but malformed ``ai`` value (e.g. a list) must not be - treated as "no active integration recorded" and must not crash. - - Before the fix, ``init_options.get("ai")`` being falsy (``[]``, - ``""``, ``0``) triggered the same all-agents fallback as a missing - key, and a *truthy* non-string value (e.g. a non-empty list) would - reach ``AGENT_CONFIGS.get(active_agent)`` and raise ``TypeError`` - because a list is unhashable. Corrupted init-options must instead - fail closed: register nothing rather than crash or back-fill every - detected agent. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - init_options_path = project / ".specify" / "init-options.json" - init_options = json.loads(init_options_path.read_text(encoding="utf-8")) - init_options["ai"] = [] - init_options_path.write_text(json.dumps(init_options), encoding="utf-8") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert registered == {}, ( - "a malformed recorded 'ai' value must fail closed, not " - "back-fill every detected agent (#2948)" - ) - - def test_extension_add_corrupted_init_options_file_fails_closed(self, tmp_path): - """A present-but-unparseable init-options.json must fail closed too, - not be treated the same as "no file at all". - - ``load_init_options`` returns ``{}`` for a corrupted/unreadable - file just like it does for a missing file, so a naive "no active - agent recorded" check based on ``load_init_options`` alone can't - tell a legacy pre-init-options project (legitimate all-agent - fallback) apart from a corrupted-but-present file for a #2948 - project (must fail closed). Corrupting the file after a normal - init must not reintroduce the all-agent fallback. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - init_options_path = project / ".specify" / "init-options.json" - init_options_path.write_text("{not valid json", encoding="utf-8") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert registered == {}, ( - "a corrupted init-options.json must fail closed, not be " - "treated like a legacy project missing the file entirely (#2948)" - ) - - def test_extension_add_dangling_init_options_symlink_fails_closed(self, tmp_path): - """A dangling init-options.json symlink must fail closed too, not be - treated the same as "no file at all". - - ``Path.exists()`` follows symlinks and returns False for a broken - symlink whose target doesn't exist, so a naive presence check based - on ``Path.exists()`` alone mistakes a dangling symlink for "no file" - and falls back to registering every detected agent. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - init_options_path = project / ".specify" / "init-options.json" - init_options_path.unlink() - init_options_path.symlink_to(project / ".specify" / "does-not-exist.json") - assert not init_options_path.exists() # sanity: dangling - assert init_options_path.is_symlink() - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert registered == {}, ( - "a dangling init-options.json symlink must fail closed, not be " - "treated like a legacy project missing the file entirely (#2948)" - ) - - -# ── uninstall ──────────────────────────────────────────────────────── - - -class TestIntegrationUninstall: - def test_uninstall_requires_speckit_project(self, tmp_path): - old_cwd = os.getcwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["integration", "uninstall"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Not a Spec Kit project" in result.output - - def test_uninstall_no_integration(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "No integration" in result.output - - def test_uninstall_removes_files(self, tmp_path): - project = _init_project(tmp_path, "claude") - # Claude uses skills directory - assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "uninstalled" in result.output - - # Command files removed - assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - - # Manifest removed - assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists() - - # integration.json removed - assert not (project / ".specify" / "integration.json").exists() - - def test_uninstall_preserves_modified_files(self, tmp_path): - """Full lifecycle: install → modify → uninstall → modified file kept.""" - project = _init_project(tmp_path, "claude") - plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" - assert plan_file.exists() - - # Modify a file - plan_file.write_text("# My custom plan command\n", encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "preserved" in result.output - assert ".claude/skills/speckit-plan/SKILL.md" in result.output - - # Modified file kept - assert plan_file.exists() - assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n" - - def test_uninstall_wrong_key(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "not installed" in result.output - - def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path): - project = _init_project(tmp_path, "claude") - _write_invalid_manifest(project, "claude") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "manifest" in result.output - assert "unreadable" in result.output - - def test_uninstall_non_default_preserves_default(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, [ - "integration", "uninstall", "codex", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "claude" - assert data["installed_integrations"] == ["claude"] - - def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): - project = _init_project(tmp_path, "gemini") - template = project / ".specify" / "templates" / "plan-template.md" - script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - assert "/speckit.plan" in template.read_text(encoding="utf-8") - assert "/speckit.plan" in script.read_text(encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "claude" - assert "/speckit-plan" in template.read_text(encoding="utf-8") - assert "/speckit-plan" in script.read_text(encoding="utf-8") - - def test_uninstall_preserves_shared_infra(self, tmp_path): - """Shared scripts and templates are not removed by integration uninstall.""" - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" - assert shared_script.exists() - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - - # Shared infrastructure preserved - assert shared_script.exists() - assert (project / ".specify" / "templates").is_dir() - - -class TestIntegrationUse: - def test_use_installed_integration_sets_default(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "codex" - assert data["default_integration"] == "codex" - assert data["installed_integrations"] == ["claude", "codex"] - - opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) - assert opts["integration"] == "codex" - assert opts["ai"] == "codex" - - def test_use_preserves_copilot_skills_mode(self, tmp_path): - """`use` on a skills-mode Copilot keeps ``ai_skills`` (issue #3550). - - Re-selecting the same skills-mode Copilot must not drop ``ai_skills`` - from init-options.json nor regenerate extension commands in the legacy - ``.agent.md``/``.prompt.md`` layout. - """ - project = _init_project(tmp_path, "copilot", integration_options="--skills") - - opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) - assert opts.get("ai_skills") is True, "precondition: init recorded skills mode" - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - # Simulate a fresh process: `use` in real life runs in its own process - # where the registry's Copilot instance has _skills_mode == False (it is - # only set during setup()). In-process test invocations otherwise reuse - # the singleton left in skills mode by init, masking the bug (#3550). - from specify_cli.integrations import get_integration - - get_integration("copilot")._skills_mode = False - - result = _run_in_project(project, ["integration", "use", "copilot"]) - assert result.exit_code == 0, result.output - - opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) - assert opts.get("ai_skills") is True, "ai_skills must survive `use copilot`" - - # No legacy command-layout files should be regenerated for the - # skills-mode agent. - assert not (project / ".github" / "agents" / "speckit.git.feature.agent.md").exists() - assert not (project / ".github" / "prompts" / "speckit.git.feature.prompt.md").exists() - assert ( - project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - def test_use_requires_installed_integration(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "use", "codex"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "not installed" in result.output - - def test_use_registers_presets_for_the_newly_active_agent(self, tmp_path): - """``integration use`` is the single rescaffold point for presets too. - - Mirrors the extension single-active rule (#2948): a preset command - override installed while ``claude`` was active must not target the - inactive ``codex`` integration, and switching via ``integration use`` - must rescaffold it there. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - preset_src = tmp_path / "cmd-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.specify.md").write_text( - "---\ndescription: Overridden specify\n---\nOverridden content\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "cmd-preset", - "name": "Command Preset", - "version": "1.0.0", - "description": "Test preset with a command override", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - } - ] - }, - } - import yaml - - (preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") - - result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) - assert result.exit_code == 0, f"preset add failed: {result.output}" - - registry_path = project / ".specify" / "presets" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "presets" - ]["cmd-preset"]["registered_commands"] - assert "claude" in registered, "active integration gets the preset command override" - assert "codex" not in registered, ( - "non-active integration must not be registered on preset add (#2948)" - ) - - result = _run_in_project(project, ["integration", "use", "codex"]) - assert result.exit_code == 0, result.output - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "presets" - ]["cmd-preset"]["registered_commands"] - assert "codex" in registered, "use registers presets for the new active agent" - assert "claude" in registered, "the previous agent's registration is preserved" - - def test_use_reregisters_presets_highest_precedence_last(self, tmp_path): - """When two enabled presets override the same command, the - higher-precedence preset (lower priority number) must win the - materialized file after ``integration use`` rescaffolds them. - - ``register_enabled_presets_for_agent`` iterates presets and each - pass overwrites the same target file, so the write order matters. - Before the fix, presets were processed lowest-number-first (highest - precedence first), so the lower-precedence preset was written last - and won -- reversing the documented priority stack (#2948). - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - import yaml - - def _make_preset(pack_id: str, content: str) -> Path: - src = tmp_path / pack_id - (src / "commands").mkdir(parents=True) - (src / "commands" / "speckit.specify.md").write_text( - f"---\ndescription: {pack_id}\n---\n{content}\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": f"Test preset {pack_id}", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - } - ] - }, - } - (src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") - return src - - # Lower-precedence preset (higher priority number), installed first. - low_precedence_src = _make_preset("low-precedence-preset", "LOW PRECEDENCE CONTENT") - result = _run_in_project(project, [ - "preset", "add", "--dev", str(low_precedence_src), "--priority", "20", - ]) - assert result.exit_code == 0, f"preset add (low) failed: {result.output}" - - # Higher-precedence preset (lower priority number), installed second. - high_precedence_src = _make_preset("high-precedence-preset", "HIGH PRECEDENCE CONTENT") - result = _run_in_project(project, [ - "preset", "add", "--dev", str(high_precedence_src), "--priority", "1", - ]) - assert result.exit_code == 0, f"preset add (high) failed: {result.output}" - - # Sanity: the priority stack already picks the high-precedence - # preset's content for the active (claude) integration. - claude_skill = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - assert "HIGH PRECEDENCE CONTENT" in claude_skill.read_text(encoding="utf-8") - assert "LOW PRECEDENCE CONTENT" not in claude_skill.read_text(encoding="utf-8") - - result = _run_in_project(project, ["integration", "use", "codex"]) - assert result.exit_code == 0, result.output - - # After rescaffolding for the newly active codex integration, the - # high-precedence preset must still win -- not whichever preset - # register_enabled_presets_for_agent happened to write last. - codex_skill = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md" - content = codex_skill.read_text(encoding="utf-8") - assert "HIGH PRECEDENCE CONTENT" in content, ( - "highest-precedence preset must win after `use` rescaffolds " - "presets for the newly active integration (#2948)" - ) - assert "LOW PRECEDENCE CONTENT" not in content - - def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): - project = _init_project(tmp_path, "claude") - template = project / ".specify" / "templates" / "plan-template.md" - script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - assert "/speckit-plan" in template.read_text(encoding="utf-8") - assert "/speckit-plan" in script.read_text(encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "gemini", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) - assert use_gemini.exit_code == 0, use_gemini.output - assert "/speckit.plan" in template.read_text(encoding="utf-8") - assert "/speckit.plan" in script.read_text(encoding="utf-8") - assert "/speckit-plan" not in script.read_text(encoding="utf-8") - - use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False) - assert use_claude.exit_code == 0, use_claude.output - assert "/speckit-plan" in template.read_text(encoding="utf-8") - assert "/speckit-plan" in script.read_text(encoding="utf-8") - assert "/speckit.plan" not in script.read_text(encoding="utf-8") - finally: - os.chdir(old_cwd) - - def test_use_preserves_modified_templates_unless_forced(self, tmp_path): - project = _init_project(tmp_path, "claude") - template = project / ".specify" / "templates" / "plan-template.md" - template.write_text("custom template with /speckit-plan\n", encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "gemini", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) - assert use_gemini.exit_code == 0, use_gemini.output - normalized = " ".join(use_gemini.output.split()) - assert "specify integration use gemini --force" in normalized - assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n" - - force_use = runner.invoke(app, [ - "integration", "use", "gemini", - "--force", - ], catch_exceptions=False) - assert force_use.exit_code == 0, force_use.output - finally: - os.chdir(old_cwd) - - updated = template.read_text(encoding="utf-8") - assert "/speckit.plan" in updated - assert "custom template" not in updated - - def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): - project = _init_project(tmp_path, "claude") - int_json = project / ".specify" / "integration.json" - init_options = project / ".specify" / "init-options.json" - - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - before_state = json.loads(int_json.read_text(encoding="utf-8")) - before_options = json.loads(init_options.read_text(encoding="utf-8")) - import specify_cli - - def fail_refresh(*args, **kwargs): - raise ValueError("refuse refresh") - - monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) - - result = runner.invoke(app, [ - "integration", "use", "codex", - "--force", - ]) - finally: - os.chdir(old_cwd) - - assert result.exit_code != 0 - assert "Failed to refresh shared infrastructure" in result.output - assert json.loads(int_json.read_text(encoding="utf-8")) == before_state - assert json.loads(init_options.read_text(encoding="utf-8")) == before_options - - -# ── switch ─────────────────────────────────────────────────────────── - - -class TestIntegrationSwitch: - def test_switch_requires_speckit_project(self, tmp_path): - old_cwd = os.getcwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["integration", "switch", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Not a Spec Kit project" in result.output - - def test_switch_unknown_target(self, tmp_path): - project = _init_project(tmp_path) - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "switch", "nonexistent"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Unknown integration" in result.output - - def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path): - project = _init_project(tmp_path, "claude") - _write_invalid_manifest(project, "claude") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "codex", - "--script", "sh", - ]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Could not read integration manifest" in result.output - - def test_switch_same_noop(self, tmp_path): - project = _init_project(tmp_path, "copilot") - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "switch", "copilot"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "already the default integration" in result.output - - def test_switch_same_force_refreshes_shared_templates(self, tmp_path): - project = _init_project(tmp_path, "claude") - template = project / ".specify" / "templates" / "plan-template.md" - script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - template.write_text("# custom shared template\n", encoding="utf-8") - script.write_text("# custom shared script\n", encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "claude", - "--force", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - assert "shared infrastructure refreshed" in result.output - assert "managed shared infrastructure refreshed" not in result.output - assert "/speckit-plan" in template.read_text(encoding="utf-8") - assert "/speckit-plan" in script.read_text(encoding="utf-8") - - def test_switch_installed_target_rejects_integration_options(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, [ - "integration", "switch", "codex", - "--integration-options", "--bogus", - ]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "--integration-options cannot be used" in result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["default_integration"] == "claude" - - def test_switch_between_integrations(self, tmp_path): - project = _init_project(tmp_path, "claude") - # Verify claude files exist (claude uses skills) - assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - assert "/speckit-specify" in shared_script.read_text(encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - assert "Switched to" in result.output - - # Old claude files removed - assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - - # New default Copilot skills created - assert ( - project / ".github" / "skills" / "speckit-plan" / "SKILL.md" - ).exists() - assert "/speckit-specify" in shared_script.read_text(encoding="utf-8") - assert "/speckit.specify" not in shared_script.read_text(encoding="utf-8") - - # integration.json updated - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "copilot" - - def test_switch_rejects_conflicting_copilot_modes_before_uninstall( - self, tmp_path - ): - project = _init_project(tmp_path, "claude") - claude_skill = ( - project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" - ) - before_state = json.loads( - (project / ".specify" / "integration.json").read_text( - encoding="utf-8" - ) - ) - - result = _run_in_project( - project, - [ - "integration", - "switch", - "copilot", - "--integration-options", - "--skills --commands", - "--script", - "sh", - ], - ) - - assert result.exit_code == 1 - assert "--skills and --commands are mutually exclusive" in result.output - assert claude_skill.exists() - assert not (project / ".github" / "skills").exists() - assert not (project / ".github" / "agents").exists() - after_state = json.loads( - (project / ".specify" / "integration.json").read_text( - encoding="utf-8" - ) - ) - assert after_state == before_state - - def test_switch_preserves_target_options_with_fallback_integration( - self, tmp_path - ): - project = _init_project(tmp_path, "claude") - install = _run_in_project( - project, - [ - "integration", - "install", - "opencode", - "--script", - "sh", - "--force", - ], - ) - assert install.exit_code == 0, install.output - - result = _run_in_project( - project, - [ - "integration", - "switch", - "copilot", - "--integration-options", - "--commands", - "--script", - "sh", - ], - ) - - assert result.exit_code == 0, result.output - assert ( - project / ".github" / "agents" / "speckit.plan.agent.md" - ).exists() - assert not (project / ".github" / "skills").exists() - state = json.loads( - (project / ".specify" / "integration.json").read_text( - encoding="utf-8" - ) - ) - assert state["integration_settings"]["copilot"]["parsed_options"] == { - "commands": True - } - - def test_switch_migrates_extension_commands(self, tmp_path): - """Switching should migrate extension commands to the new agent directory.""" - project = _init_project(tmp_path, "kimi") - - # Install the bundled git extension - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - # Verify git extension skills exist for kimi - kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md" - assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" - - result = _run_in_project(project, [ - "integration", "switch", "opencode", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - # Git extension commands should exist for opencode - opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" - assert opencode_git_feature.exists(), "Git extension command should exist for opencode" - - # Old kimi extension skills should be removed - assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed" - - # Extension registry should be updated - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - ) - registered_commands = registry["extensions"]["git"]["registered_commands"] - assert "opencode" in registered_commands - assert "kimi" not in registered_commands - - # Switch to claude - result = _run_in_project(project, [ - "integration", "switch", "claude", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - # Git extension skills should exist for claude - claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" - assert claude_git_feature.exists(), "Git extension skill should exist for claude" - - # Old opencode extension commands should be removed - assert not opencode_git_feature.exists(), "Old opencode extension command should be removed" - - # Extension registry should be updated - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - ) - registered_commands = registry["extensions"]["git"]["registered_commands"] - assert "claude" in registered_commands - assert "opencode" not in registered_commands - - def test_switch_installed_target_backfills_extension_commands(self, tmp_path): - """Switching to an already-installed agent should register extensions.""" - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "claude" in registered - assert "codex" not in registered, "precondition: codex not yet installed" - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - codex_git_feature = ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ) - assert not codex_git_feature.exists() - - result = _run_in_project(project, ["integration", "switch", "codex"]) - assert result.exit_code == 0, result.output - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" in registered - assert codex_git_feature.exists() - - def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): - """Copilot --skills should receive extension skills, not .agent.md files.""" - project = _init_project(tmp_path, "opencode") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - result = _run_in_project(project, [ - "integration", "switch", "copilot", - "--script", "sh", - "--integration-options", "--skills", - ]) - assert result.exit_code == 0, result.output - - copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md" - assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode" - assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files" - - # Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it - skill_content = copilot_git_feature.read_text(encoding="utf-8") - assert "mode:" not in skill_content, ( - "Copilot skill frontmatter must not contain unsupported 'mode' field" - ) - - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - ) - git_meta = registry["extensions"]["git"] - assert "speckit-git-feature" in git_meta["registered_skills"] - assert "copilot" not in git_meta["registered_commands"] - - result = _run_in_project(project, [ - "integration", "switch", "opencode", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" - assert opencode_git_feature.exists(), "Git extension command should exist for opencode" - assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed" - - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - ) - git_meta = registry["extensions"]["git"] - assert git_meta["registered_skills"] == [] - assert "opencode" in git_meta["registered_commands"] - assert "copilot" not in git_meta["registered_commands"] - - def test_switch_to_not_yet_installed_unregisters_old_preset_artifacts(self, tmp_path): - """Switching to a not-yet-installed integration must also clean up - the old agent's preset command overrides, mirroring the existing - extension cleanup on the same code path (#2948). - - Without this, a preset's command override -- including a custom - preset command -- rendered for the previous agent lingers as an - orphan once a different, not-yet-installed integration becomes the - new active agent. - """ - project = _init_project(tmp_path, "auggie") - - preset_src = tmp_path / "switch-cleanup-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.specify.md").write_text( - "---\ndescription: Custom preset command\n---\nOverridden content\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "switch-cleanup-preset", - "name": "Switch Cleanup Preset", - "version": "1.0.0", - "description": "Test preset with a custom command override", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.specify", - "file": "commands/speckit.specify.md", - } - ] - }, - } - import yaml - - (preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") - - result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) - assert result.exit_code == 0, f"preset add failed: {result.output}" - - auggie_cmd = project / ".augment" / "commands" / "speckit.specify.md" - assert auggie_cmd.exists(), "sanity: preset command registered for auggie" - - registry_path = project / ".specify" / "presets" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "presets" - ]["switch-cleanup-preset"]["registered_commands"] - assert "auggie" in registered, "sanity: auggie tracked before switch" - - # opencode is not yet installed in this project. - result = _run_in_project(project, [ - "integration", "switch", "opencode", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - assert not auggie_cmd.exists(), ( - "old agent's preset command override must be removed on switch " - "to a not-yet-installed integration, mirroring the existing " - "extension cleanup on this same code path (#2948)" - ) - - opencode_cmd = project / ".opencode" / "commands" / "speckit.specify.md" - assert opencode_cmd.exists(), "preset command should be registered for the new agent" - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "presets" - ]["switch-cleanup-preset"]["registered_commands"] - assert "auggie" not in registered, ( - "old agent's tracking must be dropped after switch cleanup" - ) - assert "opencode" in registered - - def test_switch_does_not_register_disabled_extensions(self, tmp_path): - """Disabled extensions should stay disabled and should not migrate commands.""" - project = _init_project(tmp_path, "opencode") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - result = _run_in_project(project, ["extension", "disable", "git"]) - assert result.exit_code == 0, result.output - - opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" - assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch" - - result = _run_in_project(project, [ - "integration", "switch", "claude", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" - assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent" - assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch" - - registry = json.loads( - (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") - ) - git_meta = registry["extensions"]["git"] - assert git_meta["enabled"] is False - assert "claude" not in git_meta["registered_commands"] - assert "opencode" not in git_meta["registered_commands"] - - def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): - """Switching refreshes managed shared scripts to the target command style.""" - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - assert shared_script.exists() - shared_content = shared_script.read_text(encoding="utf-8") - assert "/speckit-plan" in shared_content - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--integration-options", "--commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - - assert shared_script.exists() - updated = shared_script.read_text(encoding="utf-8") - assert "/speckit.plan" in updated - assert "/speckit-plan" not in updated - - def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): - """Regression for #2293: stale managed shared scripts get refreshed on switch.""" - import hashlib - - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") - - # Simulate a stale vendored script: write truncated content as bytes - # (write_text would translate \n→\r\n on Windows and break the hash) - # and update the speckit manifest hash so the stale copy is treated - # as "managed" (installed by spec-kit, not a user customization). - stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n" - shared_script.write_bytes(stale_bytes) - - manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = ( - hashlib.sha256(stale_bytes).hexdigest() - ) - manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--integration-options", "--commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - - # Stale managed file should be replaced by the target integration's rendered version. - updated = shared_script.read_text(encoding="utf-8") - assert "# stale vendored copy" not in updated - assert "/speckit.plan" in updated - assert "/speckit-plan" not in updated - - def test_switch_preserves_user_customized_shared_infra(self, tmp_path): - """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" - - # User customization: append bytes but do NOT update manifest hash, - # so on-disk hash diverges from the recorded one. - original = shared_script.read_bytes() - custom_bytes = original + b"\n# user customization\n" - shared_script.write_bytes(custom_bytes) - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--integration-options", "--commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert shared_script.read_bytes() == custom_bytes - assert "Preserved" in result.output - - def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): - """--refresh-shared-infra explicitly overwrites user customizations on switch.""" - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") - rendered_bytes = shared_script.read_bytes() - - # User customization (hash diverges from manifest) - custom_bytes = rendered_bytes + b"\n# user customization\n" - shared_script.write_bytes(custom_bytes) - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--integration-options", "--commands", - "--script", "sh", - "--refresh-shared-infra", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - # Customization is overwritten with the target integration's rendered version. - updated = shared_script.read_text(encoding="utf-8") - assert "# user customization" not in updated - assert "/speckit.plan" in updated - assert "/speckit-plan" not in updated - - def test_switch_preserves_recovered_files(self, tmp_path): - """Regression for #2918: files marked recovered in the manifest are not overwritten. - - When a file already exists on disk before init and is recorded with - ``recovered=True``, ``integration use``/``switch`` must not treat it as - managed even when the on-disk hash matches the manifest hash. - """ - import hashlib - - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - assert shared_script.is_file() - - # Simulate a team-customized file that was recorded as recovered: - # write custom content, then update the manifest to record its hash - # with the recovered flag set. - custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n" - shared_script.write_bytes(custom_bytes) - - manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - rel = ".specify/scripts/bash/setup-tasks.sh" - manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest() - manifest_data.setdefault("recovered_files", []).append(rel) - manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - # Recovered file must NOT be overwritten — team content preserved. - assert shared_script.read_bytes() == custom_bytes - - def test_switch_skips_symlinked_parent_directory(self, tmp_path): - """Regression: if .specify/scripts/bash is a symlink, switch must not write through it. - - Copilot follow-up on #2375: leaf-only symlink check let writes escape - when an *ancestor* directory was symlinked outside the project root. - """ - import sys - if sys.platform.startswith("win"): - import pytest as _pytest - _pytest.skip("Symlink creation typically requires admin on Windows") - - project = _init_project(tmp_path, "claude") - bash_dir = project / ".specify" / "scripts" / "bash" - outside = tmp_path / "outside" - outside.mkdir() - for child in bash_dir.iterdir(): - child.rename(outside / child.name) - bash_dir.rmdir() - bash_dir.symlink_to(outside, target_is_directory=True) - sentinel = (outside / "common.sh").read_bytes() - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - # Symlinked tree reported, not written through. - assert "symlink" in result.output.lower() - # Outside dir contents unchanged. - assert (outside / "common.sh").read_bytes() == sentinel - - def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path): - """--force (uninstall semantics) must NOT overwrite shared-infra customizations. - - Regression: ensures the decoupling of --force and --refresh-shared-infra. - """ - project = _init_project(tmp_path, "claude") - shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" - bundled_bytes = shared_script.read_bytes() - - custom_bytes = bundled_bytes + b"\n# user customization\n" - shared_script.write_bytes(custom_bytes) - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--script", "sh", - "--force", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - # --force alone preserves the customization - assert shared_script.read_bytes() == custom_bytes - - def test_switch_from_nothing(self, tmp_path): - """Switch when no integration is installed should just install the target.""" - project = tmp_path / "bare" - project.mkdir() - (project / ".specify").mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "switch", "claude", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - assert "Switched to" in result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "claude" - - def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): - project = _init_project(tmp_path, "claude") - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "codex", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, [ - "integration", "switch", "generic", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "codex" - assert data["installed_integrations"] == ["codex"] - - opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) - assert opts["integration"] == "codex" - assert opts["ai"] == "codex" - - template = project / ".specify" / "templates" / "plan-template.md" - assert "$speckit-plan" in template.read_text(encoding="utf-8") - - def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path): - """Regression (review 3624184343). - - When Phase 2 of a switch fails, rollback selects another installed - integration as the new default. Under active-only registration that - fallback may never have received extension artifacts (it was - installed while another integration was active), and Phase 1 already - unregistered the outgoing agent's artifacts — so the restored default - must be rescaffolded, not just written to metadata. - """ - project = _init_project(tmp_path, "claude") - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" not in registered, ( - "precondition: secondary install has no extension artifacts" - ) - - result = _run_in_project(project, [ - "integration", "switch", "generic", - "--script", "sh", - ]) - assert result.exit_code != 0 - - data = json.loads( - (project / ".specify" / "integration.json").read_text(encoding="utf-8") - ) - assert data["integration"] == "codex", "precondition: fallback restored" - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" in registered, ( - "rollback must rescaffold extensions for the restored default" - ) - assert ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - -class TestIntegrationUpgrade: - def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): - project = _init_project(tmp_path, "claude") - _write_invalid_manifest(project, "claude") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "upgrade", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "manifest" in result.output - assert "unreadable" in result.output - - def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch): - project = _init_project(tmp_path, "claude") - init_options = project / ".specify" / "init-options.json" - opts = json.loads(init_options.read_text(encoding="utf-8")) - opts["speckit_version"] = "0.6.1" - init_options.write_text(json.dumps(opts), encoding="utf-8") - - import specify_cli.integrations._commands as _int_cmds - - monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") - - result = _run_in_project(project, [ - "integration", "upgrade", "claude", - "--force", - ]) - - assert result.exit_code == 0, result.output - updated = json.loads(init_options.read_text(encoding="utf-8")) - assert updated["speckit_version"] == "0.8.11" - - def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch): - project = _init_project(tmp_path, "gemini") - install = _run_in_project(project, [ - "integration", "install", "claude", - "--script", "sh", - ]) - assert install.exit_code == 0, install.output - - init_options = project / ".specify" / "init-options.json" - opts = json.loads(init_options.read_text(encoding="utf-8")) - opts["speckit_version"] = "0.6.1" - init_options.write_text(json.dumps(opts), encoding="utf-8") - - import specify_cli.integrations._commands as _int_cmds - - monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") - - result = _run_in_project(project, [ - "integration", "upgrade", "claude", - "--script", "sh", - "--force", - ]) - - assert result.exit_code == 0, result.output - updated = json.loads(init_options.read_text(encoding="utf-8")) - assert updated["speckit_version"] == "0.8.11" - assert updated["integration"] == "gemini" - assert updated["ai"] == "gemini" - assert "context_file" not in updated - - def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): - project = _init_project(tmp_path, "claude") - int_json = project / ".specify" / "integration.json" - init_options = project / ".specify" / "init-options.json" - manifest_path = project / ".specify" / "integrations" / "claude.manifest.json" - - before_state = json.loads(int_json.read_text(encoding="utf-8")) - before_options = json.loads(init_options.read_text(encoding="utf-8")) - before_manifest = manifest_path.read_text(encoding="utf-8") - - import specify_cli - - real_install_shared_infra = specify_cli._install_shared_infra - calls = {"count": 0} - - def fail_refresh(*args, **kwargs): - calls["count"] += 1 - if calls["count"] == 2: - raise ValueError("refuse refresh") - return real_install_shared_infra(*args, **kwargs) - - monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) - - result = _run_in_project(project, [ - "integration", "upgrade", "claude", - "--force", - ]) - - assert result.exit_code != 0 - assert "Failed to refresh shared infrastructure" in result.output - assert json.loads(int_json.read_text(encoding="utf-8")) == before_state - assert json.loads(init_options.read_text(encoding="utf-8")) == before_options - assert manifest_path.read_text(encoding="utf-8") == before_manifest - - def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path): - project = _init_project( - tmp_path, "copilot", integration_options="--commands" - ) - template = project / ".specify" / "templates" / "plan-template.md" - managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" - - assert "/speckit.plan" in template.read_text(encoding="utf-8") - assert "/speckit.specify" in managed_script.read_text(encoding="utf-8") - customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n" - customized_script.write_text(customized_before, encoding="utf-8") - - result = _run_in_project(project, [ - "integration", "upgrade", "copilot", - "--integration-options", "--skills", - ]) - - assert result.exit_code == 0, result.output - assert "/speckit-plan" in template.read_text(encoding="utf-8") - managed_content = managed_script.read_text(encoding="utf-8") - assert "/speckit-specify" in managed_content - assert "/speckit.specify" not in managed_content - assert customized_script.read_text(encoding="utf-8") == customized_before - - def test_upgrade_preserves_historical_copilot_commands_without_options( - self, tmp_path - ): - """A command manifest restores missing files instead of migrating.""" - project = _init_project( - tmp_path, "copilot", integration_options="--commands" - ) - state_path = project / ".specify" / "integration.json" - state = json.loads(state_path.read_text(encoding="utf-8")) - copilot_settings = state["integration_settings"]["copilot"] - copilot_settings.pop("raw_options", None) - copilot_settings.pop("parsed_options", None) - state_path.write_text(json.dumps(state), encoding="utf-8") - - for path in (project / ".github" / "agents").glob( - "speckit.*.agent.md" - ): - path.unlink() - for path in (project / ".github" / "prompts").glob( - "speckit.*.prompt.md" - ): - path.unlink() - - result = _run_in_project( - project, - ["integration", "upgrade", "copilot", "--script", "sh", "--force"], - ) - - assert result.exit_code == 0, result.output - assert ( - project / ".github" / "agents" / "speckit.plan.agent.md" - ).exists() - assert not (project / ".github" / "skills").exists() - init_options = json.loads( - (project / ".specify" / "init-options.json").read_text( - encoding="utf-8" - ) - ) - assert init_options.get("ai_skills") is not True - - def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): - project = _init_project(tmp_path, "gemini") - template = project / ".specify" / "templates" / "plan-template.md" - script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - assert "/speckit.plan" in template.read_text(encoding="utf-8") - assert "/speckit.plan" in script.read_text(encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - install = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ], catch_exceptions=False) - assert install.exit_code == 0, install.output - - result = runner.invoke(app, [ - "integration", "upgrade", "claude", - "--script", "sh", - "--force", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0, result.output - - data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) - assert data["integration"] == "gemini" - assert "/speckit.plan" in template.read_text(encoding="utf-8") - assert "/speckit.plan" in script.read_text(encoding="utf-8") - assert "/speckit-plan" not in script.read_text(encoding="utf-8") - - def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): - """Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/.""" - project = _init_project(tmp_path, "opencode") - - # Simulate a legacy project: rename commands/ back to command/ - canonical = project / ".opencode" / "commands" - legacy = project / ".opencode" / "command" - assert canonical.is_dir(), "init should have created .opencode/commands/" - canonical.rename(legacy) - assert legacy.is_dir() - assert not canonical.exists() - - # Patch the manifest to reflect old paths (command/ not commands/) - manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json" - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - patched_files = {} - for path, info in manifest_data.get("files", {}).items(): - patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info - manifest_data["files"] = patched_files - manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") - - old_commands = sorted(legacy.glob("speckit.*.md")) - assert len(old_commands) > 0, "Legacy dir should have speckit command files" - - result = _run_in_project(project, [ - "integration", "upgrade", "opencode", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, f"upgrade failed: {result.output}" - - # New commands in canonical dir - assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade" - new_commands = sorted(canonical.glob("speckit.*.md")) - assert len(new_commands) > 0, "Commands should exist in .opencode/commands/" - - # Stale files removed from legacy dir (extension-installed commands - # like agent-context.update may still appear — only check the original - # core command stems that should have been migrated). - core_remaining = [ - f for f in legacy.glob("speckit.*.md") - if "agent-context" not in f.name - ] - assert len(core_remaining) == 0, ( - f"Legacy .opencode/command/ should have no core speckit files after upgrade, " - f"found: {[f.name for f in core_remaining]}" - ) - - def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): - """Upgrade moves Kilo commands from .kilocode/workflows/ to .kilo/commands/.""" - project = _init_project(tmp_path, "kilocode") - canonical, legacy = _move_kilocode_install_to_legacy_layout(project) - - old_commands = sorted(legacy.glob("speckit.*.md")) - assert old_commands, "Legacy dir should have speckit command files" - - result = _run_in_project(project, [ - "integration", "upgrade", "kilocode", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, f"upgrade failed: {result.output}" - - assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" - new_commands = sorted(canonical.glob("speckit.*.md")) - assert new_commands, "Commands should exist in .kilo/commands/" - - core_remaining = [ - f for f in legacy.glob("speckit.*.md") - if "agent-context" not in f.name - ] - assert core_remaining == [], ( - "Legacy .kilocode/workflows/ should have no core speckit files " - f"after upgrade, found: {[f.name for f in core_remaining]}" - ) - - def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): - """Qoder upgrade retires old extension commands after skills exist.""" - project = _init_project(tmp_path, "qodercli") - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - skills = project / ".qoder" / "skills" - commands = project / ".qoder" / "commands" - commands.mkdir(parents=True) - - manifest_path = ( - project / ".specify" / "integrations" / "qodercli.manifest.json" - ) - manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) - legacy_manifest_files = {} - for path, info in manifest_data["files"].items(): - skill_path = project / path - command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1) - legacy_path = commands / f"{command_name}.md" - legacy_path.write_bytes(skill_path.read_bytes()) - legacy_manifest_files[ - legacy_path.relative_to(project).as_posix() - ] = info - manifest_data["files"] = legacy_manifest_files - manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") - - registry_path = project / ".specify" / "extensions" / ".registry" - registry = json.loads(registry_path.read_text(encoding="utf-8")) - git_metadata = registry["extensions"]["git"] - registered_commands = git_metadata["registered_commands"]["qodercli"] - for command_name in registered_commands: - skill_name = command_name.replace("speckit.", "speckit-", 1).replace( - ".", "-" - ) - old_command = commands / f"{command_name}.md" - old_command.write_bytes( - (skills / skill_name / "SKILL.md").read_bytes() - ) - missing_replacement = commands / "speckit.git.missing.md" - missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8") - registered_commands.append("speckit.git.missing") - git_metadata["registered_skills"] = [] - registry_path.write_text(json.dumps(registry), encoding="utf-8") - - shutil.rmtree(skills) - result = _run_in_project(project, [ - "integration", "upgrade", "qodercli", "--script", "sh", "--force", - ]) - assert result.exit_code == 0, f"upgrade failed: {result.output}" - - for command_name in registered_commands[:-1]: - skill_name = command_name.replace("speckit.", "speckit-", 1).replace( - ".", "-" - ) - assert (skills / skill_name / "SKILL.md").is_file() - assert not (commands / f"{command_name}.md").exists() - assert missing_replacement.is_file(), ( - "a legacy command must remain when no replacement skill was written" - ) - - def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides( - self, tmp_path - ): - """Kilo legacy command-root migration must fail closed with presets.""" - project = _init_project(tmp_path, "kilocode") - canonical, legacy = _move_kilocode_install_to_legacy_layout(project) - - preset_file = legacy / "speckit.plan.md" - preset_file.write_text("# preset plan override\n", encoding="utf-8") - - presets_dir = project / ".specify" / "presets" - presets_dir.mkdir(parents=True, exist_ok=True) - (presets_dir / ".registry").write_text( - json.dumps({ - "presets": { - "my-preset": { - "version": "1.0.0", - "enabled": True, - "registered_commands": {"kilocode": ["speckit.plan"]}, - "registered_skills": [], - } - } - }), - encoding="utf-8", - ) - - result = _run_in_project(project, [ - "integration", "upgrade", "kilocode", - "--script", "sh", - "--force", - ]) - assert result.exit_code != 0, ( - "Kilo legacy command-root migration with presets must be rejected" - ) - assert "preset" in result.output.lower() - assert "my-preset" in result.output - assert ".kilocode/workflows" in strip_ansi(result.output) - assert ".kilo/commands" in strip_ansi(result.output) - assert not canonical.exists(), ( - "canonical Kilo commands must not be scaffolded after rejection" - ) - assert preset_file.read_text(encoding="utf-8") == "# preset plan override\n" - - def test_upgrade_reconciles_kilocode_legacy_extension_artifacts(self, tmp_path): - """Kilo upgrade moves enabled extension commands to the canonical dir.""" - project = _init_project(tmp_path, "kilocode") - canonical, legacy = _move_kilocode_install_to_legacy_layout(project) - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - assert sorted(legacy.glob("speckit.git.*.md")), ( - "legacy Kilo should render the git extension under .kilocode/workflows" - ) - assert not canonical.exists() - - result = _run_in_project(project, [ - "integration", "upgrade", "kilocode", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, f"upgrade failed: {result.output}" - - assert sorted(canonical.glob("speckit.git.*.md")), ( - "enabled git extension commands should be recreated in .kilo/commands" - ) - assert not sorted(legacy.glob("speckit.git.*.md")), ( - "legacy git extension commands should be removed after Kilo upgrade" - ) - - registry_path = project / ".specify" / "extensions" / ".registry" - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "kilocode" in registered - - def test_upgrade_preserves_disabled_kilocode_legacy_extension_and_user_file( - self, tmp_path - ): - """Legacy reconciliation must not clean disabled or user-owned files.""" - project = _init_project(tmp_path, "kilocode") - canonical, legacy = _move_kilocode_install_to_legacy_layout(project) - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - result = _run_in_project(project, ["extension", "disable", "git"]) - assert result.exit_code == 0, f"extension disable failed: {result.output}" - - disabled_extension_files = sorted(legacy.glob("speckit.git.*.md")) - assert disabled_extension_files, "disabled extension artifact should remain pre-upgrade" - - user_file = legacy / "speckit.user-owned.md" - user_file.write_text("# user-owned legacy command", encoding="utf-8") - - result = _run_in_project(project, [ - "integration", "upgrade", "kilocode", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, f"upgrade failed: {result.output}" - - assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" - assert user_file.read_text(encoding="utf-8") == "# user-owned legacy command" - for disabled_file in disabled_extension_files: - assert disabled_file.exists(), ( - "disabled extension artifacts should be preserved during " - "legacy command-root reconciliation" - ) - assert not sorted(canonical.glob("speckit.git.*.md")), ( - "disabled extensions must not be re-registered in the canonical dir" - ) - - def test_upgrade_secondary_kilocode_legacy_dir_cleans_commands_without_backfill( - self, tmp_path - ): - """Kilo cleanup stays agent-scoped without inactive extension backfill.""" - project = _init_project(tmp_path, "copilot", integration_options="--skills") - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - assert skill.exists(), "precondition: active copilot has the git extension skill" - - registry_path = project / ".specify" / "extensions" / ".registry" - - def _git_skills(): - data = json.loads(registry_path.read_text(encoding="utf-8")) - return data["extensions"]["git"].get("registered_skills", []) - - assert _git_skills(), "precondition: git skills registered for active copilot" - - result = _run_in_project(project, [ - "integration", "install", "kilocode", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, result.output - - canonical, legacy = _move_kilocode_install_to_legacy_layout(project) - legacy_git_command = legacy / "speckit.git.feature.md" - legacy_git_command.write_text("# legacy Kilo git command\n", encoding="utf-8") - registry = json.loads(registry_path.read_text(encoding="utf-8")) - registry["extensions"]["git"].setdefault("registered_commands", {})[ - "kilocode" - ] = ["speckit.git.feature"] - registry_path.write_text(json.dumps(registry), encoding="utf-8") - assert legacy_git_command.exists(), ( - "precondition: secondary Kilo has a legacy extension command file" - ) - - result = _run_in_project(project, [ - "integration", "upgrade", "kilocode", - "--script", "sh", - "--force", - ]) - assert result.exit_code == 0, result.output - - assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" - assert not sorted(canonical.glob("speckit.git.*.md")), ( - "inactive Kilo must wait for use/switch before extension rescaffolding" - ) - assert not legacy_git_command.exists(), ( - "secondary Kilo legacy extension commands should still be cleaned up" - ) - registry = json.loads(registry_path.read_text(encoding="utf-8")) - registered_commands = registry["extensions"]["git"].get( - "registered_commands", {} - ) - assert "kilocode" not in registered_commands - assert skill.exists(), ( - "secondary Kilo legacy cleanup must not delete the active agent's " - "extension skill" - ) - assert _git_skills(), ( - "secondary Kilo legacy cleanup must not untrack the active agent's " - "extension skills in the registry" - ) - - def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path): - """Regression (review #3415, 4724160183, comment 1). - - ``integration upgrade bob --integration-options="--skills"`` migrates a - legacy Bob 1.x install (``.bob/commands/*.md``) to the skills layout - (``.bob/skills/speckit-*/SKILL.md``) and stale-removes the old command - files. Because that stale-file pass shrinks the tracked set, the - upgrade's Phase 2 must NOT delete the freshly-saved ``bob.manifest.json`` - — otherwise the migrated project is left untracked and un-upgradeable. - """ - project = _init_project( - tmp_path, "bob", integration_options="--legacy-commands" - ) - - commands = project / ".bob" / "commands" - skills = project / ".bob" / "skills" - manifest_path = ( - project / ".specify" / "integrations" / "bob.manifest.json" - ) - assert commands.is_dir() and sorted(commands.glob("speckit.*.md")) - assert not skills.exists() - assert manifest_path.is_file() - - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, f"migration upgrade failed: {result.output}" - - # Skills layout scaffolded; legacy core command files removed. - assert skills.is_dir(), ".bob/skills/ must exist after --skills migration" - assert sorted(skills.glob("speckit-*")), "expected migrated skill dirs" - core_commands = [ - f for f in commands.glob("speckit.*.md") - if "agent-context" not in f.name - ] if commands.exists() else [] - assert core_commands == [], ( - f"legacy core command files should be removed, found: " - f"{[f.name for f in core_commands]}" - ) - - # The manifest must survive so the project stays tracked/upgradeable. - assert manifest_path.is_file(), ( - "bob.manifest.json must survive a layout-shrinking migration" - ) - reupgrade = _run_in_project(project, [ - "integration", "upgrade", "bob", "--script", "sh", "--force", - ]) - assert reupgrade.exit_code == 0, ( - f"migrated project must remain upgradeable: {reupgrade.output}" - ) - - def test_upgrade_bob_layout_change_reconciles_extension_artifacts(self, tmp_path): - """Regression (review #3415, 4725829110). - - When a dual-mode agent (Bob) flips layout across an upgrade, the old - layout's *extension* artifacts must be reconciled — not left orphaned. - A legacy Bob install renders enabled extensions as ``.bob/commands/`` - command files; migrating to skills via ``--skills`` must remove those - command files, recreate the extension as ``.bob/skills/`` skills, and - update the extension registry accordingly (and vice-versa for the - reverse ``--legacy-commands`` migration). - """ - project = _init_project( - tmp_path, "bob", integration_options="--legacy-commands" - ) - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - commands = project / ".bob" / "commands" - skills = project / ".bob" / "skills" - registry_path = project / ".specify" / "extensions" / ".registry" - - def _git_registry(): - data = json.loads(registry_path.read_text(encoding="utf-8")) - g = data["extensions"]["git"] - return list(g.get("registered_commands", {})), g.get( - "registered_skills", [] - ) - - # Legacy precondition: git renders as command files under .bob/commands. - assert sorted(commands.glob("speckit.git.*.md")), ( - "legacy Bob should render the git extension as command files" - ) - assert not list(skills.glob("speckit-git-*")) if skills.exists() else True - cmds_agents, skill_names = _git_registry() - assert "bob" in cmds_agents and not skill_names - - # Migrate legacy -> skills. - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, f"--skills migration failed: {result.output}" - - # Old-layout git command files removed; skills recreated. - assert not sorted(commands.glob("speckit.git.*.md")), ( - "git extension command files must be removed after --skills migration" - ) - assert sorted(skills.glob("speckit-git-*")), ( - "git extension must be recreated as skills after --skills migration" - ) - cmds_agents, skill_names = _git_registry() - assert "bob" not in cmds_agents, ( - "extension registry must drop the stale bob command entry" - ) - assert skill_names, "extension registry must record the migrated skills" - - # Migrate skills -> legacy: the reverse reconciliation must also hold. - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--legacy-commands", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, ( - f"--legacy-commands migration failed: {result.output}" - ) - assert not sorted(skills.glob("speckit-git-*")), ( - "git extension skills must be removed after --legacy-commands migration" - ) - assert sorted(commands.glob("speckit.git.*.md")), ( - "git extension command files must be recreated in legacy layout" - ) - cmds_agents, skill_names = _git_registry() - assert "bob" in cmds_agents and not skill_names - - def test_upgrade_layout_change_preserves_extension_artifacts_when_reregistration_fails( - self, tmp_path - ): - """Regression (review 3624075109). - - A layout-changing upgrade must not eagerly unregister the agent's - extension artifacts before re-registration: the retirement of each - opposite-mode artifact belongs to - ``register_enabled_extensions_for_agent``'s deferred toggle cleanup, - which retires an old artifact only after its replacement in the new - layout is confirmed. If re-registration cannot rebuild an extension - (here: its installed manifest is corrupted), the old artifact and its - registry tracking must survive instead of leaving the extension with - no artifacts at all. - """ - project = _init_project( - tmp_path, "bob", integration_options="--legacy-commands" - ) - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - commands = project / ".bob" / "commands" - assert sorted(commands.glob("speckit.git.*.md")), ( - "precondition: git extension renders as legacy command files" - ) - - # Corrupt the installed extension manifest so re-registration cannot - # rebuild the artifacts in the new layout. - ( - project / ".specify" / "extensions" / "git" / "extension.yml" - ).write_text("invalid: [", encoding="utf-8") - - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, ( - f"upgrade is best-effort about extensions: {result.output}" - ) - - assert sorted(commands.glob("speckit.git.*.md")), ( - "old-layout extension artifacts must survive when their " - "replacement could not be registered" - ) - registry_path = project / ".specify" / "extensions" / ".registry" - data = json.loads(registry_path.read_text(encoding="utf-8")) - assert "bob" in data["extensions"]["git"].get("registered_commands", {}), ( - "extension registry must keep tracking the surviving artifacts" - ) - - def test_upgrade_active_layout_change_rejected_before_missing_preset_source_can_lose_override( - self, tmp_path - ): - """Regression (review 3623357447). - - Layout-changing upgrades must fail closed even for the active - integration. Preset rescaffolding is best-effort, so a missing source - file could otherwise let stale integration cleanup delete the tracked - old-layout override without creating its replacement. - """ - project = _init_project( - tmp_path, "bob", integration_options="--legacy-commands" - ) - commands = project / ".bob" / "commands" - skills = project / ".bob" / "skills" - - preset_src = tmp_path / "cmd-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.plan.md").write_text( - "---\ndescription: Overridden plan\n---\nOverridden plan content\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "cmd-preset", - "name": "Command Preset", - "version": "1.0.0", - "description": "Test preset with a command override", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.plan", - "file": "commands/speckit.plan.md", - } - ] - }, - } - import yaml - - (preset_src / "preset.yml").write_text( - yaml.dump(manifest_data), encoding="utf-8" - ) - result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) - assert result.exit_code == 0, f"preset add failed: {result.output}" - - cmd_file = commands / "speckit.plan.md" - assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8") - - installed_source = ( - project - / ".specify" - / "presets" - / "cmd-preset" - / "commands" - / "speckit.plan.md" - ) - assert installed_source.exists(), "precondition: preset source was installed" - installed_source.unlink() - - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code != 0, ( - "layout change with tracked preset artifacts must be rejected" - ) - assert "cmd-preset" in result.output - assert not skills.exists(), "no skills layout must be scaffolded on rejection" - assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), ( - "tracked old-layout override must remain untouched" - ) - - def test_upgrade_active_layout_change_rejected_with_disabled_preset( - self, tmp_path - ): - """Regression (review 3623779277). - - The post-upgrade rescaffold iterates *enabled* presets only, and a - disabled preset's artifacts are deliberately frozen until removal - (``preset disable``). An active-agent layout change must therefore be - rejected while a disabled preset still owns artifacts for the agent — - proceeding would delete its old-layout files in stale-manifest - cleanup, skip recreating them, and leave its registry entries stale. - Re-enabling does not make a non-transactional layout migration safe. - """ - project = _init_project( - tmp_path, "bob", integration_options="--legacy-commands" - ) - commands = project / ".bob" / "commands" - skills = project / ".bob" / "skills" - - preset_src = tmp_path / "cmd-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.plan.md").write_text( - "---\ndescription: Overridden plan\n---\nOverridden plan content\n", - encoding="utf-8", - ) - manifest_data = { - "schema_version": "1.0", - "preset": { - "id": "cmd-preset", - "name": "Command Preset", - "version": "1.0.0", - "description": "Test preset with a command override", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.plan", - "file": "commands/speckit.plan.md", - } - ] - }, - } - import yaml - - (preset_src / "preset.yml").write_text( - yaml.dump(manifest_data), encoding="utf-8" - ) - result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) - assert result.exit_code == 0, f"preset add failed: {result.output}" - result = _run_in_project(project, ["preset", "disable", "cmd-preset"]) - assert result.exit_code == 0, f"preset disable failed: {result.output}" - - cmd_file = commands / "speckit.plan.md" - assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8") - - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code != 0, ( - "layout change with a disabled preset must be rejected" - ) - assert "cmd-preset" in result.output - assert not skills.exists(), "no skills layout must be scaffolded on rejection" - assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), ( - "the disabled preset's command file must be left untouched" - ) - - # Enabled presets are also rejected: rescaffolding can still fail. - result = _run_in_project(project, ["preset", "enable", "cmd-preset"]) - assert result.exit_code == 0, f"preset enable failed: {result.output}" - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code != 0 - assert "cmd-preset" in result.output - assert not skills.exists() - assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8") - - def test_upgrade_secondary_layout_change_rejected_with_presets_installed( - self, tmp_path - ): - """Regression (review #3415, 4726193915; updated for review 3623357447). - - Preset rescaffolding is active-agent-only, so a layout-changing - ``upgrade`` of a *non-active* integration still cannot reconcile that - agent's preset artifacts. It must reject the migration with an - actionable error *before any mutation* when preset overrides are - installed for that agent. A same-layout upgrade must still succeed. - """ - project = _init_project(tmp_path, "copilot") - result = _run_in_project(project, [ - "integration", "install", "bob", - "--integration-options", "--legacy-commands", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, result.output - commands = project / ".bob" / "commands" - skills = project / ".bob" / "skills" - assert sorted(commands.glob("speckit.*.md")) - - # Simulate a historical preset registration for the non-active bob. - presets_dir = project / ".specify" / "presets" - presets_dir.mkdir(parents=True, exist_ok=True) - (presets_dir / ".registry").write_text( - json.dumps({ - "presets": { - "my-preset": { - "version": "1.0.0", - "enabled": True, - "registered_commands": {"bob": ["speckit.plan"]}, - "registered_skills": {}, - } - } - }), - encoding="utf-8", - ) - - # Layout-changing upgrade of the secondary agent is rejected untouched. - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code != 0, ( - "secondary layout change with presets must be rejected" - ) - assert "preset" in result.output.lower() - assert "my-preset" in result.output - assert not skills.exists(), "no skills layout must be scaffolded on rejection" - assert sorted(commands.glob("speckit.*.md")), ( - "legacy command files must be left untouched on rejection" - ) - - # A same-layout upgrade (no flag) must still succeed with presets present. - result = _run_in_project(project, [ - "integration", "upgrade", "bob", "--script", "sh", "--force", - ]) - assert result.exit_code == 0, ( - f"same-layout upgrade must not be blocked by presets: {result.output}" - ) - - def test_upgrade_bob_layout_change_rejected_when_preset_registry_unreadable( - self, tmp_path - ): - """Regression (review #3415, 4744636079). - - The preset guard must fail *closed*: if the preset registry exists but - cannot be read/parsed (corruption, permissions), the layout-changing - upgrade must be rejected before any mutation rather than proceeding on - a false "no presets installed" assumption (which would let ``--force`` - delete preset-overridden command files while their registry state is - unknown). A genuinely absent registry must still be allowed. - """ - project = _init_project( - tmp_path, "bob", integration_options="--legacy-commands" - ) - commands = project / ".bob" / "commands" - skills = project / ".bob" / "skills" - assert sorted(commands.glob("speckit.*.md")) - - # Corrupted (unparseable) registry: exists but cannot be read as JSON. - presets_dir = project / ".specify" / "presets" - presets_dir.mkdir(parents=True, exist_ok=True) - (presets_dir / ".registry").write_text("{ not valid json", encoding="utf-8") - - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code != 0, ( - "layout change must be rejected when preset registry is unreadable" - ) - assert "preset registry" in result.output.lower() - assert not skills.exists(), "no skills layout may be scaffolded on rejection" - assert sorted(commands.glob("speckit.*.md")), ( - "legacy command files must be untouched when failing closed" - ) - - # A valid, empty registry must NOT block the migration. - (presets_dir / ".registry").write_text( - json.dumps({"presets": {}}), encoding="utf-8" - ) - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, ( - f"valid empty preset registry must not block migration: {result.output}" - ) - assert skills.exists(), "skills layout should be scaffolded once unblocked" - - def test_upgrade_secondary_bob_layout_change_preserves_active_agent_skills( - self, tmp_path - ): - """Regression (review #3415, 4726347306). - - ``integration upgrade`` supports upgrading a *secondary* (non-active) - integration. The layout-change extension reconciliation must NOT run - for a secondary agent: ``unregister_agent_artifacts`` treats the - unscoped per-extension ``registered_skills`` as belonging to the passed - agent and, if that agent's skills dir is absent, scans every agent's - skills dir — which could delete/untrack the *active* agent's extension - skills. The following re-registration cannot repair that because - extension skill rendering is active-agent-scoped (#2948). - """ - # Active agent: copilot in skills mode → git extension renders as skills. - project = _init_project(tmp_path, "copilot", integration_options="--skills") - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - assert skill.exists(), "precondition: active copilot has the git extension skill" - - registry_path = project / ".specify" / "extensions" / ".registry" - - def _git_skills(): - data = json.loads(registry_path.read_text(encoding="utf-8")) - return data["extensions"]["git"].get("registered_skills", []) - - assert _git_skills(), "precondition: git skills registered for active copilot" - - # Add a secondary (non-active) Bob in the legacy commands layout. - result = _run_in_project(project, [ - "integration", "install", "bob", - "--integration-options", "--legacy-commands", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, result.output - - # Flip the *secondary* Bob's layout to skills. copilot stays active. - result = _run_in_project(project, [ - "integration", "upgrade", "bob", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, result.output - - # The active agent's extension skill must be untouched on disk and in - # the registry — the secondary layout change must not reconcile it. - assert skill.exists(), ( - "secondary Bob layout change must not delete the active agent's " - "extension skill" - ) - assert _git_skills(), ( - "secondary Bob layout change must not untrack the active agent's " - "extension skills in the registry" - ) - - def test_upgrade_preserves_existing_vscode_settings(self, tmp_path): - """Regression: copilot upgrade must not stale-delete .vscode/settings.json. - - On init the file is created and recorded in the manifest. On upgrade, - setup() merges into the now-existing file and intentionally stops - tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2 - stale cleanup would delete it (destroying the user's settings). - """ - project = _init_project( - tmp_path, "copilot", integration_options="--commands" - ) - settings = project / ".vscode" / "settings.json" - assert settings.is_file(), "init should create .vscode/settings.json" - before = json.loads(settings.read_text(encoding="utf-8")) - assert before, "settings.json should contain managed defaults" - - # Simulate a user editing their settings: add a custom key that the - # integration does not manage. It must survive the upgrade. - before["editor.fontSize"] = 17 - settings.write_text(json.dumps(before), encoding="utf-8") - - result = _run_in_project(project, [ - "integration", "upgrade", "copilot", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, result.output - - assert settings.is_file(), ".vscode/settings.json must survive upgrade" - after = json.loads(settings.read_text(encoding="utf-8")) - assert after.get("editor.fontSize") == 17, ( - "user-defined settings must be preserved after upgrade" - ) - - def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path): - """Regression: scripts refreshed by the managed-refresh step stay +x.""" - if os.name == "nt": - pytest.skip("POSIX execute bits are not meaningful on Windows") - project = _init_project(tmp_path, "copilot") - script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" - assert script.is_file() - # Simulate a perms-losing install (e.g. wheel extraction dropping +x). - script.chmod(0o644) - assert not (script.stat().st_mode & 0o111) - - result = _run_in_project(project, [ - "integration", "upgrade", "copilot", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - assert script.stat().st_mode & 0o111, ( - "shared .sh scripts must be executable after upgrade" - ) - - def test_upgrade_does_not_backfill_non_active_integration(self, tmp_path): - """Upgrading a non-active integration must not register extensions for it. - - Maintainer-requested behavior for #2948 (reverses the #2886 upgrade - back-fill): non-active integrations only receive extension artifacts - when selected via ``integration use`` / ``switch``. Upgrade of a - non-active integration refreshes its own files and nothing else. - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - result = _run_in_project(project, [ - "integration", "install", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - registry_path = project / ".specify" / "extensions" / ".registry" - assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - - result = _run_in_project(project, [ - "integration", "upgrade", "codex", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - registered = json.loads(registry_path.read_text(encoding="utf-8"))[ - "extensions" - ]["git"]["registered_commands"] - assert "codex" not in registered, ( - "upgrade must not back-fill non-active integrations (#2948)" - ) - assert not ( - project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" - ).exists() - - def test_upgrade_active_integration_reregisters_extensions(self, tmp_path): - """Upgrading the active integration restores its extension commands. - - The active integration keeps the re-registration pass on upgrade so - missing or stale extension command files are recreated (#2948 scopes - the pass to the active integration; #2886 introduced it). - """ - project = _init_project(tmp_path, "claude") - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - cmd_file = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" - assert cmd_file.exists(), "precondition: extension command registered" - cmd_file.unlink() - - result = _run_in_project(project, [ - "integration", "upgrade", "claude", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - - assert cmd_file.exists(), ( - "upgrade of the active integration re-registers extension commands" - ) - - def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir( - self, tmp_path - ): - """End-to-end regression for #3849 (upgrade-overwrites-copilot-skills). - - In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which - regenerates the core-template skill directories — *before* re-registering - installed extensions. The extension re-registration then hits the - ``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill - sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has - not been rewritten with extension content), so pre-fix the extension - skill was silently left missing — its command content lost even though the - extension remained installed and registered. - - The fix threads ``force=True`` from ``integration_upgrade()`` down to - ``_register_extension_skills`` so the guard is bypassed and the extension - content is re-composed on top of the just-regenerated directory. This test - exercises the full ``specify integration upgrade`` command path and fails - without the fix (the skill is never recreated). - """ - project = _init_project( - tmp_path, "copilot", integration_options="--skills" - ) - - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - skill_dir = project / ".github" / "skills" / "speckit-git-feature" - skill_file = skill_dir / "SKILL.md" - assert skill_file.exists(), ( - "precondition: git extension renders as a Copilot skill" - ) - original = skill_file.read_text(encoding="utf-8") - assert "source: extension:git" in original, ( - "precondition: skill carries the git extension ownership marker" - ) - - # Simulate the exact pre-condition the bug depends on: the skill file is - # gone but its directory survives (as it does once setup() regenerates the - # core-template layout during upgrade), triggering the skill_dir_preexists - # skip guard on re-registration. - skill_file.unlink() - assert skill_dir.exists() and not skill_file.exists() - - result = _run_in_project(project, [ - "integration", "upgrade", "copilot", - "--integration-options", "--skills", - "--script", "sh", "--force", - ]) - assert result.exit_code == 0, result.output - - assert skill_file.exists(), ( - "upgrade must restore the extension skill even when its directory " - "already exists (regression #3849)" - ) - restored = skill_file.read_text(encoding="utf-8") - assert "source: extension:git" in restored, ( - "restored skill must contain the git extension content, not a bare " - "core-template stub" - ) - assert "# Git Feature Skill" in restored - - def test_upgrade_active_integration_reregisters_presets(self, tmp_path): - """Upgrading the active integration restores missing preset artifacts.""" - import yaml - - project = _init_project(tmp_path, "claude") - preset_src = tmp_path / "upgrade-preset" - (preset_src / "commands").mkdir(parents=True) - (preset_src / "commands" / "speckit.upgrade-check.md").write_text( - "---\ndescription: Upgrade check\n---\nPreset upgrade body\n", - encoding="utf-8", - ) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "upgrade-preset", - "name": "Upgrade Preset", - "version": "1.0.0", - "description": "Upgrade preset test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "command", - "name": "speckit.upgrade-check", - "file": "commands/speckit.upgrade-check.md", - } - ] - }, - } - (preset_src / "preset.yml").write_text( - yaml.dump(manifest), encoding="utf-8" - ) - - result = _run_in_project( - project, ["preset", "add", "--dev", str(preset_src)] - ) - assert result.exit_code == 0, result.output - - skill_dir = ( - project / ".claude" / "skills" / "speckit-upgrade-check" - ) - skill_file = skill_dir / "SKILL.md" - assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8") - shutil.rmtree(skill_dir) - - result = _run_in_project(project, [ - "integration", "upgrade", "claude", - "--script", "sh", - ]) - assert result.exit_code == 0, result.output - assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8") - - def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path): - """Upgrading a non-active agent must not touch the active agent's skills. - - Regression for the #2886 wiring: extension skill rendering is - active-agent-scoped, so routing upgrade of a *secondary* agent through - ``register_enabled_extensions_for_agent`` used to re-render the - *active* skills-mode agent's extension skills as a side effect — - resurrecting skill files the user had deliberately deleted. The skills - pass is now gated on the target being the active agent. (Skills parity - for non-active agents is tracked separately in #2948.) - """ - # Active agent: copilot in skills mode → git extension renders as skills. - project = _init_project(tmp_path, "copilot", integration_options="--skills") - result = _run_in_project(project, ["extension", "add", "git"]) - assert result.exit_code == 0, f"extension add failed: {result.output}" - - skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" - assert skill.exists(), "precondition: active copilot has the git extension skill" - - # Add a secondary (non-active) agent; copilot is not multi_install_safe. - result = _run_in_project(project, [ - "integration", "install", "codex", "--script", "sh", "--force", - ]) - assert result.exit_code == 0, result.output - - # The user deliberately removes the active agent's git skill. - shutil.rmtree(skill.parent) - assert not skill.exists() - - # Upgrading the *non-active* agent must not re-render copilot's skills. - result = _run_in_project(project, [ - "integration", "upgrade", "codex", "--script", "sh", - ]) - assert result.exit_code == 0, result.output - assert not skill.exists(), ( - "upgrading a non-active agent must not resurrect the active agent's " - "deleted extension skill (#2886)" - ) - - def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path): - """Unit (review #3415, 4744636079): fail closed only when unreadable. - - The preset guard helper must return an empty list for a genuinely - absent registry, but raise ``_PresetRegistryUnreadableError`` when the - registry exists yet cannot be read/parsed — so a layout-changing - upgrade never proceeds on a false "no presets" result. - """ - from specify_cli.integrations._migrate_commands import ( - _PresetRegistryUnreadableError, - _installed_command_presets_affecting_agent, - _installed_presets_affecting_agent, - ) - - project = tmp_path / "proj" - project.mkdir() - - # Genuinely absent registry → empty list (safe to proceed). - assert _installed_presets_affecting_agent(project, "bob") == [] - - presets_dir = project / ".specify" / "presets" - presets_dir.mkdir(parents=True) - registry = presets_dir / ".registry" - - # Corrupted JSON → unreadable → raise. - registry.write_text("{ not json", encoding="utf-8") - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - - # Malformed structure (presets not a dict) → unreadable → raise. - registry.write_text(json.dumps({"presets": []}), encoding="utf-8") - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - - # Malformed per-preset entry (not a dict) → ownership unknown → raise. - registry.write_text( - json.dumps({"presets": {"p1": []}}), encoding="utf-8" - ) - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - - # Malformed registered_commands (not a dict) → raise. - registry.write_text( - json.dumps({"presets": {"p1": {"registered_commands": []}}}), - encoding="utf-8", - ) - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - - # Malformed registered_skills (neither list nor dict) → raise. - registry.write_text( - json.dumps({"presets": {"p1": {"registered_skills": "oops"}}}), - encoding="utf-8", - ) - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - - # Dict-shaped fields with non-list values (ownership undecidable) - # must also fail closed, not read as "no artifacts". - registry.write_text( - json.dumps( - {"presets": {"p1": {"registered_skills": {"bob": None}}}} - ), - encoding="utf-8", - ) - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - registry.write_text( - json.dumps( - {"presets": {"p1": {"registered_commands": {"bob": ""}}}} - ), - encoding="utf-8", - ) - with pytest.raises(_PresetRegistryUnreadableError): - _installed_presets_affecting_agent(project, "bob") - - # Valid, empty registry → empty list. - registry.write_text(json.dumps({"presets": {}}), encoding="utf-8") - assert _installed_presets_affecting_agent(project, "bob") == [] - - # Valid registry with a preset registered for bob → report its ID. - # registered_skills comes in two shapes: a legacy flat list (not - # agent-scoped → fail closed, any entry affects) and the per-agent - # dict written by preset registration ({agent: [skill names]} → only - # this agent's entries affect it). - registry.write_text( - json.dumps({ - "presets": { - "p1": {"registered_commands": {"bob": ["speckit.plan"]}}, - "p2": {"registered_commands": {"codex": ["speckit.plan"]}}, - "p3": {"registered_skills": ["speckit-x"]}, - "p4": {"registered_skills": {"bob": ["speckit-y"]}}, - "p5": {"registered_skills": {"codex": ["speckit-z"]}}, - "p6": {"registered_skills": {"bob": []}}, - "p7": { - "enabled": False, - "registered_commands": {"bob": ["speckit.tasks"]}, - }, - } - }), - encoding="utf-8", - ) - assert sorted(_installed_presets_affecting_agent(project, "bob")) == [ - "p1", - "p3", - "p4", - "p7", - ] - assert _installed_command_presets_affecting_agent(project, "bob") == [ - "p1", - "p7", - ] - - -# ── Full lifecycle ─────────────────────────────────────────────────── - - -class TestIntegrationLifecycle: - def test_install_modify_uninstall_preserves_modified(self, tmp_path): - """Full lifecycle: install → modify file → uninstall → verify modified file kept.""" - project = tmp_path / "lifecycle" - project.mkdir() - (project / ".specify").mkdir() - - old_cwd = os.getcwd() - try: - os.chdir(project) - - # Install - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ], catch_exceptions=False) - assert result.exit_code == 0 - assert "installed successfully" in result.output - - # Claude uses skills directory - plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" - assert plan_file.exists() - - # Modify one file - plan_file.write_text("# user customization\n", encoding="utf-8") - - # Uninstall - result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) - assert result.exit_code == 0 - assert "preserved" in result.output - - # Modified file kept - assert plan_file.exists() - assert plan_file.read_text(encoding="utf-8") == "# user customization\n" - finally: - os.chdir(old_cwd) - - -# ── Edge-case fixes ───────────────────────────────────────────────── - - -class TestScriptTypeValidation: - def test_invalid_script_type_rejected(self, tmp_path): - """--script with an invalid value should fail with a clear error.""" - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "bash", - ]) - finally: - os.chdir(old_cwd) - assert result.exit_code != 0 - assert "Invalid script type" in result.output - - def test_valid_script_types_accepted(self, tmp_path): - """Both 'sh' and 'ps' should be accepted.""" - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, [ - "integration", "install", "claude", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - - -class TestParseIntegrationOptionsEqualsForm: - def test_equals_form_parsed(self): - """--commands-dir=./x should be parsed the same as --commands-dir ./x.""" - from specify_cli.integrations._commands import _parse_integration_options - from specify_cli.integrations import get_integration - - integration = get_integration("generic") - assert integration is not None - - result_space = _parse_integration_options(integration, "--commands-dir ./mydir") - result_equals = _parse_integration_options(integration, "--commands-dir=./mydir") - assert result_space is not None - assert result_equals is not None - assert result_space["commands_dir"] == "./mydir" - assert result_equals["commands_dir"] == "./mydir" - - def test_unbalanced_quote_exits_cleanly(self, capsys): - """An unbalanced quote must exit(1) with a message, not a raw ValueError. - - shlex.split() raises ValueError("No closing quotation") on an unbalanced - quote; the parser must translate that into the same clean typer.Exit(1) - UX as unknown-option / missing-value, rather than letting the traceback - escape (issue #3457). - """ - import typer - - from specify_cli.integrations._commands import _parse_integration_options - from specify_cli.integrations import get_integration - - integration = get_integration("generic") - assert integration is not None - - with pytest.raises(typer.Exit) as excinfo: - _parse_integration_options(integration, '--commands-dir "foo') - assert excinfo.value.exit_code == 1 - assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out - - def test_bad_option_token_with_rich_markup_exits_cleanly(self): - """A bad option token carrying Rich markup must exit cleanly, not crash. - - The token is user-controlled and gets interpolated into console.print. - A value like '[/red]foo' parses fine through shlex but is an unexpected - value / unknown option — and an unbalanced Rich tag would raise - rich.errors.MarkupError inside console.print, leaking a traceback - instead of the intended typer.Exit(1). The token must be escaped.""" - import typer - - from specify_cli.integrations._commands import _parse_integration_options - from specify_cli.integrations import get_integration - - integration = get_integration("generic") - assert integration is not None - - # Unexpected value token carrying markup. - with pytest.raises(typer.Exit): - _parse_integration_options(integration, "[/red]foo") - - # Unknown option token carrying markup. - with pytest.raises(typer.Exit): - _parse_integration_options(integration, "--[/red]bad") - - -class TestUninstallNoManifestClearsInitOptions: - def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path): - """When no manifest exists, uninstall should still clear init-options.json.""" - project = tmp_path / "proj" - project.mkdir() - (project / ".specify").mkdir() - - # Write integration.json and init-options.json without a manifest - int_json = project / ".specify" / "integration.json" - int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8") - - opts_json = project / ".specify" / "init-options.json" - opts_json.write_text(json.dumps({ - "integration": "claude", - "ai": "claude", - "ai_skills": True, - "script": "sh", - }), encoding="utf-8") - - old_cwd = os.getcwd() - try: - os.chdir(project) - result = runner.invoke(app, ["integration", "uninstall", "claude"]) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - - # init-options.json should have integration keys cleared - opts = json.loads(opts_json.read_text(encoding="utf-8")) - assert "integration" not in opts - assert "ai" not in opts - assert "ai_skills" not in opts - # Non-integration keys preserved - assert opts.get("script") == "sh" - - -class TestSwitchClearsMetadataAfterTeardown: - def test_metadata_cleared_between_phases(self, tmp_path): - """After a successful switch, metadata should reference the new integration.""" - project = _init_project(tmp_path, "claude") - - # Verify initial state - int_json = project / ".specify" / "integration.json" - assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude" - - old_cwd = os.getcwd() - try: - os.chdir(project) - # Switch to copilot — should succeed and update metadata - result = runner.invoke(app, [ - "integration", "switch", "copilot", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - - # integration.json should reference copilot, not claude - data = json.loads(int_json.read_text(encoding="utf-8")) - assert data["integration"] == "copilot" - - # init-options.json should reference copilot - opts_json = project / ".specify" / "init-options.json" - opts = json.loads(opts_json.read_text(encoding="utf-8")) - assert opts.get("ai") == "copilot" diff --git a/tests/specify_cli/integrations/__init__.py b/tests/specify_cli/integrations/__init__.py new file mode 100644 index 0000000000..9e2e4c4a1c --- /dev/null +++ b/tests/specify_cli/integrations/__init__.py @@ -0,0 +1 @@ +"""Tests for integration CLI commands.""" diff --git a/tests/specify_cli/integrations/_catalog_helpers.py b/tests/specify_cli/integrations/_catalog_helpers.py new file mode 100644 index 0000000000..56441e8c4c --- /dev/null +++ b/tests/specify_cli/integrations/_catalog_helpers.py @@ -0,0 +1,107 @@ +"""Shared fixtures for integration discovery command tests.""" + +import os + +from tests.conftest import strip_ansi + + +def _normalize_cli_output(output: str) -> str: + output = strip_ansi(output) + output = " ".join(output.split()) + return output.strip() + + +class IntegrationCatalogCliTestBase: + """End-to-end CLI tests for `integration search`, `info`, and `catalog …`. + + All tests patch `IntegrationCatalog._get_merged_integrations` so no network + or on-disk cache is touched. Adds #2344 coverage without affecting any + existing integration install/switch/uninstall/upgrade behavior. + """ + + FAKE_INTEGRATIONS = [ + { + "id": "acme-coder", + "name": "Acme Coder", + "version": "2.0.0", + "description": "Community integration for Acme Coder", + "author": "acme-org", + "tags": ["cli", "acme"], + "_catalog_name": "community", + "_install_allowed": False, + }, + { + "id": "stellar-agent", + "name": "Stellar Agent", + "version": "1.3.0", + "description": "First-party Stellar agent integration", + "author": "stellar-labs", + "tags": ["ide"], + "_catalog_name": "default", + "_install_allowed": True, + }, + ] + MARKUP_INTEGRATION = { + "id": "[red]markup-id[/red]", + "name": "[green]Markup Name[/green]", + "version": "[blue]1.0.0[/blue]", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "license": "[cyan]Markup License[/cyan]", + "repository": "[bold]Markup Repository[/bold]", + "tags": ["[italic]markup-tag[/italic]"], + "_catalog_name": "[underline]markup-catalog[/underline]", + "_install_allowed": False, + } + + def _make_project(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + return project + + def _patch_catalog(self, monkeypatch, integrations=None): + """Return a stubbed `_get_merged_integrations` that yields *integrations*.""" + from specify_cli.integrations.catalog import IntegrationCatalog + + data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS) + + def fake_merged(self, force_refresh=False): + return data + + monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged) + + def _invoke(self, argv, cwd): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(cwd) + return runner.invoke(app, argv, catch_exceptions=False) + finally: + os.chdir(old) + + +class IntegrationListCatalogTestBase: + def _init_project(self, tmp_path): + """Create a minimal spec-kit project.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project diff --git a/tests/specify_cli/integrations/_helpers.py b/tests/specify_cli/integrations/_helpers.py new file mode 100644 index 0000000000..10e8cf8ee0 --- /dev/null +++ b/tests/specify_cli/integrations/_helpers.py @@ -0,0 +1,77 @@ +"""Shared helpers for mirrored integration command tests.""" + +import json +import os +import shutil + +from typer.testing import CliRunner + +from specify_cli import app +from tests.conftest import strip_ansi + + +runner = CliRunner() + +def _init_project(tmp_path, integration="copilot", integration_options=None): + """Helper: init a spec-kit project with the given integration.""" + project = tmp_path / "proj" + project.mkdir() + args = [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--ignore-agent-tools", + ] + if integration_options: + args += ["--integration-options", integration_options] + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, args, catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + return project + +def _run_in_project(project, args): + """Run a CLI command from inside a generated project.""" + old_cwd = os.getcwd() + try: + os.chdir(project) + return runner.invoke(app, args, catch_exceptions=False) + finally: + os.chdir(old_cwd) + +def _write_invalid_manifest(project, key): + manifest = project / ".specify" / "integrations" / f"{key}.manifest.json" + manifest.write_bytes(b"\xff\xfe\x00") + return manifest + +def _move_kilocode_install_to_legacy_layout(project): + """Simulate a pre-.kilo Kilo install tracked under .kilocode/workflows.""" + canonical = project / ".kilo" / "commands" + legacy = project / ".kilocode" / "workflows" + assert canonical.is_dir(), "init should have created .kilo/commands/" + legacy.parent.mkdir(parents=True, exist_ok=True) + canonical.rename(legacy) + assert legacy.is_dir() + assert not canonical.exists() + + manifest_path = project / ".specify" / "integrations" / "kilocode.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_data["files"] = { + path.replace(".kilo/commands/", ".kilocode/workflows/"): info + for path, info in manifest_data.get("files", {}).items() + } + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + return canonical, legacy + +def _copy_project_template(tmp_path, template): + project = tmp_path / "proj" + shutil.copytree(template, project) + return project + +def _integration_list_row_cells(output: str, key: str) -> list[str]: + plain = strip_ansi(output) + row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}")) + return [cell.strip() for cell in row.split("│")[1:-1]] diff --git a/tests/specify_cli/integrations/catalog/__init__.py b/tests/specify_cli/integrations/catalog/__init__.py new file mode 100644 index 0000000000..e2d48ee1ef --- /dev/null +++ b/tests/specify_cli/integrations/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests for integration catalog CLI commands.""" diff --git a/tests/specify_cli/integrations/catalog/test_command_add.py b/tests/specify_cli/integrations/catalog/test_command_add.py new file mode 100644 index 0000000000..0686b1bad1 --- /dev/null +++ b/tests/specify_cli/integrations/catalog/test_command_add.py @@ -0,0 +1,110 @@ +"""Tests for TestIntegrationCatalogAdd.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 + +import pytest # noqa: F401 + +from tests.specify_cli.integrations._catalog_helpers import IntegrationCatalogCliTestBase + +class TestIntegrationCatalogAdd(IntegrationCatalogCliTestBase): + def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + add_result = self._invoke( + [ + "integration", + "catalog", + "add", + "https://new.example.com/catalog.json", + "--name", + "mine", + ], + project, + ) + assert add_result.exit_code == 0, add_result.output + assert "Catalog source added" in add_result.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + assert cfg_path.exists() + + list_result = self._invoke(["integration", "catalog", "list"], project) + assert list_result.exit_code == 0, list_result.output + assert "Project catalog sources" in list_result.output + assert "[0]" in list_result.output + assert "mine" in list_result.output + assert "default" not in list_result.output + assert "community" not in list_result.output + + remove_result = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert remove_result.exit_code == 0, remove_result.output + assert "'mine' removed" in remove_result.output + + def test_catalog_add_strips_whitespace_in_success_output_and_storage( + self, tmp_path, monkeypatch + ): + """Surrounding whitespace in the URL must not appear in the success + message or be persisted to the YAML config.""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + padded_url = " https://padded.example.com/catalog.json " + clean_url = "https://padded.example.com/catalog.json" + + add_result = self._invoke( + [ + "integration", + "catalog", + "add", + padded_url, + "--name", + "padded", + ], + project, + ) + assert add_result.exit_code == 0, add_result.output + assert clean_url in add_result.output + assert padded_url not in add_result.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + import yaml as _yaml + data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + urls = [c["url"] for c in data["catalogs"]] + assert clean_url in urls + assert padded_url not in urls + + def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + result = self._invoke( + [ + "integration", + "catalog", + "add", + "http://insecure.example.com/catalog.json", + ], + project, + ) + assert result.exit_code == 1 + assert "HTTPS" in result.output + + def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + url = "https://dup.example.com/catalog.json" + first = self._invoke( + ["integration", "catalog", "add", url], project + ) + assert first.exit_code == 0, first.output + second = self._invoke( + ["integration", "catalog", "add", url], project + ) + assert second.exit_code == 1 + assert "already configured" in second.output diff --git a/tests/specify_cli/integrations/catalog/test_command_list.py b/tests/specify_cli/integrations/catalog/test_command_list.py new file mode 100644 index 0000000000..c78db12b5c --- /dev/null +++ b/tests/specify_cli/integrations/catalog/test_command_list.py @@ -0,0 +1,146 @@ +"""Tests for TestIntegrationCatalogList.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 + +import pytest # noqa: F401 +import yaml + +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, + IntegrationListCatalogTestBase, + _normalize_cli_output, +) + +class TestIntegrationCatalogList(IntegrationCatalogCliTestBase): + def test_catalog_list_requires_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + result = self._invoke(["integration", "catalog", "list"], project) + assert result.exit_code == 1 + assert "Not a Spec Kit project" in result.output + + def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + result = self._invoke(["integration", "catalog", "list"], project) + assert result.exit_code == 0, result.output + assert "Integration Catalog Sources" in result.output + assert "No project-level catalog sources configured" in result.output + assert "Active catalog sources" in result.output + assert "non-removable" in result.output + assert "default" in result.output + assert "community" in result.output + # Built-in defaults are active, but not removable project entries. + assert "[0]" not in result.output + assert "[1]" not in result.output + + def test_catalog_list_normalizes_blank_project_catalog_names( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + cfg_path = project / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://null-name.example.com/catalog.json", + "name": None, + }, + { + "url": "https://blank-name.example.com/catalog.json", + "name": " ", + }, + ] + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "catalog", "list"], project) + normalized_output = _normalize_cli_output(result.output) + + assert result.exit_code == 0, result.output + assert "[0] catalog-1" in normalized_output + assert "[1] catalog-2" in normalized_output + assert "None" not in normalized_output + + def test_catalog_list_env_override_supersedes_project_config( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "https://env.example.com/catalog.json", + ) + cfg_path = project / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://project.example.com/catalog.json", + "name": "project", + "priority": 1, + } + ] + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "catalog", "list"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output + assert "supersedes configured catalog files" in normalized_output + assert "non-removable" in normalized_output + assert "https://env.example.com/catalog.json" in normalized_output + assert "https://project.example.com/catalog.json" not in normalized_output + assert "[0]" not in normalized_output + + +class TestIntegrationCatalogListMarkup(IntegrationListCatalogTestBase): + def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch): + """User-editable catalog name/url/description must not be parsed as Rich markup.""" + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.integrations.catalog import IntegrationCatalog + runner = CliRunner() + project = self._init_project(tmp_path) + + configs = [ + { + "name": "Bracket [Catalog]", + "url": "https://example.com/[cat].json", + "description": "desc [with] brackets", + "install_allowed": True, + }, + ] + monkeypatch.setattr( + IntegrationCatalog, + "get_project_catalog_configs", + lambda self: [dict(c) for c in configs], + ) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "catalog", "list"]) + finally: + os.chdir(old) + + assert result.exit_code == 0, result.output + assert "Bracket [Catalog]" in result.output + assert "https://example.com/[cat].json" in result.output + assert "desc [with] brackets" in result.output diff --git a/tests/specify_cli/integrations/catalog/test_command_remove.py b/tests/specify_cli/integrations/catalog/test_command_remove.py new file mode 100644 index 0000000000..c2ba7f8e3e --- /dev/null +++ b/tests/specify_cli/integrations/catalog/test_command_remove.py @@ -0,0 +1,83 @@ +"""Tests for TestIntegrationCatalogRemove.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 + +import pytest # noqa: F401 + +from tests.specify_cli.integrations._catalog_helpers import IntegrationCatalogCliTestBase + +class TestIntegrationCatalogRemove(IntegrationCatalogCliTestBase): + def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + # Need a config file for remove to attempt an index lookup + self._invoke( + [ + "integration", + "catalog", + "add", + "https://only.example.com/catalog.json", + ], + project, + ) + result = self._invoke( + ["integration", "catalog", "remove", "9"], project + ) + assert result.exit_code == 1 + assert "out of range" in result.output + + def test_catalog_remove_without_config(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + result = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert result.exit_code == 1 + assert "No catalog config" in result.output + + def test_catalog_remove_final_entry_restores_defaults( + self, tmp_path, monkeypatch + ): + """End-to-end: add → remove-last-entry → list should not error. + + Regression for the flow where a user adds a catalog, removes it, then + runs any follow-up integration command. Without the fix the config + file would be left as `catalogs: []` and every subsequent + `integration` call would fail with "contains no 'catalogs' entries". + """ + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + add = self._invoke( + [ + "integration", + "catalog", + "add", + "https://only.example.com/catalog.json", + "--name", + "only", + ], + project, + ) + assert add.exit_code == 0, add.output + + remove = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert remove.exit_code == 0, remove.output + assert "'only' removed" in remove.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + assert not cfg_path.exists(), ( + "config file should be deleted when the final catalog is removed" + ) + + # Follow-up command must succeed and show the built-in defaults, + # not error out on "contains no 'catalogs' entries". + listing = self._invoke(["integration", "catalog", "list"], project) + assert listing.exit_code == 0, listing.output + assert "default" in listing.output + assert "community" in listing.output diff --git a/tests/specify_cli/integrations/conftest.py b/tests/specify_cli/integrations/conftest.py new file mode 100644 index 0000000000..c79582bfdf --- /dev/null +++ b/tests/specify_cli/integrations/conftest.py @@ -0,0 +1,22 @@ +"""Shared fixtures for mirrored integration command tests.""" + +import pytest + +from tests.specify_cli.integrations._helpers import _copy_project_template, _init_project + + +@pytest.fixture(scope="module") +def status_copilot_template(tmp_path_factory): + return _init_project(tmp_path_factory.mktemp("status-copilot"), "copilot") + +@pytest.fixture(scope="module") +def status_claude_template(tmp_path_factory): + return _init_project(tmp_path_factory.mktemp("status-claude"), "claude") + +@pytest.fixture +def copilot_project(tmp_path, status_copilot_template): + return _copy_project_template(tmp_path, status_copilot_template) + +@pytest.fixture +def claude_project(tmp_path, status_claude_template): + return _copy_project_template(tmp_path, status_claude_template) diff --git a/tests/specify_cli/integrations/test_command_info.py b/tests/specify_cli/integrations/test_command_info.py new file mode 100644 index 0000000000..6b973cb136 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_info.py @@ -0,0 +1,120 @@ +"""Tests for TestIntegrationInfo.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 + +import pytest # noqa: F401 + +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, + _normalize_cli_output, +) + +class TestIntegrationInfo(IntegrationCatalogCliTestBase): + def test_info_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "info", "stellar-agent"], project + ) + assert result.exit_code == 0, result.output + assert "Stellar Agent" in result.output + assert "stellar-agent" in result.output + assert "v1.3.0" in result.output + + def test_info_not_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "info", "does-not-exist"], project + ) + assert result.exit_code == 1 + assert "not found" in result.output + + def test_info_not_found_escapes_query_markup(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + integration_id = "[red]does-not-exist[/red]" + + result = self._invoke( + ["integration", "info", integration_id], + project, + ) + + assert result.exit_code == 1 + assert integration_id in _normalize_cli_output(result.output) + + def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + # Empty catalog, but copilot is a registered built-in. + self._patch_catalog(monkeypatch, integrations=[]) + result = self._invoke(["integration", "info", "copilot"], project) + assert result.exit_code == 0, result.output + assert "Built-in integration" in result.output + + def test_info_escapes_catalog_markup(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION]) + + result = self._invoke( + ["integration", "info", self.MARKUP_INTEGRATION["id"]], + project, + ) + + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + for value in ( + self.MARKUP_INTEGRATION["id"], + self.MARKUP_INTEGRATION["name"], + self.MARKUP_INTEGRATION["version"], + self.MARKUP_INTEGRATION["description"], + self.MARKUP_INTEGRATION["author"], + self.MARKUP_INTEGRATION["license"], + self.MARKUP_INTEGRATION["repository"], + self.MARKUP_INTEGRATION["tags"][0], + self.MARKUP_INTEGRATION["_catalog_name"], + ): + assert value in output + + def test_info_unknown_with_local_config_error_shows_local_config_tip( + self, tmp_path, monkeypatch + ): + """`integration info ` falls back to the catalog-error branch + and must show local-config guidance, not 'Try again when online'.""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + cfg = project / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - [bad\n" + cfg.write_text(invalid_yaml, encoding="utf-8") + + result = self._invoke( + ["integration", "info", "definitely-not-real"], project + ) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "configuration file path shown above" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "Try again when online" not in normalized_output + + def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "http://insecure.example.com/catalog.json", + ) + + result = self._invoke( + ["integration", "info", "definitely-not-real"], project + ) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output + assert "unset it to use the configured catalog files" in normalized_output + assert "Try again when online" not in normalized_output diff --git a/tests/specify_cli/integrations/test_command_install.py b/tests/specify_cli/integrations/test_command_install.py new file mode 100644 index 0000000000..3ff1f94e8b --- /dev/null +++ b/tests/specify_cli/integrations/test_command_install.py @@ -0,0 +1,702 @@ +"""Tests for mirrored integration CLI behavior in test_command_install.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, + _normalize_cli_output, +) +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationInstall: + def test_install_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "install", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a Spec Kit project" in result.output + + def test_install_unknown_integration(self, tmp_path): + project = _init_project(tmp_path) + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "nonexistent"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_install_already_installed(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "copilot"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + plain = strip_ansi(result.output) + assert "already installed" in plain + normalized = " ".join(plain.split()) + assert "specify integration upgrade copilot" in normalized + assert "already the default integration" in normalized + assert "No files were changed" in normalized + assert "specify integration uninstall copilot" not in normalized + + def test_install_already_installed_non_default_guides_use(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "install", "codex"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + output = strip_ansi(result.output) + normalized = " ".join(output.split()) + assert "already installed" in normalized + assert "specify integration use codex" in normalized + assert "specify integration upgrade codex" in normalized + assert "specify integration uninstall codex" not in normalized + + def test_install_different_when_one_exists(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "install", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + plain = strip_ansi(result.output) + assert "Installed integrations: copilot" in plain + assert "Default integration: copilot" in plain + normalized = " ".join(plain.split()) + assert "To replace the default integration" in normalized + assert "specify integration switch claude" in normalized + assert "To install 'claude' alongside" in normalized + assert "retry the same install command with --force" in normalized + + def test_install_multi_safe_integration(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "installed successfully" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["default_integration"] == "claude" + assert data["integration_state_schema"] == 1 + assert data["installed_integrations"] == ["claude", "codex"] + assert data["integration_settings"]["claude"]["invoke_separator"] == "-" + assert data["integration_settings"]["codex"]["invoke_separator"] == "-" + + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "claude") + init_options = project / ".specify" / "init-options.json" + opts = json.loads(init_options.read_text(encoding="utf-8")) + opts["speckit_version"] = "0.6.1" + init_options.write_text(json.dumps(opts), encoding="utf-8") + + import specify_cli.integrations._commands as _int_cmds + + monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + + assert result.exit_code == 0, result.output + updated = json.loads(init_options.read_text(encoding="utf-8")) + assert updated["speckit_version"] == "0.8.11" + assert updated["integration"] == "claude" + assert updated["ai"] == "claude" + assert "context_file" not in updated + + def test_install_additional_preserves_shared_manifest(self, tmp_path): + project = _init_project(tmp_path, "claude") + shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" + before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) + assert before + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) + assert before <= after + + def test_install_multi_safe_migrates_legacy_state(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + int_json.write_text(json.dumps({ + "integration": "claude", + "version": "0.0.0", + }), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads(int_json.read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["default_integration"] == "claude" + assert data["installed_integrations"] == ["claude", "codex"] + + def test_install_multi_unsafe_requires_force(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + plain = strip_ansi(result.output) + assert "Installed integrations: copilot" in plain + assert "multi-install safe" in plain + normalized = " ".join(plain.split()) + assert "To replace the default integration" in normalized + assert "specify integration switch claude" in normalized + assert "To install 'claude' alongside" in normalized + assert "retry the same install command with --force" in normalized + + def test_install_multi_unsafe_allowed_with_force(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + assert data["installed_integrations"] == ["copilot", "claude"] + + def test_install_into_bare_project(self, tmp_path): + """Install into a project with .specify/ but no integration.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "installed successfully" in result.output + + # integration.json written + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + + # Manifest created + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + # Claude uses skills directory (not commands) + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_install_bare_project_gets_shared_infra(self, tmp_path): + """Installing into a bare project should create shared scripts and templates.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + # Shared infrastructure should be present + assert (project / ".specify" / "scripts").is_dir() + assert (project / ".specify" / "templates").is_dir() + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + script_content = script.read_text(encoding="utf-8") + assert "/speckit-specify" in script_content + assert "/speckit.specify" not in script_content + + def test_install_dollar_skill_into_bare_project_gets_native_shared_refs( + self, tmp_path + ): + """A dollar-style integration supplies its prefix without a default.""" + project = tmp_path / "bare-codex" + project.mkdir() + (project / ".specify").mkdir() + + result = _run_in_project( + project, ["integration", "install", "codex", "--script", "sh"] + ) + + assert result.exit_code == 0, result.output + plan = project / ".specify" / "templates" / "plan-template.md" + plan_content = plan.read_text(encoding="utf-8") + assert "$speckit-plan" in plan_content + assert "/speckit-plan" not in plan_content + + def test_install_defers_extension_commands_until_use(self, tmp_path): + """Installing a second integration does not register enabled extensions. + + Maintainer-requested behavior for #2886: extension command back-fill is + limited to ``integration use`` / ``switch`` / ``upgrade``. Plain + ``install`` only adds the integration; selecting it with ``use`` then + registers the enabled extensions for that agent. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered + assert "codex" not in registered, "precondition: codex not yet installed" + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Install alone does not back-fill the git extension for the secondary + # agent. + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered, "existing agent registration preserved" + assert "codex" not in registered + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered, "use should register extension commands (#2886)" + assert ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_install_does_not_register_disabled_extensions(self, tmp_path): + """A disabled extension must not be registered for a newly installed agent.""" + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + registry_path = project / ".specify" / "extensions" / ".registry" + git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"] + assert git_meta["enabled"] is False + assert "codex" not in git_meta["registered_commands"] + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path): + """A non-active skills-mode agent gets extension artifacts only on use. + + Plain ``install`` has no extension side effects. Once the secondary + Copilot ``--skills`` integration is selected with ``use``, it becomes the + active agent and receives extension skills. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + # Copilot is not multi_install_safe, so --force is required to add it + # alongside the existing default integration. + result = _run_in_project(project, [ + "integration", "install", "copilot", + "--script", "sh", + "--integration-options", "--skills", + "--force", + ]) + assert result.exit_code == 0, result.output + + # Precondition that makes --skills load-bearing: copilot IS in skills + # mode, so its own core commands are scaffolded as skills. + assert ( + project / ".github" / "skills" / "speckit-specify" / "SKILL.md" + ).exists(), "precondition: copilot installed in skills mode" + + # The git extension is not registered for the non-active copilot agent + # during install. + git_meta = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + )["extensions"]["git"] + assert "copilot" not in git_meta["registered_commands"] + assert not ( + project / ".github" / "agents" / "speckit.git.feature.agent.md" + ).exists() + assert not ( + project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + result = _run_in_project(project, ["integration", "use", "copilot"]) + assert result.exit_code == 0, result.output + + git_meta = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + )["extensions"]["git"] + # `use` makes copilot active, so extension artifacts follow copilot's + # skills-mode layout. + assert "copilot" not in git_meta["registered_commands"] + assert "speckit-git-feature" in git_meta["registered_skills"] + assert not ( + project / ".github" / "agents" / "speckit.git.feature.agent.md" + ).exists() + assert ( + project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_extension_add_registers_active_integration_only(self, tmp_path): + """``extension add`` registers commands for the active integration only. + + Maintainer-requested behavior for #2948: with multiple integrations + installed, ``extension add`` must treat the project as single-active — + only the current integration gets the new extension's commands. + Non-active integrations receive them when selected via + ``integration use`` / ``switch`` (rescaffold). + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered, "active integration gets the extension" + assert "codex" not in registered, ( + "non-active integration must not be registered on add (#2948)" + ) + assert ( + project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + # Selecting the other integration rescaffolds it with the extension. + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered, "use registers extensions for the new active agent" + assert ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_extension_add_generic_active_does_not_backfill_other_agents(self, tmp_path): + """A recorded but unsupported active key (``generic``) must not + fall back to registering every detected agent. + + ``generic`` is deliberately excluded from ``AGENT_CONFIGS`` because + its output directory is only known via ``--commands-dir``, not a + static config. Before the fix, treating that active key like "no + active integration recorded" made the fallback register the + extension for every other detected agent — exactly the multi-target + behavior #2948 is meant to stop. + """ + project = _init_project( + tmp_path, "generic", + integration_options="--commands-dir .myagent/commands", + ) + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, result.output + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" not in registered, ( + "a recorded but unsupported active key must not target other " + "detected agents (#2948)" + ) + + def test_extension_add_malformed_ai_value_fails_closed(self, tmp_path): + """A recorded but malformed ``ai`` value (e.g. a list) must not be + treated as "no active integration recorded" and must not crash. + + Before the fix, ``init_options.get("ai")`` being falsy (``[]``, + ``""``, ``0``) triggered the same all-agents fallback as a missing + key, and a *truthy* non-string value (e.g. a non-empty list) would + reach ``AGENT_CONFIGS.get(active_agent)`` and raise ``TypeError`` + because a list is unhashable. Corrupted init-options must instead + fail closed: register nothing rather than crash or back-fill every + detected agent. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + init_options_path = project / ".specify" / "init-options.json" + init_options = json.loads(init_options_path.read_text(encoding="utf-8")) + init_options["ai"] = [] + init_options_path.write_text(json.dumps(init_options), encoding="utf-8") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert registered == {}, ( + "a malformed recorded 'ai' value must fail closed, not " + "back-fill every detected agent (#2948)" + ) + + def test_extension_add_corrupted_init_options_file_fails_closed(self, tmp_path): + """A present-but-unparseable init-options.json must fail closed too, + not be treated the same as "no file at all". + + ``load_init_options`` returns ``{}`` for a corrupted/unreadable + file just like it does for a missing file, so a naive "no active + agent recorded" check based on ``load_init_options`` alone can't + tell a legacy pre-init-options project (legitimate all-agent + fallback) apart from a corrupted-but-present file for a #2948 + project (must fail closed). Corrupting the file after a normal + init must not reintroduce the all-agent fallback. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + init_options_path = project / ".specify" / "init-options.json" + init_options_path.write_text("{not valid json", encoding="utf-8") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert registered == {}, ( + "a corrupted init-options.json must fail closed, not be " + "treated like a legacy project missing the file entirely (#2948)" + ) + + def test_extension_add_dangling_init_options_symlink_fails_closed(self, tmp_path): + """A dangling init-options.json symlink must fail closed too, not be + treated the same as "no file at all". + + ``Path.exists()`` follows symlinks and returns False for a broken + symlink whose target doesn't exist, so a naive presence check based + on ``Path.exists()`` alone mistakes a dangling symlink for "no file" + and falls back to registering every detected agent. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + init_options_path = project / ".specify" / "init-options.json" + init_options_path.unlink() + init_options_path.symlink_to(project / ".specify" / "does-not-exist.json") + assert not init_options_path.exists() # sanity: dangling + assert init_options_path.is_symlink() + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert registered == {}, ( + "a dangling init-options.json symlink must fail closed, not be " + "treated like a legacy project missing the file entirely (#2948)" + ) + + +class TestScriptTypeValidation: + def test_invalid_script_type_rejected(self, tmp_path): + """--script with an invalid value should fail with a clear error.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "bash", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Invalid script type" in result.output + + def test_valid_script_types_accepted(self, tmp_path): + """Both 'sh' and 'ps' should be accepted.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + +class TestIntegrationInstallDiagnostics(IntegrationCatalogCliTestBase): + def test_integration_install_failure_reports_phase_target_and_rollback( + self, tmp_path, monkeypatch + ): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations.base import IntegrationBase + + class BrokenIntegration(IntegrationBase): + key = "broken-test" + config = { + "name": "Broken Test", + "folder": ".broken/", + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".broken/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + + def setup(self, project_root, manifest, **kwargs): + raise OSError("setup exploded\nwith context") + + def teardown(self, project_root, manifest, force=False): + raise OSError("rollback exploded") + + project = self._make_project(tmp_path) + monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration()) + + result = self._invoke(["integration", "install", "broken-test"], project) + normalized = _normalize_cli_output(result.output) + + assert result.exit_code == 1, result.output + assert "Failed to rollback integration 'broken-test'" in normalized + assert "rollback exploded" in normalized + assert "Failed to install integration 'broken-test'" in normalized + assert "setup exploded with context" in normalized diff --git a/tests/specify_cli/integrations/test_command_list.py b/tests/specify_cli/integrations/test_command_list.py new file mode 100644 index 0000000000..5d11931a3d --- /dev/null +++ b/tests/specify_cli/integrations/test_command_list.py @@ -0,0 +1,175 @@ +"""Tests for mirrored integration CLI behavior in test_command_list.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401 +from tests.specify_cli.integrations._catalog_helpers import IntegrationListCatalogTestBase +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationList: + def test_list_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a Spec Kit project" in result.output + + def test_list_shows_installed(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output + + def test_list_shows_available_integrations(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Should show multiple integrations + assert "claude" in result.output + assert "gemini" in result.output + assert "zed" in result.output + + def test_list_shows_multi_install_safe_status(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "Multi-install" in result.output + assert "Safe" in result.output + assert _integration_list_row_cells(result.output, "claude")[-1] == "yes" + assert _integration_list_row_cells(result.output, "copilot")[-1] == "no" + + def test_list_rejects_newer_integration_state_schema(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + data = json.loads(int_json.read_text(encoding="utf-8")) + data["integration_state_schema"] = 99 + int_json.write_text(json.dumps(data), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + normalized = " ".join(result.output.split()) + assert "schema 99" in normalized + assert "only supports schema 1" in normalized + + +class TestIntegrationListCatalog(IntegrationListCatalogTestBase): + def test_list_catalog_flag(self, tmp_path, monkeypatch): + """--catalog should show catalog entries.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "test-agent": { + "id": "test-agent", + "name": "Test Agent", + "version": "1.0.0", + "description": "A test agent", + "tags": ["cli"], + }, + }, + } + + import specify_cli.authentication.http as _auth_http + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url if isinstance(url, str) else url.full_url + self._offset = 0 + + def read(self, size=-1): + if size == -1: + chunk = self._data[self._offset:] + self._offset = len(self._data) + else: + chunk = self._data[self._offset:self._offset + size] + self._offset += len(chunk) + return chunk + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", + lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url)) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list", "--catalog"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "test-agent" in result.output + assert "Test Agent" in result.output + + def test_list_without_catalog_still_works(self, tmp_path): + """Default list (no --catalog) works as before.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output diff --git a/tests/specify_cli/integrations/test_command_scaffold.py b/tests/specify_cli/integrations/test_command_scaffold.py new file mode 100644 index 0000000000..bac9e6c154 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_scaffold.py @@ -0,0 +1,93 @@ +"""Tests for the ``specify integration scaffold`` command.""" + +from pathlib import Path # noqa: F401 + +from typer.testing import CliRunner + +from specify_cli import app +from tests.conftest import strip_ansi +from tests.integrations._integration_scaffold_helpers import integration_repo_root as _repo_root + + +runner = CliRunner() + +def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch): + root = _repo_root(tmp_path) + monkeypatch.chdir(root) + + result = runner.invoke(app, [ + "integration", "scaffold", "my-agent", + "--type", "markdown", + ], catch_exceptions=False) + + output = strip_ansi(result.output) + integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py" + test_file = root / "tests" / "integrations" / "test_integration_my_agent.py" + + assert result.exit_code == 0 + assert integration_file.exists() + assert test_file.exists() + assert "Created integration scaffold: my-agent" in output + assert "Register MyAgentIntegration" in output + + content = integration_file.read_text(encoding="utf-8") + assert "class MyAgentIntegration(MarkdownIntegration):" in content + assert 'key = "my-agent"' in content + assert '"folder": ".my-agent/"' in content + assert '"extension": ".md"' in content + assert "multi_install_safe = False" in content + + test_content = test_file.read_text(encoding="utf-8") + assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content + assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content + assert "assert integration.multi_install_safe is False" in test_content + +def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch): + root = _repo_root(tmp_path) + monkeypatch.chdir(root) + + result = runner.invoke(app, [ + "integration", "scaffold", "my-agent", + "--type", "xml", + ]) + + output = strip_ansi(result.output) + assert result.exit_code == 2 + assert "Invalid value for '--type'" in output + assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists() + +def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch): + root = _repo_root(tmp_path) + monkeypatch.chdir(root) + + import specify_cli.integration_scaffold as scaffold_module + + def boom(*args, **kwargs): + raise PermissionError("Permission denied: read-only checkout") + + monkeypatch.setattr(scaffold_module, "scaffold_integration", boom) + + result = runner.invoke(app, [ + "integration", "scaffold", "my-agent", + "--type", "markdown", + ], catch_exceptions=False) + + output = strip_ansi(result.output) + assert result.exit_code == 1 + assert "Error:" in output + assert "Permission denied" in output + +def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch): + root = _repo_root(tmp_path) + monkeypatch.chdir(root) + + result = runner.invoke(app, [ + "integration", "scaffold", "my-agent", + "--type", "YAML", + ], catch_exceptions=False) + + assert result.exit_code == 0, strip_ansi(result.output) + content = ( + root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py" + ).read_text(encoding="utf-8") + assert "class MyAgentIntegration(YamlIntegration):" in content diff --git a/tests/specify_cli/integrations/test_command_search.py b/tests/specify_cli/integrations/test_command_search.py new file mode 100644 index 0000000000..9a0a40244b --- /dev/null +++ b/tests/specify_cli/integrations/test_command_search.py @@ -0,0 +1,199 @@ +"""Tests for TestIntegrationSearch.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 + +import pytest # noqa: F401 + +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, + _normalize_cli_output, +) + +class TestIntegrationSearch(IntegrationCatalogCliTestBase): + def test_search_requires_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + result = self._invoke(["integration", "search"], project) + assert result.exit_code == 1 + assert "Not a Spec Kit project" in result.output + + def test_search_lists_all(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Found 2 integration(s)" in result.output + assert "acme-coder" in result.output + assert "stellar-agent" in result.output + assert "specify integration install stellar-agent" not in normalized_output + assert "Only built-in integration IDs can be installed" in normalized_output + + def test_search_validates_integration_json_before_catalog_lookup( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + (project / ".specify" / "integration.json").write_text( + "{bad json\n", encoding="utf-8" + ) + + from specify_cli.integrations.catalog import IntegrationCatalog + + def fail_search(self, **kwargs): + raise AssertionError("catalog search should not be called") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1 + assert "contains invalid JSON" in normalized_output + assert "integration.json" in normalized_output + + def test_search_rejects_non_utf8_integration_json_before_catalog_lookup( + self, tmp_path, monkeypatch + ): + """A non-UTF8 ``integration.json`` must surface a clear error and + avoid falling through to the catalog lookup, mirroring the malformed-JSON + case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``.""" + project = self._make_project(tmp_path) + # 0xFF is invalid as the leading byte of any UTF-8 sequence, so + # ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``. + (project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00") + + from specify_cli.integrations.catalog import IntegrationCatalog + + def fail_search(self, **kwargs): + raise AssertionError("catalog search should not be called") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1 + assert "not valid UTF-8" in normalized_output + assert "integration.json" in normalized_output + + def test_search_filters_by_tag(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search", "--tag", "acme"], project) + assert result.exit_code == 0, result.output + assert "Found 1 integration(s)" in result.output + assert "acme-coder" in result.output + assert "stellar-agent" not in result.output + + def test_search_filters_by_author(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "search", "--author", "stellar-labs"], project + ) + assert result.exit_code == 0, result.output + assert "Found 1 integration(s)" in result.output + assert "stellar-agent" in result.output + + def test_search_no_match_hint(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "search", "--tag", "nope"], project + ) + assert result.exit_code == 0, result.output + assert "No integrations found" in result.output + assert "specify integration search" in result.output + + def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search", "acme"], project) + assert result.exit_code == 0, result.output + # acme-coder is flagged _install_allowed=False, so we should warn + assert "Not directly installable" in result.output + + def test_search_escapes_catalog_markup(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION]) + + result = self._invoke(["integration", "search"], project) + + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + for value in ( + self.MARKUP_INTEGRATION["id"], + self.MARKUP_INTEGRATION["name"], + self.MARKUP_INTEGRATION["version"], + self.MARKUP_INTEGRATION["description"], + self.MARKUP_INTEGRATION["author"], + self.MARKUP_INTEGRATION["tags"][0], + self.MARKUP_INTEGRATION["_catalog_name"], + ): + assert value in output + + def test_search_local_config_error_shows_local_config_tip( + self, tmp_path, monkeypatch + ): + """`integration search` must point at .specify/integration-catalogs.yml + for local-config errors (not the generic 'temporarily unavailable').""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + # Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError. + cfg = project / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - [bad\n" + cfg.write_text(invalid_yaml, encoding="utf-8") + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "configuration file path shown above" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "temporarily unavailable" not in normalized_output + + def test_search_invalid_env_catalog_url_shows_env_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "http://insecure.example.com/catalog.json", + ) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output + assert "unset it to use the configured catalog files" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "temporarily unavailable" not in normalized_output + + def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ") + + from specify_cli.integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + ) + + def fail_search(self, **kwargs): + raise IntegrationCatalogError("catalog offline") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "temporarily unavailable" in normalized_output + assert ( + "SPECKIT_INTEGRATION_CATALOG_URL environment variable" + not in normalized_output + ) diff --git a/tests/specify_cli/integrations/test_command_status.py b/tests/specify_cli/integrations/test_command_status.py new file mode 100644 index 0000000000..d97acf91f0 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_status.py @@ -0,0 +1,835 @@ +"""Tests for mirrored integration CLI behavior in test_command_status.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationStatus: + def test_status_requires_speckit_project(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(app, ["integration", "status"]) + assert result.exit_code != 0 + assert "Not a Spec Kit project" in result.output + + def test_status_reports_healthy_project(self, copilot_project): + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code == 0 + assert "Integration status: OK" in result.output + assert "Default integration: copilot" in result.output + assert "Installed integrations: copilot" in result.output + assert "Shared templates target alignment: copilot" in result.output + assert "Modified managed files: 0" in result.output + assert "Missing managed files: 0" in result.output + + def test_status_json_reports_healthy_project(self, copilot_project): + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["default_integration"] == "copilot" + assert payload["installed_integrations"] == ["copilot"] + assert payload["recorded_installed_integrations"] == ["copilot"] + assert payload["manifest_checked_integrations"] == ["copilot", "speckit"] + assert payload["multi_install_safe"] is True + assert payload["shared_templates_target_alignment"] == "copilot" + assert "shared_templates_aligned_to" not in payload + assert payload["findings"] == [] + + def test_status_reports_invalid_integration_json(self, copilot_project): + (copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "integration-state-unreadable" in result.output + assert "invalid JSON" in result.output + assert "Detail:" in result.output + assert "Multi-install safe: unknown" in result.output + assert "Traceback" not in result.output + + def test_status_json_reports_unknown_multi_install_safety_when_state_unreadable( + self, + copilot_project, + ): + (copilot_project / ".specify" / "integration.json").write_text("{", encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["multi_install_safe"] is None + assert payload["manifest_checked_integrations"] == [] + assert payload["findings"][0]["code"] == "integration-state-unreadable" + assert "Detail:" in payload["findings"][0]["message"] + + def test_status_reports_supported_schema_for_newer_integration_state(self, copilot_project): + state_path = copilot_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["integration_state_schema"] = 99 + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["findings"][0]["code"] == "integration-state-unreadable" + assert "schema 99" in payload["findings"][0]["message"] + assert "supported schema: 1" in payload["findings"][0]["message"] + + def test_status_reports_missing_integration_json(self, copilot_project): + (copilot_project / ".specify" / "integration.json").unlink() + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "integration-state-missing" in result.output + assert ".specify/integration.json is missing" in result.output + assert "Multi-install safe: unknown" in result.output + + def test_status_json_reports_unknown_multi_install_safety_when_state_missing( + self, + copilot_project, + ): + (copilot_project / ".specify" / "integration.json").unlink() + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["multi_install_safe"] is None + assert payload["manifest_checked_integrations"] == [] + assert payload["findings"][0]["code"] == "integration-state-missing" + + def test_status_json_reports_no_installed_integrations_as_warning(self, copilot_project): + state_path = copilot_project / ".specify" / "integration.json" + state_path.write_text( + json.dumps({ + "version": "test", + "integration_state_schema": 1, + "installed_integrations": [], + }), + encoding="utf-8", + ) + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "warning" + assert payload["installed_integrations"] == [] + assert payload["multi_install_safe"] is None + assert payload["manifest_checked_integrations"] == ["speckit"] + assert payload["findings"][0]["code"] == "no-installed-integrations" + assert "speckit" in payload["manifests"] + assert payload["manifests"]["speckit"]["readable"] is True + + def test_status_checks_shared_manifest_when_no_integrations_installed(self, copilot_project): + state_path = copilot_project / ".specify" / "integration.json" + state_path.write_text( + json.dumps({ + "version": "test", + "integration_state_schema": 1, + "installed_integrations": [], + }), + encoding="utf-8", + ) + (copilot_project / ".specify" / "integrations" / "speckit.manifest.json").unlink() + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["installed_integrations"] == [] + assert payload["manifest_checked_integrations"] == ["speckit"] + assert payload["unchecked_manifests"] == 1 + assert any( + item["code"] == "no-installed-integrations" + for item in payload["findings"] + ) + assert any( + item["code"] == "manifest-missing" + and item["integration"] == "speckit" + for item in payload["findings"] + ) + + def test_status_json_reports_missing_default_integration_as_error(self, claude_project): + state_path = claude_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state.pop("default_integration", None) + state.pop("integration", None) + state["installed_integrations"] = ["claude"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["status"] == "error" + assert payload["default_integration"] is None + assert any( + item["code"] == "default-integration-missing" + for item in payload["findings"] + ) + + def test_status_ignores_non_list_raw_installed_integrations(self, copilot_project): + state_path = copilot_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state.pop("default_integration", None) + state.pop("integration", None) + state["installed_integrations"] = "copilot" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "warning" + assert payload["installed_integrations"] == [] + assert payload["recorded_installed_integrations"] == [] + assert payload["manifest_checked_integrations"] == ["speckit"] + assert payload["multi_install_safe"] is None + assert [item["code"] for item in payload["findings"]] == [ + "installed-integrations-invalid", + "no-installed-integrations", + ] + + def test_status_reports_non_list_raw_installed_integrations_with_default(self, copilot_project): + state_path = copilot_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["default_integration"] = "copilot" + state["integration"] = "copilot" + state["installed_integrations"] = "copilot" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "warning" + assert payload["installed_integrations"] == ["copilot"] + assert payload["recorded_installed_integrations"] == [] + assert payload["manifest_checked_integrations"] == ["copilot", "speckit"] + assert payload["multi_install_safe"] is None + assert [item["code"] for item in payload["findings"]] == [ + "installed-integrations-invalid", + ] + + def test_status_reports_default_integration_not_installed(self, claude_project): + state_path = claude_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["default_integration"] = "codex" + state["integration"] = "codex" + state["installed_integrations"] = ["claude"] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["default_integration"] == "codex" + assert payload["installed_integrations"] == ["codex", "claude"] + assert payload["recorded_installed_integrations"] == ["claude"] + assert payload["manifest_checked_integrations"] == ["claude", "speckit"] + assert any( + item["code"] == "default-integration-not-installed" + and "Default integration 'codex' is not listed" in item["message"] + for item in payload["findings"] + ) + assert "codex" not in payload["manifests"] + assert not any( + item["code"] == "manifest-missing" and item.get("integration") == "codex" + for item in payload["findings"] + ) + + def test_status_checks_effective_default_manifest_when_raw_installed_is_empty(self, claude_project): + state_path = claude_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = [] + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["installed_integrations"] == ["claude"] + assert payload["recorded_installed_integrations"] == [] + assert payload["manifest_checked_integrations"] == ["claude", "speckit"] + assert payload["multi_install_safe"] is None + assert payload["manifests"]["claude"]["readable"] is True + assert any( + item["code"] == "default-integration-not-installed" + for item in payload["findings"] + ) + + def test_status_reports_missing_manifest(self, copilot_project): + (copilot_project / ".specify" / "integrations" / "copilot.manifest.json").unlink() + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "manifest-missing" in result.output + assert "Manifest for integration 'copilot' is missing" in result.output + + def test_status_reports_unreadable_manifest_in_json_summary(self, copilot_project): + _write_invalid_manifest(copilot_project, "copilot") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["unchecked_manifests"] == 1 + assert payload["manifests"]["copilot"]["readable"] is False + assert payload["manifests"]["copilot"]["missing_files"] == [] + assert payload["manifests"]["copilot"]["modified_files"] == [] + + def test_status_reports_modified_managed_files_without_failing(self, copilot_project): + manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + (copilot_project / first_rel).write_text("MODIFIED CONTENT\n", encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code == 0 + assert "Integration status: WARNING" in result.output + assert "managed-files-modified" in result.output + assert "Modified managed files: 1" in result.output + + def test_status_reports_missing_managed_files(self, copilot_project): + manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + (copilot_project / first_rel).unlink() + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "managed-files-missing" in result.output + assert "Missing managed files: 1" in result.output + + def test_status_reports_missing_shared_managed_files(self, copilot_project): + shared_file = copilot_project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_file.exists() + shared_file.unlink() + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "managed-files-missing" in result.output + assert "shared Spec Kit infrastructure" in result.output + assert "Missing managed files: 1" in result.output + + def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, monkeypatch): + from specify_cli.integration_status import _manifest_file_status + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "proj" + project.mkdir() + tracked = project / "tracked.md" + tracked.write_text("content\n", encoding="utf-8") + manifest = IntegrationManifest("test", project, version="test") + manifest.record_existing("tracked.md") + + def fail_exists(self): + raise AssertionError(f"Path.exists() should not be used for {self}") + + monkeypatch.setattr(Path, "exists", fail_exists) + + missing, modified, invalid, valid = _manifest_file_status( + manifest, + project.resolve(), + ) + + assert missing == [] + assert modified == [] + assert invalid == [] + assert valid == ["tracked.md"] + + def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch): + def fail_exists(self): + raise AssertionError(f"Path.exists() should not be used for {self}") + + monkeypatch.setattr(Path, "exists", fail_exists) + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["manifests"]["copilot"]["readable"] is True + + def test_status_reports_unresolved_project_root_without_crashing(self, copilot_project, monkeypatch): + original_resolve = Path.resolve + failed = {"done": False} + + def fail_first_project_root_resolve(self, *args, **kwargs): + if self == copilot_project and not failed["done"]: + failed["done"] = True + raise RuntimeError("symlink loop") + return original_resolve(self, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_first_project_root_resolve) + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "warning" + assert any(item["code"] == "project-root-unresolved" for item in payload["findings"]) + + def test_status_loads_manifests_when_project_root_resolution_keeps_failing( + self, + copilot_project, + monkeypatch, + ): + original_resolve = Path.resolve + + def fail_project_root_resolve(self, *args, **kwargs): + if self == copilot_project: + raise RuntimeError("symlink loop") + return original_resolve(self, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_project_root_resolve) + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["status"] == "warning" + assert payload["manifests"]["copilot"]["readable"] is True + assert payload["manifests"]["speckit"]["readable"] is True + assert any(item["code"] == "project-root-unresolved" for item in payload["findings"]) + + def test_status_uses_lexical_manifest_paths_when_project_root_resolution_falls_back(self, tmp_path): + from specify_cli.integration_status import _manifest_file_status + from specify_cli.integrations.manifest import IntegrationManifest + + real_project = tmp_path / "real-project" + real_project.mkdir() + tracked = real_project / "tracked.md" + tracked.write_text("content\n", encoding="utf-8") + symlinked_project = tmp_path / "symlinked-project" + try: + symlinked_project.symlink_to(real_project, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + manifest = IntegrationManifest("test", real_project, version="test") + manifest.record_existing("tracked.md") + manifest.project_root = symlinked_project.absolute() + + missing, modified, invalid, valid = _manifest_file_status( + manifest, + symlinked_project.absolute(), + project_root_is_resolved=False, + ) + + assert missing == [] + assert modified == [] + assert invalid == [] + assert valid == ["tracked.md"] + + def test_status_treats_resolve_runtime_error_as_invalid_path(self, tmp_path, monkeypatch): + from specify_cli.integration_status import _manifest_file_status + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "proj" + project.mkdir() + tracked = project / "tracked.md" + tracked.write_text("content\n", encoding="utf-8") + manifest = IntegrationManifest("test", project, version="test") + manifest.record_existing("tracked.md") + project_root_resolved = project.resolve() + original_resolve = Path.resolve + + def fail_project_parent_resolve(self, *args, **kwargs): + if self == project: + raise RuntimeError("symlink loop") + return original_resolve(self, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_project_parent_resolve) + + missing, modified, invalid, valid = _manifest_file_status( + manifest, + project_root_resolved, + ) + + assert missing == [] + assert modified == [] + assert invalid == ["tracked.md"] + assert valid == [] + + def test_status_does_not_mask_runtime_errors_from_manifest_load(self, copilot_project, monkeypatch): + from specify_cli import integration_status as status_module + + def fail_load(key, project_root, **kwargs): + raise RuntimeError(f"unexpected manifest loader bug for {key}") + + monkeypatch.setattr(status_module.IntegrationManifest, "load", fail_load) + + with pytest.raises(RuntimeError, match="unexpected manifest loader bug"): + status_module.build_integration_status_report(copilot_project) + + def test_status_treats_dangling_symlink_as_missing(self, copilot_project): + manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + target = copilot_project / first_rel + target.unlink() + try: + target.symlink_to(copilot_project / "missing-target") + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert first_rel in payload["manifests"]["copilot"]["missing_files"] + assert first_rel not in payload["manifests"]["copilot"]["modified_files"] + + def test_status_treats_windows_style_dangling_symlink_as_missing(self, tmp_path, monkeypatch): + from specify_cli.integration_status import _manifest_file_status + from specify_cli.integrations.manifest import IntegrationManifest + + project = tmp_path / "proj" + project.mkdir() + tracked = project / "tracked.md" + tracked.write_text("content\n", encoding="utf-8") + regular_stat = tracked.lstat() + + manifest = IntegrationManifest("test", project, version="test") + manifest.record_existing("tracked.md") + + tracked.unlink() + try: + tracked.symlink_to(project / "missing-target") + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + original_lstat = Path.lstat + original_is_symlink = Path.is_symlink + + def windows_style_lstat(self): + if self == tracked: + return regular_stat + return original_lstat(self) + + def windows_style_is_symlink(self): + if self == tracked: + return True + return original_is_symlink(self) + + monkeypatch.setattr(Path, "lstat", windows_style_lstat) + monkeypatch.setattr(Path, "is_symlink", windows_style_is_symlink) + + missing, modified, invalid, valid = _manifest_file_status( + manifest, + project.resolve(), + ) + + assert missing == ["tracked.md"] + assert modified == [] + assert invalid == [] + assert valid == ["tracked.md"] + + def test_strip_extended_length_prefix_normalizes_windows_paths(self): + from specify_cli.integration_status import _strip_extended_length_prefix + + # Build the prefixed strings explicitly so the test is meaningful on + # every platform (POSIX won't parse backslash separators, but the + # helper operates on the string form). Compare Path objects rather than + # their str() form: on Windows pathlib renders a UNC root with a + # trailing separator (``\\server\share\``), so an exact string match is + # brittle, whereas Path equality captures the intended semantics on + # both POSIX and Windows. + bs = "\\" + assert _strip_extended_length_prefix( + Path(f"{bs}{bs}?{bs}C:{bs}proj") + ) == Path(f"C:{bs}proj") + assert _strip_extended_length_prefix( + Path(f"{bs}{bs}?{bs}UNC{bs}server{bs}share") + ) == Path(f"{bs}{bs}server{bs}share") + # Paths without the prefix are returned unchanged. + assert _strip_extended_length_prefix(Path("relative/path")) == Path("relative/path") + + def test_is_within_project_tolerates_extended_length_prefix(self): + from specify_cli.integration_status import _is_within_project + + # A readlink result on POSIX never carries the prefix, so an in-project + # child is contained and an outside path is not. The Windows + # prefix-stripping branch is exercised by the dangling-symlink tests on + # Windows CI; here we lock in the cross-platform containment contract. + root = Path("/tmp/project").resolve() + assert _is_within_project(root, root / "child") + assert not _is_within_project(root, Path("/tmp/other").resolve()) + + def test_status_reports_unsafe_manifest_paths_without_hashing_them(self, tmp_path, copilot_project): + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("outside project\n", encoding="utf-8") + link = copilot_project / "outside-link" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_data["files"]["outside-link/secret.txt"] = "wrong" + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["invalid_manifest_paths"] == 1 + assert "outside-link/secret.txt" in payload["manifests"]["copilot"]["invalid_files"] + assert "outside-link/secret.txt" not in payload["manifests"]["copilot"]["modified_files"] + + def test_status_reports_tracked_symlink_target_escape_as_invalid(self, tmp_path, copilot_project, monkeypatch): + outside = tmp_path / "outside" + outside.mkdir() + outside_file = outside / "secret.txt" + outside_file.write_text("outside project\n", encoding="utf-8") + + manifest_path = copilot_project / ".specify" / "integrations" / "copilot.manifest.json" + tracked_files = json.loads(manifest_path.read_text(encoding="utf-8"))["files"] + first_rel = next(iter(tracked_files)) + tracked_path = copilot_project / first_rel + tracked_path.unlink() + try: + tracked_path.symlink_to(outside_file) + except OSError as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + original_stat = Path.stat + + def fail_tracked_symlink_stat(self, *args, **kwargs): + follows_symlinks = kwargs.get("follow_symlinks", True) + if self == tracked_path and follows_symlinks: + raise AssertionError("Path.stat() should not follow tracked symlinks") + return original_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", fail_tracked_symlink_stat) + + result = _run_in_project(copilot_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["invalid_manifest_paths"] == 1 + assert first_rel in payload["manifests"]["copilot"]["invalid_files"] + assert first_rel not in payload["manifests"]["copilot"]["modified_files"] + + def test_status_reports_unsafe_multi_install_combination(self, copilot_project): + from specify_cli.integrations.manifest import IntegrationManifest + + state_path = copilot_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["copilot", "claude"] + state["default_integration"] = "copilot" + state["integration"] = "copilot" + state_path.write_text(json.dumps(state), encoding="utf-8") + IntegrationManifest("claude", copilot_project, version="test").save() + + result = _run_in_project(copilot_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "unsafe-multi-install" in result.output + assert "Multi-install safe: no" in result.output + assert "specify integration switch " in result.output + + def test_status_treats_unknown_multi_install_as_unsafe(self, claude_project): + from specify_cli.integrations.manifest import IntegrationManifest + + state_path = claude_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["claude", "mystery"] + state["default_integration"] = "claude" + state["integration"] = "claude" + state_path.write_text(json.dumps(state), encoding="utf-8") + IntegrationManifest("mystery", claude_project, version="test").save() + + result = _run_in_project(claude_project, ["integration", "status"]) + + assert result.exit_code != 0 + assert "unknown-integration" in result.output + assert "unsafe-multi-install" in result.output + assert "remove the stale integration entry" in result.output + assert "Multi-install safe: no" in result.output + + def test_status_gives_actionable_suggestion_for_unknown_manifest(self, claude_project): + state_path = claude_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["mystery"] + state["default_integration"] = "mystery" + state["integration"] = "mystery" + state_path.write_text(json.dumps(state), encoding="utf-8") + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + manifest_finding = next( + item for item in payload["findings"] + if item["code"] == "manifest-missing" and item["integration"] == "mystery" + ) + assert "remove the stale integration entry" in manifest_finding["suggestion"] + assert "integration upgrade mystery" not in manifest_finding["suggestion"] + + def test_status_rejects_unsafe_integration_keys_before_manifest_lookup(self, tmp_path, claude_project): + state_path = claude_project / ".specify" / "integration.json" + unsafe_key = "../../../escape" + state_path.write_text( + json.dumps({ + "integration": unsafe_key, + "default_integration": unsafe_key, + "installed_integrations": [unsafe_key], + }), + encoding="utf-8", + ) + outside_manifest = tmp_path / "escape.manifest.json" + outside_manifest.write_text( + json.dumps({"integration": unsafe_key, "files": {}}), + encoding="utf-8", + ) + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert unsafe_key not in payload["manifests"] + assert payload["manifest_checked_integrations"] == ["speckit"] + assert any( + item["code"] == "integration-key-invalid" + and item["integration"] == unsafe_key + for item in payload["findings"] + ) + + def test_status_rejects_filename_invalid_integration_keys(self, claude_project): + state_path = claude_project / ".specify" / "integration.json" + unsafe_key = "bad:key" + state_path.write_text( + json.dumps({ + "integration": unsafe_key, + "default_integration": unsafe_key, + "installed_integrations": [unsafe_key], + }), + encoding="utf-8", + ) + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert any( + item["code"] == "integration-key-invalid" + and item["integration"] == unsafe_key + for item in payload["findings"] + ) + + def test_status_rejects_windows_reserved_integration_keys(self, claude_project): + state_path = claude_project / ".specify" / "integration.json" + unsafe_key = "CON" + state_path.write_text( + json.dumps({ + "integration": unsafe_key, + "default_integration": unsafe_key, + "installed_integrations": [unsafe_key], + }), + encoding="utf-8", + ) + + result = _run_in_project(claude_project, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert any( + item["code"] == "integration-key-invalid" + and item["integration"] == unsafe_key + for item in payload["findings"] + ) + + def test_status_reports_managed_file_collisions(self, claude_project): + from specify_cli.integrations.manifest import IntegrationManifest + + state_path = claude_project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["installed_integrations"] = ["claude", "codex"] + state["default_integration"] = "claude" + state["integration"] = "claude" + state_path.write_text(json.dumps(state), encoding="utf-8") + + claude_manifest = claude_project / ".specify" / "integrations" / "claude.manifest.json" + tracked_files = json.loads(claude_manifest.read_text(encoding="utf-8"))["files"] + shared_rel = next(iter(tracked_files)) + codex_manifest = IntegrationManifest("codex", claude_project, version="test") + codex_manifest.record_existing(shared_rel) + codex_manifest.save() + + result = _run_in_project(claude_project, ["integration", "status"]) + + assert result.exit_code == 0 + assert "managed-file-collision" in result.output + assert "Integration status: WARNING" in result.output + + def test_status_json_is_not_rich_rendered(self, tmp_path, monkeypatch): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + (project / ".specify" / "integration.json").write_text( + json.dumps({ + "integration": "[red]x[/red]", + "installed_integrations": ["[red]x[/red]"], + }), + encoding="utf-8", + ) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["integration", "status", "--json"]) + + assert result.exit_code != 0 + payload = json.loads(result.output) + assert payload["default_integration"] == "[red]x[/red]" + assert payload["installed_integrations"] == ["[red]x[/red]"] + + def test_status_text_escapes_rich_markup_from_project_state(self, tmp_path, monkeypatch): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + (project / ".specify" / "integration.json").write_text( + json.dumps({ + "integration": "[red]x[/red]", + "installed_integrations": ["[red]x[/red]"], + }), + encoding="utf-8", + ) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["integration", "status"]) + + assert result.exit_code != 0 + assert "Default integration: [red]x[/red]" in result.output + assert "Installed integrations: [red]x[/red]" in result.output diff --git a/tests/specify_cli/integrations/test_command_switch.py b/tests/specify_cli/integrations/test_command_switch.py new file mode 100644 index 0000000000..b1824102aa --- /dev/null +++ b/tests/specify_cli/integrations/test_command_switch.py @@ -0,0 +1,875 @@ +"""Tests for mirrored integration CLI behavior in test_command_switch.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, + _normalize_cli_output, +) +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationSwitch: + def test_switch_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "switch", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a Spec Kit project" in result.output + + def test_switch_unknown_target(self, tmp_path): + project = _init_project(tmp_path) + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "switch", "nonexistent"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Unknown integration" in result.output + + def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "codex", + "--script", "sh", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Could not read integration manifest" in result.output + + def test_switch_same_noop(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "switch", "copilot"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "already the default integration" in result.output + + def test_switch_same_force_refreshes_shared_templates(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + template.write_text("# custom shared template\n", encoding="utf-8") + script.write_text("# custom shared script\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "claude", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "shared infrastructure refreshed" in result.output + assert "managed shared infrastructure refreshed" not in result.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") + + def test_switch_installed_target_rejects_integration_options(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "switch", "codex", + "--integration-options", "--bogus", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "--integration-options cannot be used" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["default_integration"] == "claude" + + def test_switch_between_integrations(self, tmp_path): + project = _init_project(tmp_path, "claude") + # Verify claude files exist (claude uses skills) + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + assert "/speckit-specify" in shared_script.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "Switched to" in result.output + + # Old claude files removed + assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + # New default Copilot skills created + assert ( + project / ".github" / "skills" / "speckit-plan" / "SKILL.md" + ).exists() + assert "/speckit-specify" in shared_script.read_text(encoding="utf-8") + assert "/speckit.specify" not in shared_script.read_text(encoding="utf-8") + + # integration.json updated + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + def test_switch_rejects_conflicting_copilot_modes_before_uninstall( + self, tmp_path + ): + project = _init_project(tmp_path, "claude") + claude_skill = ( + project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + ) + before_state = json.loads( + (project / ".specify" / "integration.json").read_text( + encoding="utf-8" + ) + ) + + result = _run_in_project( + project, + [ + "integration", + "switch", + "copilot", + "--integration-options", + "--skills --commands", + "--script", + "sh", + ], + ) + + assert result.exit_code == 1 + assert "--skills and --commands are mutually exclusive" in result.output + assert claude_skill.exists() + assert not (project / ".github" / "skills").exists() + assert not (project / ".github" / "agents").exists() + after_state = json.loads( + (project / ".specify" / "integration.json").read_text( + encoding="utf-8" + ) + ) + assert after_state == before_state + + def test_switch_preserves_target_options_with_fallback_integration( + self, tmp_path + ): + project = _init_project(tmp_path, "claude") + install = _run_in_project( + project, + [ + "integration", + "install", + "opencode", + "--script", + "sh", + "--force", + ], + ) + assert install.exit_code == 0, install.output + + result = _run_in_project( + project, + [ + "integration", + "switch", + "copilot", + "--integration-options", + "--commands", + "--script", + "sh", + ], + ) + + assert result.exit_code == 0, result.output + assert ( + project / ".github" / "agents" / "speckit.plan.agent.md" + ).exists() + assert not (project / ".github" / "skills").exists() + state = json.loads( + (project / ".specify" / "integration.json").read_text( + encoding="utf-8" + ) + ) + assert state["integration_settings"]["copilot"]["parsed_options"] == { + "commands": True + } + + def test_switch_migrates_extension_commands(self, tmp_path): + """Switching should migrate extension commands to the new agent directory.""" + project = _init_project(tmp_path, "kimi") + + # Install the bundled git extension + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + # Verify git extension skills exist for kimi + kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md" + assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" + + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Git extension commands should exist for opencode + opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Git extension command should exist for opencode" + + # Old kimi extension skills should be removed + assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed" + + # Extension registry should be updated + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + registered_commands = registry["extensions"]["git"]["registered_commands"] + assert "opencode" in registered_commands + assert "kimi" not in registered_commands + + # Switch to claude + result = _run_in_project(project, [ + "integration", "switch", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Git extension skills should exist for claude + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert claude_git_feature.exists(), "Git extension skill should exist for claude" + + # Old opencode extension commands should be removed + assert not opencode_git_feature.exists(), "Old opencode extension command should be removed" + + # Extension registry should be updated + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + registered_commands = registry["extensions"]["git"]["registered_commands"] + assert "claude" in registered_commands + assert "opencode" not in registered_commands + + def test_switch_installed_target_backfills_extension_commands(self, tmp_path): + """Switching to an already-installed agent should register extensions.""" + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "claude" in registered + assert "codex" not in registered, "precondition: codex not yet installed" + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + codex_git_feature = ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ) + assert not codex_git_feature.exists() + + result = _run_in_project(project, ["integration", "switch", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered + assert codex_git_feature.exists() + + def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): + """Copilot --skills should receive extension skills, not .agent.md files.""" + project = _init_project(tmp_path, "opencode") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + result = _run_in_project(project, [ + "integration", "switch", "copilot", + "--script", "sh", + "--integration-options", "--skills", + ]) + assert result.exit_code == 0, result.output + + copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md" + assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode" + assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files" + + # Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it + skill_content = copilot_git_feature.read_text(encoding="utf-8") + assert "mode:" not in skill_content, ( + "Copilot skill frontmatter must not contain unsupported 'mode' field" + ) + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert "speckit-git-feature" in git_meta["registered_skills"] + assert "copilot" not in git_meta["registered_commands"] + + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Git extension command should exist for opencode" + assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed" + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert git_meta["registered_skills"] == [] + assert "opencode" in git_meta["registered_commands"] + assert "copilot" not in git_meta["registered_commands"] + + def test_switch_to_not_yet_installed_unregisters_old_preset_artifacts(self, tmp_path): + """Switching to a not-yet-installed integration must also clean up + the old agent's preset command overrides, mirroring the existing + extension cleanup on the same code path (#2948). + + Without this, a preset's command override -- including a custom + preset command -- rendered for the previous agent lingers as an + orphan once a different, not-yet-installed integration becomes the + new active agent. + """ + project = _init_project(tmp_path, "auggie") + + preset_src = tmp_path / "switch-cleanup-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Custom preset command\n---\nOverridden content\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "switch-cleanup-preset", + "name": "Switch Cleanup Preset", + "version": "1.0.0", + "description": "Test preset with a custom command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + import yaml + + (preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") + + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + + auggie_cmd = project / ".augment" / "commands" / "speckit.specify.md" + assert auggie_cmd.exists(), "sanity: preset command registered for auggie" + + registry_path = project / ".specify" / "presets" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["switch-cleanup-preset"]["registered_commands"] + assert "auggie" in registered, "sanity: auggie tracked before switch" + + # opencode is not yet installed in this project. + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + assert not auggie_cmd.exists(), ( + "old agent's preset command override must be removed on switch " + "to a not-yet-installed integration, mirroring the existing " + "extension cleanup on this same code path (#2948)" + ) + + opencode_cmd = project / ".opencode" / "commands" / "speckit.specify.md" + assert opencode_cmd.exists(), "preset command should be registered for the new agent" + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["switch-cleanup-preset"]["registered_commands"] + assert "auggie" not in registered, ( + "old agent's tracking must be dropped after switch cleanup" + ) + assert "opencode" in registered + + def test_switch_does_not_register_disabled_extensions(self, tmp_path): + """Disabled extensions should stay disabled and should not migrate commands.""" + project = _init_project(tmp_path, "opencode") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, result.output + + opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch" + + result = _run_in_project(project, [ + "integration", "switch", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent" + assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch" + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert git_meta["enabled"] is False + assert "claude" not in git_meta["registered_commands"] + assert "opencode" not in git_meta["registered_commands"] + + def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): + """Switching refreshes managed shared scripts to the target command style.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + assert shared_script.exists() + shared_content = shared_script.read_text(encoding="utf-8") + assert "/speckit-plan" in shared_content + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--integration-options", "--commands", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + assert shared_script.exists() + updated = shared_script.read_text(encoding="utf-8") + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated + + def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path): + """Regression for #2293: stale managed shared scripts get refreshed on switch.""" + import hashlib + + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") + + # Simulate a stale vendored script: write truncated content as bytes + # (write_text would translate \n→\r\n on Windows and break the hash) + # and update the speckit manifest hash so the stale copy is treated + # as "managed" (installed by spec-kit, not a user customization). + stale_bytes = b"#!/usr/bin/env bash\n# stale vendored copy\n" + shared_script.write_bytes(stale_bytes) + + manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = ( + hashlib.sha256(stale_bytes).hexdigest() + ) + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--integration-options", "--commands", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Stale managed file should be replaced by the target integration's rendered version. + updated = shared_script.read_text(encoding="utf-8") + assert "# stale vendored copy" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated + + def test_switch_preserves_user_customized_shared_infra(self, tmp_path): + """User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + + # User customization: append bytes but do NOT update manifest hash, + # so on-disk hash diverges from the recorded one. + original = shared_script.read_bytes() + custom_bytes = original + b"\n# user customization\n" + shared_script.write_bytes(custom_bytes) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--integration-options", "--commands", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert shared_script.read_bytes() == custom_bytes + assert "Preserved" in result.output + + def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): + """--refresh-shared-infra explicitly overwrites user customizations on switch.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") + rendered_bytes = shared_script.read_bytes() + + # User customization (hash diverges from manifest) + custom_bytes = rendered_bytes + b"\n# user customization\n" + shared_script.write_bytes(custom_bytes) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--integration-options", "--commands", + "--script", "sh", + "--refresh-shared-infra", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Customization is overwritten with the target integration's rendered version. + updated = shared_script.read_text(encoding="utf-8") + assert "# user customization" not in updated + assert "/speckit.plan" in updated + assert "/speckit-plan" not in updated + + def test_switch_preserves_recovered_files(self, tmp_path): + """Regression for #2918: files marked recovered in the manifest are not overwritten. + + When a file already exists on disk before init and is recorded with + ``recovered=True``, ``integration use``/``switch`` must not treat it as + managed even when the on-disk hash matches the manifest hash. + """ + import hashlib + + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + assert shared_script.is_file() + + # Simulate a team-customized file that was recorded as recovered: + # write custom content, then update the manifest to record its hash + # with the recovered flag set. + custom_bytes = b"#!/usr/bin/env bash\n# team custom workflow\nexit 0\n" + shared_script.write_bytes(custom_bytes) + + manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + rel = ".specify/scripts/bash/setup-tasks.sh" + manifest_data["files"][rel] = hashlib.sha256(custom_bytes).hexdigest() + manifest_data.setdefault("recovered_files", []).append(rel) + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Recovered file must NOT be overwritten — team content preserved. + assert shared_script.read_bytes() == custom_bytes + + def test_switch_skips_symlinked_parent_directory(self, tmp_path): + """Regression: if .specify/scripts/bash is a symlink, switch must not write through it. + + Copilot follow-up on #2375: leaf-only symlink check let writes escape + when an *ancestor* directory was symlinked outside the project root. + """ + import sys + if sys.platform.startswith("win"): + import pytest as _pytest + _pytest.skip("Symlink creation typically requires admin on Windows") + + project = _init_project(tmp_path, "claude") + bash_dir = project / ".specify" / "scripts" / "bash" + outside = tmp_path / "outside" + outside.mkdir() + for child in bash_dir.iterdir(): + child.rename(outside / child.name) + bash_dir.rmdir() + bash_dir.symlink_to(outside, target_is_directory=True) + sentinel = (outside / "common.sh").read_bytes() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # Symlinked tree reported, not written through. + assert "symlink" in result.output.lower() + # Outside dir contents unchanged. + assert (outside / "common.sh").read_bytes() == sentinel + + def test_switch_force_alone_does_not_overwrite_shared_customizations(self, tmp_path): + """--force (uninstall semantics) must NOT overwrite shared-infra customizations. + + Regression: ensures the decoupling of --force and --refresh-shared-infra. + """ + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + bundled_bytes = shared_script.read_bytes() + + custom_bytes = bundled_bytes + b"\n# user customization\n" + shared_script.write_bytes(custom_bytes) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + # --force alone preserves the customization + assert shared_script.read_bytes() == custom_bytes + + def test_switch_from_nothing(self, tmp_path): + """Switch when no integration is installed should just install the target.""" + project = tmp_path / "bare" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "claude", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "Switched to" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + + def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "switch", "generic", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert data["installed_integrations"] == ["codex"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "codex" + assert opts["ai"] == "codex" + + template = project / ".specify" / "templates" / "plan-template.md" + assert "$speckit-plan" in template.read_text(encoding="utf-8") + + def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path): + """Regression (review 3624184343). + + When Phase 2 of a switch fails, rollback selects another installed + integration as the new default. Under active-only registration that + fallback may never have received extension artifacts (it was + installed while another integration was active), and Phase 1 already + unregistered the outgoing agent's artifacts — so the restored default + must be rescaffolded, not just written to metadata. + """ + project = _init_project(tmp_path, "claude") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" not in registered, ( + "precondition: secondary install has no extension artifacts" + ) + + result = _run_in_project(project, [ + "integration", "switch", "generic", + "--script", "sh", + ]) + assert result.exit_code != 0 + + data = json.loads( + (project / ".specify" / "integration.json").read_text(encoding="utf-8") + ) + assert data["integration"] == "codex", "precondition: fallback restored" + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" in registered, ( + "rollback must rescaffold extensions for the restored default" + ) + assert ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + +class TestSwitchClearsMetadataAfterTeardown: + def test_metadata_cleared_between_phases(self, tmp_path): + """After a successful switch, metadata should reference the new integration.""" + project = _init_project(tmp_path, "claude") + + # Verify initial state + int_json = project / ".specify" / "integration.json" + assert json.loads(int_json.read_text(encoding="utf-8"))["integration"] == "claude" + + old_cwd = os.getcwd() + try: + os.chdir(project) + # Switch to copilot — should succeed and update metadata + result = runner.invoke(app, [ + "integration", "switch", "copilot", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # integration.json should reference copilot, not claude + data = json.loads(int_json.read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + + # init-options.json should reference copilot + opts_json = project / ".specify" / "init-options.json" + opts = json.loads(opts_json.read_text(encoding="utf-8")) + assert opts.get("ai") == "copilot" + + +class TestIntegrationSwitchDiagnostics(IntegrationCatalogCliTestBase): + def test_integration_switch_cleanup_warning_reports_phase_and_targets( + self, tmp_path, monkeypatch + ): + from specify_cli.extensions import ExtensionManager + + project = self._make_project(tmp_path) + (project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True) + (project / ".specify" / "integration.json").write_text( + json.dumps( + { + "version": 1, + "integration": "copilot", + "integrations": ["copilot"], + "integration_settings": {"copilot": {"script": "sh"}}, + } + ), + encoding="utf-8", + ) + (project / ".specify" / "integrations" / "copilot.manifest.json").write_text( + json.dumps( + { + "integration": "copilot", + "version": "0.0.0", + "installed_at": "2026-05-16T00:00:00+00:00", + "files": {}, + } + ), + encoding="utf-8", + ) + + def fail_cleanup(self, integration_key): + raise OSError("cleanup exploded") + + monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup) + + result = self._invoke(["integration", "switch", "claude"], project) + normalized = _normalize_cli_output(result.output) + + assert result.exit_code == 0, result.output + assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized + assert "cleanup exploded" in normalized + assert "Switched to integration" in normalized diff --git a/tests/specify_cli/integrations/test_command_uninstall.py b/tests/specify_cli/integrations/test_command_uninstall.py new file mode 100644 index 0000000000..c32d4c7d52 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_uninstall.py @@ -0,0 +1,222 @@ +"""Tests for mirrored integration CLI behavior in test_command_uninstall.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationUninstall: + def test_uninstall_requires_speckit_project(self, tmp_path): + old_cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "uninstall"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Not a Spec Kit project" in result.output + + def test_uninstall_no_integration(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "No integration" in result.output + + def test_uninstall_removes_files(self, tmp_path): + project = _init_project(tmp_path, "claude") + # Claude uses skills directory + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "uninstalled" in result.output + + # Command files removed + assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + # Manifest removed + assert not (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + # integration.json removed + assert not (project / ".specify" / "integration.json").exists() + + def test_uninstall_preserves_modified_files(self, tmp_path): + """Full lifecycle: install → modify → uninstall → modified file kept.""" + project = _init_project(tmp_path, "claude") + plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + + # Modify a file + plan_file.write_text("# My custom plan command\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "preserved" in result.output + assert ".claude/skills/speckit-plan/SKILL.md" in result.output + + # Modified file kept + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == "# My custom plan command\n" + + def test_uninstall_wrong_key(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "manifest" in result.output + assert "unreadable" in result.output + + def test_uninstall_non_default_preserves_default(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "uninstall", "codex", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["installed_integrations"] == ["claude"] + + def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): + project = _init_project(tmp_path, "gemini") + template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") + + def test_uninstall_preserves_shared_infra(self, tmp_path): + """Shared scripts and templates are not removed by integration uninstall.""" + project = _init_project(tmp_path, "claude") + shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" + assert shared_script.exists() + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # Shared infrastructure preserved + assert shared_script.exists() + assert (project / ".specify" / "templates").is_dir() + + +class TestUninstallNoManifestClearsInitOptions: + def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path): + """When no manifest exists, uninstall should still clear init-options.json.""" + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + + # Write integration.json and init-options.json without a manifest + int_json = project / ".specify" / "integration.json" + int_json.write_text(json.dumps({"integration": "claude"}), encoding="utf-8") + + opts_json = project / ".specify" / "init-options.json" + opts_json.write_text(json.dumps({ + "integration": "claude", + "ai": "claude", + "ai_skills": True, + "script": "sh", + }), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + + # init-options.json should have integration keys cleared + opts = json.loads(opts_json.read_text(encoding="utf-8")) + assert "integration" not in opts + assert "ai" not in opts + assert "ai_skills" not in opts + # Non-integration keys preserved + assert opts.get("script") == "sh" diff --git a/tests/specify_cli/integrations/test_command_upgrade.py b/tests/specify_cli/integrations/test_command_upgrade.py new file mode 100644 index 0000000000..2433f823b5 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_upgrade.py @@ -0,0 +1,1532 @@ +"""Tests for mirrored integration CLI behavior in test_command_upgrade.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, + _normalize_cli_output, +) +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationUpgradeDetailed: + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "manifest" in result.output + assert "unreadable" in result.output + + def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "claude") + init_options = project / ".specify" / "init-options.json" + opts = json.loads(init_options.read_text(encoding="utf-8")) + opts["speckit_version"] = "0.6.1" + init_options.write_text(json.dumps(opts), encoding="utf-8") + + import specify_cli.integrations._commands as _int_cmds + + monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--force", + ]) + + assert result.exit_code == 0, result.output + updated = json.loads(init_options.read_text(encoding="utf-8")) + assert updated["speckit_version"] == "0.8.11" + + def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "gemini") + install = _run_in_project(project, [ + "integration", "install", "claude", + "--script", "sh", + ]) + assert install.exit_code == 0, install.output + + init_options = project / ".specify" / "init-options.json" + opts = json.loads(init_options.read_text(encoding="utf-8")) + opts["speckit_version"] = "0.6.1" + init_options.write_text(json.dumps(opts), encoding="utf-8") + + import specify_cli.integrations._commands as _int_cmds + + monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11") + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--script", "sh", + "--force", + ]) + + assert result.exit_code == 0, result.output + updated = json.loads(init_options.read_text(encoding="utf-8")) + assert updated["speckit_version"] == "0.8.11" + assert updated["integration"] == "gemini" + assert updated["ai"] == "gemini" + assert "context_file" not in updated + + def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + init_options = project / ".specify" / "init-options.json" + manifest_path = project / ".specify" / "integrations" / "claude.manifest.json" + + before_state = json.loads(int_json.read_text(encoding="utf-8")) + before_options = json.loads(init_options.read_text(encoding="utf-8")) + before_manifest = manifest_path.read_text(encoding="utf-8") + + import specify_cli + + real_install_shared_infra = specify_cli._install_shared_infra + calls = {"count": 0} + + def fail_refresh(*args, **kwargs): + calls["count"] += 1 + if calls["count"] == 2: + raise ValueError("refuse refresh") + return real_install_shared_infra(*args, **kwargs) + + monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--force", + ]) + + assert result.exit_code != 0 + assert "Failed to refresh shared infrastructure" in result.output + assert json.loads(int_json.read_text(encoding="utf-8")) == before_state + assert json.loads(init_options.read_text(encoding="utf-8")) == before_options + assert manifest_path.read_text(encoding="utf-8") == before_manifest + + def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path): + project = _init_project( + tmp_path, "copilot", integration_options="--commands" + ) + template = project / ".specify" / "templates" / "plan-template.md" + managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.specify" in managed_script.read_text(encoding="utf-8") + customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n" + customized_script.write_text(customized_before, encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", + "--integration-options", "--skills", + ]) + + assert result.exit_code == 0, result.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + managed_content = managed_script.read_text(encoding="utf-8") + assert "/speckit-specify" in managed_content + assert "/speckit.specify" not in managed_content + assert customized_script.read_text(encoding="utf-8") == customized_before + + def test_upgrade_preserves_historical_copilot_commands_without_options( + self, tmp_path + ): + """A command manifest restores missing files instead of migrating.""" + project = _init_project( + tmp_path, "copilot", integration_options="--commands" + ) + state_path = project / ".specify" / "integration.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + copilot_settings = state["integration_settings"]["copilot"] + copilot_settings.pop("raw_options", None) + copilot_settings.pop("parsed_options", None) + state_path.write_text(json.dumps(state), encoding="utf-8") + + for path in (project / ".github" / "agents").glob( + "speckit.*.agent.md" + ): + path.unlink() + for path in (project / ".github" / "prompts").glob( + "speckit.*.prompt.md" + ): + path.unlink() + + result = _run_in_project( + project, + ["integration", "upgrade", "copilot", "--script", "sh", "--force"], + ) + + assert result.exit_code == 0, result.output + assert ( + project / ".github" / "agents" / "speckit.plan.agent.md" + ).exists() + assert not (project / ".github" / "skills").exists() + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text( + encoding="utf-8" + ) + ) + assert init_options.get("ai_skills") is not True + + def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): + project = _init_project(tmp_path, "gemini") + template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "upgrade", "claude", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "gemini" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") + assert "/speckit-plan" not in script.read_text(encoding="utf-8") + + def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path): + """Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/.""" + project = _init_project(tmp_path, "opencode") + + # Simulate a legacy project: rename commands/ back to command/ + canonical = project / ".opencode" / "commands" + legacy = project / ".opencode" / "command" + assert canonical.is_dir(), "init should have created .opencode/commands/" + canonical.rename(legacy) + assert legacy.is_dir() + assert not canonical.exists() + + # Patch the manifest to reflect old paths (command/ not commands/) + manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json" + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + patched_files = {} + for path, info in manifest_data.get("files", {}).items(): + patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info + manifest_data["files"] = patched_files + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + old_commands = sorted(legacy.glob("speckit.*.md")) + assert len(old_commands) > 0, "Legacy dir should have speckit command files" + + result = _run_in_project(project, [ + "integration", "upgrade", "opencode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + # New commands in canonical dir + assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade" + new_commands = sorted(canonical.glob("speckit.*.md")) + assert len(new_commands) > 0, "Commands should exist in .opencode/commands/" + + # Stale files removed from legacy dir (extension-installed commands + # like agent-context.update may still appear — only check the original + # core command stems that should have been migrated). + core_remaining = [ + f for f in legacy.glob("speckit.*.md") + if "agent-context" not in f.name + ] + assert len(core_remaining) == 0, ( + f"Legacy .opencode/command/ should have no core speckit files after upgrade, " + f"found: {[f.name for f in core_remaining]}" + ) + + def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path): + """Upgrade moves Kilo commands from .kilocode/workflows/ to .kilo/commands/.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + old_commands = sorted(legacy.glob("speckit.*.md")) + assert old_commands, "Legacy dir should have speckit command files" + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" + new_commands = sorted(canonical.glob("speckit.*.md")) + assert new_commands, "Commands should exist in .kilo/commands/" + + core_remaining = [ + f for f in legacy.glob("speckit.*.md") + if "agent-context" not in f.name + ] + assert core_remaining == [], ( + "Legacy .kilocode/workflows/ should have no core speckit files " + f"after upgrade, found: {[f.name for f in core_remaining]}" + ) + + def test_upgrade_migrates_qodercli_extension_commands_to_skills(self, tmp_path): + """Qoder upgrade retires old extension commands after skills exist.""" + project = _init_project(tmp_path, "qodercli") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skills = project / ".qoder" / "skills" + commands = project / ".qoder" / "commands" + commands.mkdir(parents=True) + + manifest_path = ( + project / ".specify" / "integrations" / "qodercli.manifest.json" + ) + manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) + legacy_manifest_files = {} + for path, info in manifest_data["files"].items(): + skill_path = project / path + command_name = skill_path.parent.name.replace("speckit-", "speckit.", 1) + legacy_path = commands / f"{command_name}.md" + legacy_path.write_bytes(skill_path.read_bytes()) + legacy_manifest_files[ + legacy_path.relative_to(project).as_posix() + ] = info + manifest_data["files"] = legacy_manifest_files + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + registry_path = project / ".specify" / "extensions" / ".registry" + registry = json.loads(registry_path.read_text(encoding="utf-8")) + git_metadata = registry["extensions"]["git"] + registered_commands = git_metadata["registered_commands"]["qodercli"] + for command_name in registered_commands: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + old_command = commands / f"{command_name}.md" + old_command.write_bytes( + (skills / skill_name / "SKILL.md").read_bytes() + ) + missing_replacement = commands / "speckit.git.missing.md" + missing_replacement.write_text("# preserve until replaced\n", encoding="utf-8") + registered_commands.append("speckit.git.missing") + git_metadata["registered_skills"] = [] + registry_path.write_text(json.dumps(registry), encoding="utf-8") + + shutil.rmtree(skills) + result = _run_in_project(project, [ + "integration", "upgrade", "qodercli", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + for command_name in registered_commands[:-1]: + skill_name = command_name.replace("speckit.", "speckit-", 1).replace( + ".", "-" + ) + assert (skills / skill_name / "SKILL.md").is_file() + assert not (commands / f"{command_name}.md").exists() + assert missing_replacement.is_file(), ( + "a legacy command must remain when no replacement skill was written" + ) + + def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides( + self, tmp_path + ): + """Kilo legacy command-root migration must fail closed with presets.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + preset_file = legacy / "speckit.plan.md" + preset_file.write_text("# preset plan override\n", encoding="utf-8") + + presets_dir = project / ".specify" / "presets" + presets_dir.mkdir(parents=True, exist_ok=True) + (presets_dir / ".registry").write_text( + json.dumps({ + "presets": { + "my-preset": { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"kilocode": ["speckit.plan"]}, + "registered_skills": [], + } + } + }), + encoding="utf-8", + ) + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code != 0, ( + "Kilo legacy command-root migration with presets must be rejected" + ) + assert "preset" in result.output.lower() + assert "my-preset" in result.output + assert ".kilocode/workflows" in strip_ansi(result.output) + assert ".kilo/commands" in strip_ansi(result.output) + assert not canonical.exists(), ( + "canonical Kilo commands must not be scaffolded after rejection" + ) + assert preset_file.read_text(encoding="utf-8") == "# preset plan override\n" + + def test_upgrade_reconciles_kilocode_legacy_extension_artifacts(self, tmp_path): + """Kilo upgrade moves enabled extension commands to the canonical dir.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + assert sorted(legacy.glob("speckit.git.*.md")), ( + "legacy Kilo should render the git extension under .kilocode/workflows" + ) + assert not canonical.exists() + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + assert sorted(canonical.glob("speckit.git.*.md")), ( + "enabled git extension commands should be recreated in .kilo/commands" + ) + assert not sorted(legacy.glob("speckit.git.*.md")), ( + "legacy git extension commands should be removed after Kilo upgrade" + ) + + registry_path = project / ".specify" / "extensions" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "kilocode" in registered + + def test_upgrade_preserves_disabled_kilocode_legacy_extension_and_user_file( + self, tmp_path + ): + """Legacy reconciliation must not clean disabled or user-owned files.""" + project = _init_project(tmp_path, "kilocode") + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, f"extension disable failed: {result.output}" + + disabled_extension_files = sorted(legacy.glob("speckit.git.*.md")) + assert disabled_extension_files, "disabled extension artifact should remain pre-upgrade" + + user_file = legacy / "speckit.user-owned.md" + user_file.write_text("# user-owned legacy command", encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, f"upgrade failed: {result.output}" + + assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" + assert user_file.read_text(encoding="utf-8") == "# user-owned legacy command" + for disabled_file in disabled_extension_files: + assert disabled_file.exists(), ( + "disabled extension artifacts should be preserved during " + "legacy command-root reconciliation" + ) + assert not sorted(canonical.glob("speckit.git.*.md")), ( + "disabled extensions must not be re-registered in the canonical dir" + ) + + def test_upgrade_secondary_kilocode_legacy_dir_cleans_commands_without_backfill( + self, tmp_path + ): + """Kilo cleanup stays agent-scoped without inactive extension backfill.""" + project = _init_project(tmp_path, "copilot", integration_options="--skills") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + assert skill.exists(), "precondition: active copilot has the git extension skill" + + registry_path = project / ".specify" / "extensions" / ".registry" + + def _git_skills(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + return data["extensions"]["git"].get("registered_skills", []) + + assert _git_skills(), "precondition: git skills registered for active copilot" + + result = _run_in_project(project, [ + "integration", "install", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, result.output + + canonical, legacy = _move_kilocode_install_to_legacy_layout(project) + legacy_git_command = legacy / "speckit.git.feature.md" + legacy_git_command.write_text("# legacy Kilo git command\n", encoding="utf-8") + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registry["extensions"]["git"].setdefault("registered_commands", {})[ + "kilocode" + ] = ["speckit.git.feature"] + registry_path.write_text(json.dumps(registry), encoding="utf-8") + assert legacy_git_command.exists(), ( + "precondition: secondary Kilo has a legacy extension command file" + ) + + result = _run_in_project(project, [ + "integration", "upgrade", "kilocode", + "--script", "sh", + "--force", + ]) + assert result.exit_code == 0, result.output + + assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade" + assert not sorted(canonical.glob("speckit.git.*.md")), ( + "inactive Kilo must wait for use/switch before extension rescaffolding" + ) + assert not legacy_git_command.exists(), ( + "secondary Kilo legacy extension commands should still be cleaned up" + ) + registry = json.loads(registry_path.read_text(encoding="utf-8")) + registered_commands = registry["extensions"]["git"].get( + "registered_commands", {} + ) + assert "kilocode" not in registered_commands + assert skill.exists(), ( + "secondary Kilo legacy cleanup must not delete the active agent's " + "extension skill" + ) + assert _git_skills(), ( + "secondary Kilo legacy cleanup must not untrack the active agent's " + "extension skills in the registry" + ) + + def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path): + """Regression (review #3415, 4724160183, comment 1). + + ``integration upgrade bob --integration-options="--skills"`` migrates a + legacy Bob 1.x install (``.bob/commands/*.md``) to the skills layout + (``.bob/skills/speckit-*/SKILL.md``) and stale-removes the old command + files. Because that stale-file pass shrinks the tracked set, the + upgrade's Phase 2 must NOT delete the freshly-saved ``bob.manifest.json`` + — otherwise the migrated project is left untracked and un-upgradeable. + """ + project = _init_project( + tmp_path, "bob", integration_options="--legacy-commands" + ) + + commands = project / ".bob" / "commands" + skills = project / ".bob" / "skills" + manifest_path = ( + project / ".specify" / "integrations" / "bob.manifest.json" + ) + assert commands.is_dir() and sorted(commands.glob("speckit.*.md")) + assert not skills.exists() + assert manifest_path.is_file() + + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, f"migration upgrade failed: {result.output}" + + # Skills layout scaffolded; legacy core command files removed. + assert skills.is_dir(), ".bob/skills/ must exist after --skills migration" + assert sorted(skills.glob("speckit-*")), "expected migrated skill dirs" + core_commands = [ + f for f in commands.glob("speckit.*.md") + if "agent-context" not in f.name + ] if commands.exists() else [] + assert core_commands == [], ( + f"legacy core command files should be removed, found: " + f"{[f.name for f in core_commands]}" + ) + + # The manifest must survive so the project stays tracked/upgradeable. + assert manifest_path.is_file(), ( + "bob.manifest.json must survive a layout-shrinking migration" + ) + reupgrade = _run_in_project(project, [ + "integration", "upgrade", "bob", "--script", "sh", "--force", + ]) + assert reupgrade.exit_code == 0, ( + f"migrated project must remain upgradeable: {reupgrade.output}" + ) + + def test_upgrade_bob_layout_change_reconciles_extension_artifacts(self, tmp_path): + """Regression (review #3415, 4725829110). + + When a dual-mode agent (Bob) flips layout across an upgrade, the old + layout's *extension* artifacts must be reconciled — not left orphaned. + A legacy Bob install renders enabled extensions as ``.bob/commands/`` + command files; migrating to skills via ``--skills`` must remove those + command files, recreate the extension as ``.bob/skills/`` skills, and + update the extension registry accordingly (and vice-versa for the + reverse ``--legacy-commands`` migration). + """ + project = _init_project( + tmp_path, "bob", integration_options="--legacy-commands" + ) + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + commands = project / ".bob" / "commands" + skills = project / ".bob" / "skills" + registry_path = project / ".specify" / "extensions" / ".registry" + + def _git_registry(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + g = data["extensions"]["git"] + return list(g.get("registered_commands", {})), g.get( + "registered_skills", [] + ) + + # Legacy precondition: git renders as command files under .bob/commands. + assert sorted(commands.glob("speckit.git.*.md")), ( + "legacy Bob should render the git extension as command files" + ) + assert not list(skills.glob("speckit-git-*")) if skills.exists() else True + cmds_agents, skill_names = _git_registry() + assert "bob" in cmds_agents and not skill_names + + # Migrate legacy -> skills. + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, f"--skills migration failed: {result.output}" + + # Old-layout git command files removed; skills recreated. + assert not sorted(commands.glob("speckit.git.*.md")), ( + "git extension command files must be removed after --skills migration" + ) + assert sorted(skills.glob("speckit-git-*")), ( + "git extension must be recreated as skills after --skills migration" + ) + cmds_agents, skill_names = _git_registry() + assert "bob" not in cmds_agents, ( + "extension registry must drop the stale bob command entry" + ) + assert skill_names, "extension registry must record the migrated skills" + + # Migrate skills -> legacy: the reverse reconciliation must also hold. + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--legacy-commands", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, ( + f"--legacy-commands migration failed: {result.output}" + ) + assert not sorted(skills.glob("speckit-git-*")), ( + "git extension skills must be removed after --legacy-commands migration" + ) + assert sorted(commands.glob("speckit.git.*.md")), ( + "git extension command files must be recreated in legacy layout" + ) + cmds_agents, skill_names = _git_registry() + assert "bob" in cmds_agents and not skill_names + + def test_upgrade_layout_change_preserves_extension_artifacts_when_reregistration_fails( + self, tmp_path + ): + """Regression (review 3624075109). + + A layout-changing upgrade must not eagerly unregister the agent's + extension artifacts before re-registration: the retirement of each + opposite-mode artifact belongs to + ``register_enabled_extensions_for_agent``'s deferred toggle cleanup, + which retires an old artifact only after its replacement in the new + layout is confirmed. If re-registration cannot rebuild an extension + (here: its installed manifest is corrupted), the old artifact and its + registry tracking must survive instead of leaving the extension with + no artifacts at all. + """ + project = _init_project( + tmp_path, "bob", integration_options="--legacy-commands" + ) + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + commands = project / ".bob" / "commands" + assert sorted(commands.glob("speckit.git.*.md")), ( + "precondition: git extension renders as legacy command files" + ) + + # Corrupt the installed extension manifest so re-registration cannot + # rebuild the artifacts in the new layout. + ( + project / ".specify" / "extensions" / "git" / "extension.yml" + ).write_text("invalid: [", encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, ( + f"upgrade is best-effort about extensions: {result.output}" + ) + + assert sorted(commands.glob("speckit.git.*.md")), ( + "old-layout extension artifacts must survive when their " + "replacement could not be registered" + ) + registry_path = project / ".specify" / "extensions" / ".registry" + data = json.loads(registry_path.read_text(encoding="utf-8")) + assert "bob" in data["extensions"]["git"].get("registered_commands", {}), ( + "extension registry must keep tracking the surviving artifacts" + ) + + def test_upgrade_active_layout_change_rejected_before_missing_preset_source_can_lose_override( + self, tmp_path + ): + """Regression (review 3623357447). + + Layout-changing upgrades must fail closed even for the active + integration. Preset rescaffolding is best-effort, so a missing source + file could otherwise let stale integration cleanup delete the tracked + old-layout override without creating its replacement. + """ + project = _init_project( + tmp_path, "bob", integration_options="--legacy-commands" + ) + commands = project / ".bob" / "commands" + skills = project / ".bob" / "skills" + + preset_src = tmp_path / "cmd-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.plan.md").write_text( + "---\ndescription: Overridden plan\n---\nOverridden plan content\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "cmd-preset", + "name": "Command Preset", + "version": "1.0.0", + "description": "Test preset with a command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + } + ] + }, + } + import yaml + + (preset_src / "preset.yml").write_text( + yaml.dump(manifest_data), encoding="utf-8" + ) + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + + cmd_file = commands / "speckit.plan.md" + assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8") + + installed_source = ( + project + / ".specify" + / "presets" + / "cmd-preset" + / "commands" + / "speckit.plan.md" + ) + assert installed_source.exists(), "precondition: preset source was installed" + installed_source.unlink() + + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code != 0, ( + "layout change with tracked preset artifacts must be rejected" + ) + assert "cmd-preset" in result.output + assert not skills.exists(), "no skills layout must be scaffolded on rejection" + assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), ( + "tracked old-layout override must remain untouched" + ) + + def test_upgrade_active_layout_change_rejected_with_disabled_preset( + self, tmp_path + ): + """Regression (review 3623779277). + + The post-upgrade rescaffold iterates *enabled* presets only, and a + disabled preset's artifacts are deliberately frozen until removal + (``preset disable``). An active-agent layout change must therefore be + rejected while a disabled preset still owns artifacts for the agent — + proceeding would delete its old-layout files in stale-manifest + cleanup, skip recreating them, and leave its registry entries stale. + Re-enabling does not make a non-transactional layout migration safe. + """ + project = _init_project( + tmp_path, "bob", integration_options="--legacy-commands" + ) + commands = project / ".bob" / "commands" + skills = project / ".bob" / "skills" + + preset_src = tmp_path / "cmd-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.plan.md").write_text( + "---\ndescription: Overridden plan\n---\nOverridden plan content\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "cmd-preset", + "name": "Command Preset", + "version": "1.0.0", + "description": "Test preset with a command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + } + ] + }, + } + import yaml + + (preset_src / "preset.yml").write_text( + yaml.dump(manifest_data), encoding="utf-8" + ) + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + result = _run_in_project(project, ["preset", "disable", "cmd-preset"]) + assert result.exit_code == 0, f"preset disable failed: {result.output}" + + cmd_file = commands / "speckit.plan.md" + assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code != 0, ( + "layout change with a disabled preset must be rejected" + ) + assert "cmd-preset" in result.output + assert not skills.exists(), "no skills layout must be scaffolded on rejection" + assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8"), ( + "the disabled preset's command file must be left untouched" + ) + + # Enabled presets are also rejected: rescaffolding can still fail. + result = _run_in_project(project, ["preset", "enable", "cmd-preset"]) + assert result.exit_code == 0, f"preset enable failed: {result.output}" + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code != 0 + assert "cmd-preset" in result.output + assert not skills.exists() + assert "Overridden plan content" in cmd_file.read_text(encoding="utf-8") + + def test_upgrade_secondary_layout_change_rejected_with_presets_installed( + self, tmp_path + ): + """Regression (review #3415, 4726193915; updated for review 3623357447). + + Preset rescaffolding is active-agent-only, so a layout-changing + ``upgrade`` of a *non-active* integration still cannot reconcile that + agent's preset artifacts. It must reject the migration with an + actionable error *before any mutation* when preset overrides are + installed for that agent. A same-layout upgrade must still succeed. + """ + project = _init_project(tmp_path, "copilot") + result = _run_in_project(project, [ + "integration", "install", "bob", + "--integration-options", "--legacy-commands", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + commands = project / ".bob" / "commands" + skills = project / ".bob" / "skills" + assert sorted(commands.glob("speckit.*.md")) + + # Simulate a historical preset registration for the non-active bob. + presets_dir = project / ".specify" / "presets" + presets_dir.mkdir(parents=True, exist_ok=True) + (presets_dir / ".registry").write_text( + json.dumps({ + "presets": { + "my-preset": { + "version": "1.0.0", + "enabled": True, + "registered_commands": {"bob": ["speckit.plan"]}, + "registered_skills": {}, + } + } + }), + encoding="utf-8", + ) + + # Layout-changing upgrade of the secondary agent is rejected untouched. + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code != 0, ( + "secondary layout change with presets must be rejected" + ) + assert "preset" in result.output.lower() + assert "my-preset" in result.output + assert not skills.exists(), "no skills layout must be scaffolded on rejection" + assert sorted(commands.glob("speckit.*.md")), ( + "legacy command files must be left untouched on rejection" + ) + + # A same-layout upgrade (no flag) must still succeed with presets present. + result = _run_in_project(project, [ + "integration", "upgrade", "bob", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, ( + f"same-layout upgrade must not be blocked by presets: {result.output}" + ) + + def test_upgrade_bob_layout_change_rejected_when_preset_registry_unreadable( + self, tmp_path + ): + """Regression (review #3415, 4744636079). + + The preset guard must fail *closed*: if the preset registry exists but + cannot be read/parsed (corruption, permissions), the layout-changing + upgrade must be rejected before any mutation rather than proceeding on + a false "no presets installed" assumption (which would let ``--force`` + delete preset-overridden command files while their registry state is + unknown). A genuinely absent registry must still be allowed. + """ + project = _init_project( + tmp_path, "bob", integration_options="--legacy-commands" + ) + commands = project / ".bob" / "commands" + skills = project / ".bob" / "skills" + assert sorted(commands.glob("speckit.*.md")) + + # Corrupted (unparseable) registry: exists but cannot be read as JSON. + presets_dir = project / ".specify" / "presets" + presets_dir.mkdir(parents=True, exist_ok=True) + (presets_dir / ".registry").write_text("{ not valid json", encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code != 0, ( + "layout change must be rejected when preset registry is unreadable" + ) + assert "preset registry" in result.output.lower() + assert not skills.exists(), "no skills layout may be scaffolded on rejection" + assert sorted(commands.glob("speckit.*.md")), ( + "legacy command files must be untouched when failing closed" + ) + + # A valid, empty registry must NOT block the migration. + (presets_dir / ".registry").write_text( + json.dumps({"presets": {}}), encoding="utf-8" + ) + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, ( + f"valid empty preset registry must not block migration: {result.output}" + ) + assert skills.exists(), "skills layout should be scaffolded once unblocked" + + def test_upgrade_secondary_bob_layout_change_preserves_active_agent_skills( + self, tmp_path + ): + """Regression (review #3415, 4726347306). + + ``integration upgrade`` supports upgrading a *secondary* (non-active) + integration. The layout-change extension reconciliation must NOT run + for a secondary agent: ``unregister_agent_artifacts`` treats the + unscoped per-extension ``registered_skills`` as belonging to the passed + agent and, if that agent's skills dir is absent, scans every agent's + skills dir — which could delete/untrack the *active* agent's extension + skills. The following re-registration cannot repair that because + extension skill rendering is active-agent-scoped (#2948). + """ + # Active agent: copilot in skills mode → git extension renders as skills. + project = _init_project(tmp_path, "copilot", integration_options="--skills") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + assert skill.exists(), "precondition: active copilot has the git extension skill" + + registry_path = project / ".specify" / "extensions" / ".registry" + + def _git_skills(): + data = json.loads(registry_path.read_text(encoding="utf-8")) + return data["extensions"]["git"].get("registered_skills", []) + + assert _git_skills(), "precondition: git skills registered for active copilot" + + # Add a secondary (non-active) Bob in the legacy commands layout. + result = _run_in_project(project, [ + "integration", "install", "bob", + "--integration-options", "--legacy-commands", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + + # Flip the *secondary* Bob's layout to skills. copilot stays active. + result = _run_in_project(project, [ + "integration", "upgrade", "bob", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + + # The active agent's extension skill must be untouched on disk and in + # the registry — the secondary layout change must not reconcile it. + assert skill.exists(), ( + "secondary Bob layout change must not delete the active agent's " + "extension skill" + ) + assert _git_skills(), ( + "secondary Bob layout change must not untrack the active agent's " + "extension skills in the registry" + ) + + def test_upgrade_preserves_existing_vscode_settings(self, tmp_path): + """Regression: copilot upgrade must not stale-delete .vscode/settings.json. + + On init the file is created and recorded in the manifest. On upgrade, + setup() merges into the now-existing file and intentionally stops + tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2 + stale cleanup would delete it (destroying the user's settings). + """ + project = _init_project( + tmp_path, "copilot", integration_options="--commands" + ) + settings = project / ".vscode" / "settings.json" + assert settings.is_file(), "init should create .vscode/settings.json" + before = json.loads(settings.read_text(encoding="utf-8")) + assert before, "settings.json should contain managed defaults" + + # Simulate a user editing their settings: add a custom key that the + # integration does not manage. It must survive the upgrade. + before["editor.fontSize"] = 17 + settings.write_text(json.dumps(before), encoding="utf-8") + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + + assert settings.is_file(), ".vscode/settings.json must survive upgrade" + after = json.loads(settings.read_text(encoding="utf-8")) + assert after.get("editor.fontSize") == 17, ( + "user-defined settings must be preserved after upgrade" + ) + + def test_upgrade_restores_executable_bit_on_shared_scripts(self, tmp_path): + """Regression: scripts refreshed by the managed-refresh step stay +x.""" + if os.name == "nt": + pytest.skip("POSIX execute bits are not meaningful on Windows") + project = _init_project(tmp_path, "copilot") + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + assert script.is_file() + # Simulate a perms-losing install (e.g. wheel extraction dropping +x). + script.chmod(0o644) + assert not (script.stat().st_mode & 0o111) + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + assert script.stat().st_mode & 0o111, ( + "shared .sh scripts must be executable after upgrade" + ) + + def test_upgrade_does_not_backfill_non_active_integration(self, tmp_path): + """Upgrading a non-active integration must not register extensions for it. + + Maintainer-requested behavior for #2948 (reverses the #2886 upgrade + back-fill): non-active integrations only receive extension artifacts + when selected via ``integration use`` / ``switch``. Upgrade of a + non-active integration refreshes its own files and nothing else. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + registry_path = project / ".specify" / "extensions" / ".registry" + assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + + result = _run_in_project(project, [ + "integration", "upgrade", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "extensions" + ]["git"]["registered_commands"] + assert "codex" not in registered, ( + "upgrade must not back-fill non-active integrations (#2948)" + ) + assert not ( + project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_upgrade_active_integration_reregisters_extensions(self, tmp_path): + """Upgrading the active integration restores its extension commands. + + The active integration keeps the re-registration pass on upgrade so + missing or stale extension command files are recreated (#2948 scopes + the pass to the active integration; #2886 introduced it). + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + cmd_file = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert cmd_file.exists(), "precondition: extension command registered" + cmd_file.unlink() + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + assert cmd_file.exists(), ( + "upgrade of the active integration re-registers extension commands" + ) + + def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir( + self, tmp_path + ): + """End-to-end regression for #3849 (upgrade-overwrites-copilot-skills). + + In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which + regenerates the core-template skill directories — *before* re-registering + installed extensions. The extension re-registration then hits the + ``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill + sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has + not been rewritten with extension content), so pre-fix the extension + skill was silently left missing — its command content lost even though the + extension remained installed and registered. + + The fix threads ``force=True`` from ``integration_upgrade()`` down to + ``_register_extension_skills`` so the guard is bypassed and the extension + content is re-composed on top of the just-regenerated directory. This test + exercises the full ``specify integration upgrade`` command path and fails + without the fix (the skill is never recreated). + """ + project = _init_project( + tmp_path, "copilot", integration_options="--skills" + ) + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skill_dir = project / ".github" / "skills" / "speckit-git-feature" + skill_file = skill_dir / "SKILL.md" + assert skill_file.exists(), ( + "precondition: git extension renders as a Copilot skill" + ) + original = skill_file.read_text(encoding="utf-8") + assert "source: extension:git" in original, ( + "precondition: skill carries the git extension ownership marker" + ) + + # Simulate the exact pre-condition the bug depends on: the skill file is + # gone but its directory survives (as it does once setup() regenerates the + # core-template layout during upgrade), triggering the skill_dir_preexists + # skip guard on re-registration. + skill_file.unlink() + assert skill_dir.exists() and not skill_file.exists() + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + + assert skill_file.exists(), ( + "upgrade must restore the extension skill even when its directory " + "already exists (regression #3849)" + ) + restored = skill_file.read_text(encoding="utf-8") + assert "source: extension:git" in restored, ( + "restored skill must contain the git extension content, not a bare " + "core-template stub" + ) + assert "# Git Feature Skill" in restored + + def test_upgrade_active_integration_reregisters_presets(self, tmp_path): + """Upgrading the active integration restores missing preset artifacts.""" + import yaml + + project = _init_project(tmp_path, "claude") + preset_src = tmp_path / "upgrade-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.upgrade-check.md").write_text( + "---\ndescription: Upgrade check\n---\nPreset upgrade body\n", + encoding="utf-8", + ) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "upgrade-preset", + "name": "Upgrade Preset", + "version": "1.0.0", + "description": "Upgrade preset test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.upgrade-check", + "file": "commands/speckit.upgrade-check.md", + } + ] + }, + } + (preset_src / "preset.yml").write_text( + yaml.dump(manifest), encoding="utf-8" + ) + + result = _run_in_project( + project, ["preset", "add", "--dev", str(preset_src)] + ) + assert result.exit_code == 0, result.output + + skill_dir = ( + project / ".claude" / "skills" / "speckit-upgrade-check" + ) + skill_file = skill_dir / "SKILL.md" + assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8") + shutil.rmtree(skill_dir) + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + assert "Preset upgrade body" in skill_file.read_text(encoding="utf-8") + + def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path): + """Upgrading a non-active agent must not touch the active agent's skills. + + Regression for the #2886 wiring: extension skill rendering is + active-agent-scoped, so routing upgrade of a *secondary* agent through + ``register_enabled_extensions_for_agent`` used to re-render the + *active* skills-mode agent's extension skills as a side effect — + resurrecting skill files the user had deliberately deleted. The skills + pass is now gated on the target being the active agent. (Skills parity + for non-active agents is tracked separately in #2948.) + """ + # Active agent: copilot in skills mode → git extension renders as skills. + project = _init_project(tmp_path, "copilot", integration_options="--skills") + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + assert skill.exists(), "precondition: active copilot has the git extension skill" + + # Add a secondary (non-active) agent; copilot is not multi_install_safe. + result = _run_in_project(project, [ + "integration", "install", "codex", "--script", "sh", "--force", + ]) + assert result.exit_code == 0, result.output + + # The user deliberately removes the active agent's git skill. + shutil.rmtree(skill.parent) + assert not skill.exists() + + # Upgrading the *non-active* agent must not re-render copilot's skills. + result = _run_in_project(project, [ + "integration", "upgrade", "codex", "--script", "sh", + ]) + assert result.exit_code == 0, result.output + assert not skill.exists(), ( + "upgrading a non-active agent must not resurrect the active agent's " + "deleted extension skill (#2886)" + ) + + + +class TestIntegrationUpgradeDiagnostics(IntegrationCatalogCliTestBase): + def test_integration_upgrade_failure_reports_phase_and_target( + self, tmp_path, monkeypatch + ): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations.copilot import CopilotIntegration + + class UpgradeBrokenIntegration(CopilotIntegration): + key = "upgrade-broken" + config = dict(CopilotIntegration.config) + config["name"] = "Upgrade Broken" + + def setup(self, project_root, manifest, **kwargs): + raise OSError("upgrade exploded\nwith context") + + project = self._make_project(tmp_path) + monkeypatch.setitem( + INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration() + ) + + (project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True) + (project / ".specify" / "integration.json").write_text( + json.dumps( + { + "version": 1, + "integration": "upgrade-broken", + "integrations": ["upgrade-broken"], + "integration_settings": {"upgrade-broken": {"script": "sh"}}, + } + ), + encoding="utf-8", + ) + ( + project / ".specify" / "integrations" / "upgrade-broken.manifest.json" + ).write_text( + json.dumps( + { + "integration": "upgrade-broken", + "version": "0.0.0", + "installed_at": "2026-05-16T00:00:00+00:00", + "files": {}, + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "upgrade", "upgrade-broken"], project) + normalized = _normalize_cli_output(result.output) + + assert result.exit_code == 1, result.output + assert "Failed to upgrade integration 'upgrade-broken'" in normalized + assert "upgrade exploded with context" in normalized + assert "previous integration files may still be in place" in normalized + + +class TestIntegrationUpgradeBasic: + """Test ``specify integration upgrade``.""" + + def _init_project(self, tmp_path, integration="copilot"): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_upgrade_requires_speckit_project(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "Not a Spec Kit project" in result.output + + def test_upgrade_no_integration_installed(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "No integration is currently installed" in result.output + + def test_upgrade_succeeds(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_blocks_on_modified_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file so the manifest hash won't match + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + assert manifest_path.exists(), "Manifest should exist after init" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "modified" in result.output.lower() + + def test_upgrade_force_overwrites_modified(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_wrong_integration_key(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_upgrade_no_manifest(self, tmp_path): + """Upgrade with missing manifest suggests fresh install.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Remove manifest + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + if manifest_path.exists(): + manifest_path.unlink() + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "Nothing to upgrade" in result.output diff --git a/tests/specify_cli/integrations/test_command_upgrade_layout.py b/tests/specify_cli/integrations/test_command_upgrade_layout.py new file mode 100644 index 0000000000..671be65ac3 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_upgrade_layout.py @@ -0,0 +1,120 @@ +"""Tests for integration upgrade layout-migration guards.""" + +import json + +import pytest + + +class TestIntegrationUpgradeLayout: + def test_installed_presets_affecting_agent_absent_vs_unreadable(self, tmp_path): + """Unit (review #3415, 4744636079): fail closed only when unreadable. + + The preset guard helper must return an empty list for a genuinely + absent registry, but raise ``_PresetRegistryUnreadableError`` when the + registry exists yet cannot be read/parsed — so a layout-changing + upgrade never proceeds on a false "no presets" result. + """ + from specify_cli.integrations._command_upgrade_layout import ( + _PresetRegistryUnreadableError, + _installed_command_presets_affecting_agent, + _installed_presets_affecting_agent, + ) + + project = tmp_path / "proj" + project.mkdir() + + # Genuinely absent registry → empty list (safe to proceed). + assert _installed_presets_affecting_agent(project, "bob") == [] + + presets_dir = project / ".specify" / "presets" + presets_dir.mkdir(parents=True) + registry = presets_dir / ".registry" + + # Corrupted JSON → unreadable → raise. + registry.write_text("{ not json", encoding="utf-8") + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + + # Malformed structure (presets not a dict) → unreadable → raise. + registry.write_text(json.dumps({"presets": []}), encoding="utf-8") + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + + # Malformed per-preset entry (not a dict) → ownership unknown → raise. + registry.write_text( + json.dumps({"presets": {"p1": []}}), encoding="utf-8" + ) + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + + # Malformed registered_commands (not a dict) → raise. + registry.write_text( + json.dumps({"presets": {"p1": {"registered_commands": []}}}), + encoding="utf-8", + ) + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + + # Malformed registered_skills (neither list nor dict) → raise. + registry.write_text( + json.dumps({"presets": {"p1": {"registered_skills": "oops"}}}), + encoding="utf-8", + ) + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + + # Dict-shaped fields with non-list values (ownership undecidable) + # must also fail closed, not read as "no artifacts". + registry.write_text( + json.dumps( + {"presets": {"p1": {"registered_skills": {"bob": None}}}} + ), + encoding="utf-8", + ) + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + registry.write_text( + json.dumps( + {"presets": {"p1": {"registered_commands": {"bob": ""}}}} + ), + encoding="utf-8", + ) + with pytest.raises(_PresetRegistryUnreadableError): + _installed_presets_affecting_agent(project, "bob") + + # Valid, empty registry → empty list. + registry.write_text(json.dumps({"presets": {}}), encoding="utf-8") + assert _installed_presets_affecting_agent(project, "bob") == [] + + # Valid registry with a preset registered for bob → report its ID. + # registered_skills comes in two shapes: a legacy flat list (not + # agent-scoped → fail closed, any entry affects) and the per-agent + # dict written by preset registration ({agent: [skill names]} → only + # this agent's entries affect it). + registry.write_text( + json.dumps({ + "presets": { + "p1": {"registered_commands": {"bob": ["speckit.plan"]}}, + "p2": {"registered_commands": {"codex": ["speckit.plan"]}}, + "p3": {"registered_skills": ["speckit-x"]}, + "p4": {"registered_skills": {"bob": ["speckit-y"]}}, + "p5": {"registered_skills": {"codex": ["speckit-z"]}}, + "p6": {"registered_skills": {"bob": []}}, + "p7": { + "enabled": False, + "registered_commands": {"bob": ["speckit.tasks"]}, + }, + } + }), + encoding="utf-8", + ) + assert sorted(_installed_presets_affecting_agent(project, "bob")) == [ + "p1", + "p3", + "p4", + "p7", + ] + assert _installed_command_presets_affecting_agent(project, "bob") == [ + "p1", + "p7", + ] diff --git a/tests/specify_cli/integrations/test_command_use.py b/tests/specify_cli/integrations/test_command_use.py new file mode 100644 index 0000000000..a48056eb88 --- /dev/null +++ b/tests/specify_cli/integrations/test_command_use.py @@ -0,0 +1,343 @@ +"""Tests for mirrored integration CLI behavior in test_command_use.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationUse: + def test_use_installed_integration_sets_default(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert data["default_integration"] == "codex" + assert data["installed_integrations"] == ["claude", "codex"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "codex" + assert opts["ai"] == "codex" + + def test_use_preserves_copilot_skills_mode(self, tmp_path): + """`use` on a skills-mode Copilot keeps ``ai_skills`` (issue #3550). + + Re-selecting the same skills-mode Copilot must not drop ``ai_skills`` + from init-options.json nor regenerate extension commands in the legacy + ``.agent.md``/``.prompt.md`` layout. + """ + project = _init_project(tmp_path, "copilot", integration_options="--skills") + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts.get("ai_skills") is True, "precondition: init recorded skills mode" + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + # Simulate a fresh process: `use` in real life runs in its own process + # where the registry's Copilot instance has _skills_mode == False (it is + # only set during setup()). In-process test invocations otherwise reuse + # the singleton left in skills mode by init, masking the bug (#3550). + from specify_cli.integrations import get_integration + + get_integration("copilot")._skills_mode = False + + result = _run_in_project(project, ["integration", "use", "copilot"]) + assert result.exit_code == 0, result.output + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts.get("ai_skills") is True, "ai_skills must survive `use copilot`" + + # No legacy command-layout files should be regenerated for the + # skills-mode agent. + assert not (project / ".github" / "agents" / "speckit.git.feature.agent.md").exists() + assert not (project / ".github" / "prompts" / "speckit.git.feature.prompt.md").exists() + assert ( + project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + ).exists() + + def test_use_requires_installed_integration(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "use", "codex"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_use_registers_presets_for_the_newly_active_agent(self, tmp_path): + """``integration use`` is the single rescaffold point for presets too. + + Mirrors the extension single-active rule (#2948): a preset command + override installed while ``claude`` was active must not target the + inactive ``codex`` integration, and switching via ``integration use`` + must rescaffold it there. + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + preset_src = tmp_path / "cmd-preset" + (preset_src / "commands").mkdir(parents=True) + (preset_src / "commands" / "speckit.specify.md").write_text( + "---\ndescription: Overridden specify\n---\nOverridden content\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "cmd-preset", + "name": "Command Preset", + "version": "1.0.0", + "description": "Test preset with a command override", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + import yaml + + (preset_src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") + + result = _run_in_project(project, ["preset", "add", "--dev", str(preset_src)]) + assert result.exit_code == 0, f"preset add failed: {result.output}" + + registry_path = project / ".specify" / "presets" / ".registry" + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["cmd-preset"]["registered_commands"] + assert "claude" in registered, "active integration gets the preset command override" + assert "codex" not in registered, ( + "non-active integration must not be registered on preset add (#2948)" + ) + + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + registered = json.loads(registry_path.read_text(encoding="utf-8"))[ + "presets" + ]["cmd-preset"]["registered_commands"] + assert "codex" in registered, "use registers presets for the new active agent" + assert "claude" in registered, "the previous agent's registration is preserved" + + def test_use_reregisters_presets_highest_precedence_last(self, tmp_path): + """When two enabled presets override the same command, the + higher-precedence preset (lower priority number) must win the + materialized file after ``integration use`` rescaffolds them. + + ``register_enabled_presets_for_agent`` iterates presets and each + pass overwrites the same target file, so the write order matters. + Before the fix, presets were processed lowest-number-first (highest + precedence first), so the lower-precedence preset was written last + and won -- reversing the documented priority stack (#2948). + """ + project = _init_project(tmp_path, "claude") + + result = _run_in_project(project, [ + "integration", "install", "codex", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + import yaml + + def _make_preset(pack_id: str, content: str) -> Path: + src = tmp_path / pack_id + (src / "commands").mkdir(parents=True) + (src / "commands" / "speckit.specify.md").write_text( + f"---\ndescription: {pack_id}\n---\n{content}\n", + encoding="utf-8", + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": f"Test preset {pack_id}", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + (src / "preset.yml").write_text(yaml.dump(manifest_data), encoding="utf-8") + return src + + # Lower-precedence preset (higher priority number), installed first. + low_precedence_src = _make_preset("low-precedence-preset", "LOW PRECEDENCE CONTENT") + result = _run_in_project(project, [ + "preset", "add", "--dev", str(low_precedence_src), "--priority", "20", + ]) + assert result.exit_code == 0, f"preset add (low) failed: {result.output}" + + # Higher-precedence preset (lower priority number), installed second. + high_precedence_src = _make_preset("high-precedence-preset", "HIGH PRECEDENCE CONTENT") + result = _run_in_project(project, [ + "preset", "add", "--dev", str(high_precedence_src), "--priority", "1", + ]) + assert result.exit_code == 0, f"preset add (high) failed: {result.output}" + + # Sanity: the priority stack already picks the high-precedence + # preset's content for the active (claude) integration. + claude_skill = project / ".claude" / "skills" / "speckit-specify" / "SKILL.md" + assert "HIGH PRECEDENCE CONTENT" in claude_skill.read_text(encoding="utf-8") + assert "LOW PRECEDENCE CONTENT" not in claude_skill.read_text(encoding="utf-8") + + result = _run_in_project(project, ["integration", "use", "codex"]) + assert result.exit_code == 0, result.output + + # After rescaffolding for the newly active codex integration, the + # high-precedence preset must still win -- not whichever preset + # register_enabled_presets_for_agent happened to write last. + codex_skill = project / ".agents" / "skills" / "speckit-specify" / "SKILL.md" + content = codex_skill.read_text(encoding="utf-8") + assert "HIGH PRECEDENCE CONTENT" in content, ( + "highest-precedence preset must win after `use` rescaffolds " + "presets for the newly active integration (#2948)" + ) + assert "LOW PRECEDENCE CONTENT" not in content + + def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" + assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "gemini", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) + assert use_gemini.exit_code == 0, use_gemini.output + assert "/speckit.plan" in template.read_text(encoding="utf-8") + assert "/speckit.plan" in script.read_text(encoding="utf-8") + assert "/speckit-plan" not in script.read_text(encoding="utf-8") + + use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False) + assert use_claude.exit_code == 0, use_claude.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "/speckit-plan" in script.read_text(encoding="utf-8") + assert "/speckit.plan" not in script.read_text(encoding="utf-8") + finally: + os.chdir(old_cwd) + + def test_use_preserves_modified_templates_unless_forced(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + template.write_text("custom template with /speckit-plan\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "gemini", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) + assert use_gemini.exit_code == 0, use_gemini.output + normalized = " ".join(use_gemini.output.split()) + assert "specify integration use gemini --force" in normalized + assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n" + + force_use = runner.invoke(app, [ + "integration", "use", "gemini", + "--force", + ], catch_exceptions=False) + assert force_use.exit_code == 0, force_use.output + finally: + os.chdir(old_cwd) + + updated = template.read_text(encoding="utf-8") + assert "/speckit.plan" in updated + assert "custom template" not in updated + + def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + init_options = project / ".specify" / "init-options.json" + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + before_state = json.loads(int_json.read_text(encoding="utf-8")) + before_options = json.loads(init_options.read_text(encoding="utf-8")) + import specify_cli + + def fail_refresh(*args, **kwargs): + raise ValueError("refuse refresh") + + monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh) + + result = runner.invoke(app, [ + "integration", "use", "codex", + "--force", + ]) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert "Failed to refresh shared infrastructure" in result.output + assert json.loads(int_json.read_text(encoding="utf-8")) == before_state + assert json.loads(init_options.read_text(encoding="utf-8")) == before_options diff --git a/tests/specify_cli/integrations/test_lifecycle.py b/tests/specify_cli/integrations/test_lifecycle.py new file mode 100644 index 0000000000..f5e3c1968c --- /dev/null +++ b/tests/specify_cli/integrations/test_lifecycle.py @@ -0,0 +1,59 @@ +"""Tests for mirrored integration CLI behavior in test_lifecycle.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + +class TestIntegrationLifecycle: + def test_install_modify_uninstall_preserves_modified(self, tmp_path): + """Full lifecycle: install → modify file → uninstall → verify modified file kept.""" + project = tmp_path / "lifecycle" + project.mkdir() + (project / ".specify").mkdir() + + old_cwd = os.getcwd() + try: + os.chdir(project) + + # Install + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert result.exit_code == 0 + assert "installed successfully" in result.output + + # Claude uses skills directory + plan_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + + # Modify one file + plan_file.write_text("# user customization\n", encoding="utf-8") + + # Uninstall + result = runner.invoke(app, ["integration", "uninstall"], catch_exceptions=False) + assert result.exit_code == 0 + assert "preserved" in result.output + + # Modified file kept + assert plan_file.exists() + assert plan_file.read_text(encoding="utf-8") == "# user customization\n" + finally: + os.chdir(old_cwd) diff --git a/tests/specify_cli/integrations/test_registration.py b/tests/specify_cli/integrations/test_registration.py new file mode 100644 index 0000000000..0b65d8f836 --- /dev/null +++ b/tests/specify_cli/integrations/test_registration.py @@ -0,0 +1,209 @@ +"""Tests for mirrored integration CLI behavior in test_registration.py.""" + +from __future__ import annotations + +import json # noqa: F401 +import os # noqa: F401 +import shutil # noqa: F401 +from pathlib import Path # noqa: F401 + +import pytest # noqa: F401 + +from specify_cli import app # noqa: F401 +from specify_cli.integrations import _commands +from specify_cli.integrations.catalog import catalog_app +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.integrations._catalog_helpers import ( + IntegrationCatalogCliTestBase, +) +from tests.specify_cli.integrations._helpers import ( + _copy_project_template, # noqa: F401 + _init_project, # noqa: F401 + _integration_list_row_cells, # noqa: F401 + _move_kilocode_install_to_legacy_layout, # noqa: F401 + _run_in_project, # noqa: F401 + _write_invalid_manifest, # noqa: F401 + runner, # noqa: F401 +) + + +def test_integration_commands_registered_once_in_stable_order(): + assert [command.name for command in _commands.integration_app.registered_commands] == [ + "install", + "uninstall", + "switch", + "upgrade", + "list", + "status", + "use", + "search", + "info", + "scaffold", + ] + assert [group.name for group in _commands.integration_app.registered_groups] == [ + "catalog" + ] + + +def test_catalog_commands_registered_once_in_stable_order(): + assert [command.name for command in catalog_app.registered_commands] == [ + "list", + "add", + "remove", + ] + assert _commands.integration_catalog_app is catalog_app + + +def test_legacy_grouped_command_imports_resolve_to_extracted_handlers(): + from specify_cli.integrations import ( + _install_commands, + _migrate_commands, + _query_commands, + _scaffold_commands, + ) + from specify_cli.integrations.command_install import integration_install + from specify_cli.integrations.command_list import integration_list + from specify_cli.integrations.command_scaffold import integration_scaffold + from specify_cli.integrations.command_upgrade import integration_upgrade + + assert _install_commands.integration_install is integration_install + assert _migrate_commands.integration_upgrade is integration_upgrade + assert _query_commands.integration_list is integration_list + assert _scaffold_commands.integration_scaffold is integration_scaffold + + +def test_version_lookup_remains_late_bound_through_commands_module(monkeypatch): + from specify_cli.integrations._helpers import _get_speckit_version + + monkeypatch.setattr(_commands, "get_speckit_version", lambda: "9.8.7-test") + + assert _get_speckit_version() == "9.8.7-test" + + +def test_upgrade_layout_helpers_remain_patchable_through_legacy_module(monkeypatch): + from specify_cli.integrations import _migrate_commands + from specify_cli.integrations import command_upgrade + + sentinel = object() + monkeypatch.setattr( + _migrate_commands, + "_installed_presets_affecting_agent", + lambda *_args, **_kwargs: sentinel, + ) + + assert command_upgrade._installed_presets_affecting_agent(".", "copilot") is sentinel + + +class TestParseIntegrationOptionsEqualsForm: + def test_equals_form_parsed(self): + """--commands-dir=./x should be parsed the same as --commands-dir ./x.""" + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + result_space = _parse_integration_options(integration, "--commands-dir ./mydir") + result_equals = _parse_integration_options(integration, "--commands-dir=./mydir") + assert result_space is not None + assert result_equals is not None + assert result_space["commands_dir"] == "./mydir" + assert result_equals["commands_dir"] == "./mydir" + + def test_unbalanced_quote_exits_cleanly(self, capsys): + """An unbalanced quote must exit(1) with a message, not a raw ValueError. + + shlex.split() raises ValueError("No closing quotation") on an unbalanced + quote; the parser must translate that into the same clean typer.Exit(1) + UX as unknown-option / missing-value, rather than letting the traceback + escape (issue #3457). + """ + import typer + + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + with pytest.raises(typer.Exit) as excinfo: + _parse_integration_options(integration, '--commands-dir "foo') + assert excinfo.value.exit_code == 1 + assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out + + def test_bad_option_token_with_rich_markup_exits_cleanly(self): + """A bad option token carrying Rich markup must exit cleanly, not crash. + + The token is user-controlled and gets interpolated into console.print. + A value like '[/red]foo' parses fine through shlex but is an unexpected + value / unknown option — and an unbalanced Rich tag would raise + rich.errors.MarkupError inside console.print, leaking a traceback + instead of the intended typer.Exit(1). The token must be escaped.""" + import typer + + from specify_cli.integrations._commands import _parse_integration_options + from specify_cli.integrations import get_integration + + integration = get_integration("generic") + assert integration is not None + + # Unexpected value token carrying markup. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "[/red]foo") + + # Unknown option token carrying markup. + with pytest.raises(typer.Exit): + _parse_integration_options(integration, "--[/red]bad") + + +@pytest.mark.parametrize( + "args", + [ + ["init", "--help"], + ["integration", "install", "--help"], + ["integration", "switch", "--help"], + ["integration", "upgrade", "--help"], + ], +) +def test_script_help_includes_python_variant(args): + result = runner.invoke(app, args) + + assert result.exit_code == 0 + assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split()) + + +class TestIntegrationProjectGuards(IntegrationCatalogCliTestBase): + def test_primary_integration_commands_require_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + commands = [ + ["integration", "list"], + ["integration", "install", "codex"], + ["integration", "use", "codex"], + ["integration", "uninstall"], + ["integration", "switch", "codex"], + ["integration", "upgrade"], + ] + + for command in commands: + result = self._invoke(command, project) + failure_context = ( + f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" + ) + assert result.exit_code == 1, failure_context + assert "Not a Spec Kit project" in result.output, failure_context + + def test_integration_commands_require_specify_directory(self, tmp_path): + project = tmp_path / "bad" + project.mkdir() + (project / ".specify").write_text("not a directory") + + commands = [ + ["integration", "list"], + ["integration", "use", "codex"], + ] + + for command in commands: + result = self._invoke(command, project) + assert result.exit_code == 1, result.output + assert "Not a Spec Kit project" in result.output diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 09f1c9203c..73156d5847 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -5770,7 +5770,7 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload) key-presence check and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_extensions``. The sibling integration catalog reader already validates both the root - object and the nested mapping (see ``integrations/catalog.py``); the + object and the nested mapping (see ``integrations/catalog/__init__.py``); the extension catalog must stay consistent. """ from unittest.mock import patch, MagicMock @@ -6088,7 +6088,7 @@ def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch): """An unwritable cache dir doesn't fail a successful fetch. Cache writes are best-effort, mirroring the read side and the - ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` + ``integrations/catalog/__init__.py`` precedent: if ``mkdir``/``write_text`` raises ``OSError`` (read-only checkout, permissions), the already-fetched-and-validated payload must still be returned rather than surfacing the cache failure to the caller. @@ -6140,7 +6140,7 @@ def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir): but it doesn't (and shouldn't) validate every entry inside it — a single bad entry in an otherwise-valid catalog should be skipped, not crash the whole resolve path. Mirrors the per-entry skip in - ``integrations/catalog.py``: a malformed entry returns no error, + ``integrations/catalog/__init__.py``: a malformed entry returns no error, valid entries continue to merge normally. """ from unittest.mock import patch, MagicMock diff --git a/tests/test_presets.py b/tests/test_presets.py index d71bd52588..c12d150a8e 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2742,7 +2742,7 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, paylo key-presence check and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_packs``. The sibling integration catalog reader already validates both the root - object and the nested mapping (see ``integrations/catalog.py``); the + object and the nested mapping (see ``integrations/catalog/__init__.py``); the preset catalog must stay consistent. """ from unittest.mock import patch, MagicMock @@ -3057,7 +3057,7 @@ def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch) """An unwritable cache dir doesn't fail a successful fetch. Cache writes are best-effort, mirroring the read side and the - ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` + ``integrations/catalog/__init__.py`` precedent: if ``mkdir``/``write_text`` raises ``OSError`` (read-only checkout, permissions), the already-fetched-and-validated payload must still be returned — not swallowed into the broad except and re-raised as a @@ -3112,7 +3112,7 @@ def test_get_merged_packs_skips_non_mapping_entries(self, project_dir): but it doesn't (and shouldn't) validate every entry inside it — a single bad entry in an otherwise-valid catalog should be skipped, not crash the whole resolve path. Mirrors the per-entry skip in - ``integrations/catalog.py``: a malformed entry returns no error, + ``integrations/catalog/__init__.py``: a malformed entry returns no error, valid entries continue to merge normally. """ from unittest.mock import patch, MagicMock From 3f988db515f6bb54e4437ed2f9d6de5a5ed80253 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:05:23 -0500 Subject: [PATCH 2/3] chore: keep integration refactor scoped Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/cli.md | 6 ------ src/specify_cli/extensions/__init__.py | 8 ++++---- src/specify_cli/integrations/bob/__init__.py | 2 +- src/specify_cli/presets/__init__.py | 8 ++++---- tests/integrations/test_events.py | 2 +- tests/test_extensions.py | 6 +++--- tests/test_presets.py | 6 +++--- 7 files changed, 16 insertions(+), 22 deletions(-) diff --git a/design/cli.md b/design/cli.md index 78f7c67813..eb563da736 100644 --- a/design/cli.md +++ b/design/cli.md @@ -157,12 +157,6 @@ subcommand. For example, an `update/` directory would incorrectly suggest an `extension update ...` subcommand group. Use `_command_update_.py` instead. -When a nested CLI namespace has the same name as an existing domain module, -convert that module into a package and keep its established domain exports in -the package `__init__.py`. This preserves imports such as -`from package.catalog import Catalog` while allowing -`package/catalog/command_.py` to mirror the CLI namespace. - ## Registration Command registration remains centralized at the command-group boundary. diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 50acfe0d00..775ade731c 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -3820,7 +3820,7 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: here and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_extensions``. The sibling integration catalog reader already guards both the root - object and the nested mapping (see ``integrations/catalog/__init__.py``); + object and the nested mapping (see ``integrations/catalog.py``); the extension catalog must stay consistent so a malformed payload surfaces as the user-facing ``Invalid catalog format`` error instead of a raw Python traceback. @@ -4039,7 +4039,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Save to cache. Both files are explicitly UTF-8 to match the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog/__init__.py`` precedent (see the cache write + # ``integrations/catalog.py`` precedent (see the cache write # helpers in ``CatalogCache`` there). Without this, platforms # whose default encoding isn't UTF-8 would write locale-encoded # bytes that the read path can't decode, forcing an unnecessary @@ -4119,7 +4119,7 @@ def _get_merged_extensions( # catalog. Skip non-mapping entries here so a payload like # ``{"extensions": {"foo": [], "bar": {...}}}`` still merges # the valid entries without crashing on ``**ext_data``. - # Mirrors ``integrations/catalog/__init__.py:245``. + # Mirrors ``integrations/catalog.py:245``. if not isinstance(ext_data, dict): continue if ext_id not in merged: # Higher-priority catalog wins @@ -4237,7 +4237,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Save to cache. Explicit UTF-8 on both writes mirrors the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog/__init__.py`` precedent — otherwise platforms + # ``integrations/catalog.py`` precedent — otherwise platforms # whose default encoding isn't UTF-8 would write locale-encoded # bytes the read path can't decode, forcing an unnecessary # refetch on every invocation. Like the read side, the write diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 821f03dd66..b1b5ce9a54 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -201,7 +201,7 @@ def is_skills_mode( *parsed_options* is typically empty: no flag was passed, and existing Bob 1.x installs never persisted a ``legacy_commands`` option to recover. This is independent of whether ``setup()`` runs — ``upgrade`` - *does* call :meth:`setup` (see ``command_upgrade.integration_upgrade``), + *does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``), but it passes those same empty *parsed_options*, so without disk detection the mode would resolve to the skills default. Defaulting to skills there would rewrite such a project's ``ai_skills`` flag to diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 3d1dba4139..b22a440661 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -4565,7 +4565,7 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_packs``. The sibling integration catalog reader already guards both the root object and - the nested mapping (see ``integrations/catalog/__init__.py``); the preset + the nested mapping (see ``integrations/catalog.py``); the preset catalog must stay consistent so a malformed payload surfaces as the user-facing ``Invalid preset catalog format`` error instead of a raw Python traceback. @@ -4868,7 +4868,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Both files are written explicitly as UTF-8 to match the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog/__init__.py`` precedent. Without this, + # ``integrations/catalog.py`` precedent. Without this, # platforms whose default encoding isn't UTF-8 would write # locale-encoded bytes the read path can't decode, forcing an # unnecessary refetch on every invocation. The write itself @@ -4923,7 +4923,7 @@ def _get_merged_packs(self, force_refresh: bool = False) -> Dict[str, Dict[str, # so a payload like ``{"presets": {"foo": [], "bar": # {...}}}`` still merges the valid entries without # crashing on ``**pack_data``. Mirrors - # ``integrations/catalog/__init__.py:245``. + # ``integrations/catalog.py:245``. if not isinstance(pack_data, dict): continue pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} @@ -5040,7 +5040,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: # Save to cache. Explicit UTF-8 on both writes mirrors the # ``read_text(encoding="utf-8")`` on the read side and the - # ``integrations/catalog/__init__.py`` precedent — otherwise platforms + # ``integrations/catalog.py`` precedent — otherwise platforms # whose default encoding isn't UTF-8 would write # locale-encoded bytes the read path can't decode, forcing an # unnecessary refetch on every invocation. Like the read diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 63e4c58e9d..16304c78a4 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -2996,7 +2996,7 @@ def test_fresh_manifest_upgrade_deletes_dispatcher_when_last(self, tmp_path): # Simulate the upgrade path: a fresh manifest (like # IntegrationManifest(key, project_root, version=...) in - # command_upgrade) that never recorded the dispatcher. + # _migrate_commands) that never recorded the dispatcher. fresh = IntegrationManifest(claude.key, tmp_path, version="test") assert EVENTS_DISPATCHER_REL not in fresh.files install_integration_events(claude, tmp_path, fresh, {}) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 73156d5847..09f1c9203c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -5770,7 +5770,7 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload) key-presence check and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_extensions``. The sibling integration catalog reader already validates both the root - object and the nested mapping (see ``integrations/catalog/__init__.py``); the + object and the nested mapping (see ``integrations/catalog.py``); the extension catalog must stay consistent. """ from unittest.mock import patch, MagicMock @@ -6088,7 +6088,7 @@ def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch): """An unwritable cache dir doesn't fail a successful fetch. Cache writes are best-effort, mirroring the read side and the - ``integrations/catalog/__init__.py`` precedent: if ``mkdir``/``write_text`` + ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` raises ``OSError`` (read-only checkout, permissions), the already-fetched-and-validated payload must still be returned rather than surfacing the cache failure to the caller. @@ -6140,7 +6140,7 @@ def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir): but it doesn't (and shouldn't) validate every entry inside it — a single bad entry in an otherwise-valid catalog should be skipped, not crash the whole resolve path. Mirrors the per-entry skip in - ``integrations/catalog/__init__.py``: a malformed entry returns no error, + ``integrations/catalog.py``: a malformed entry returns no error, valid entries continue to merge normally. """ from unittest.mock import patch, MagicMock diff --git a/tests/test_presets.py b/tests/test_presets.py index c12d150a8e..d71bd52588 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2742,7 +2742,7 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, project_dir, paylo key-presence check and then crash with ``AttributeError: 'list' object has no attribute 'items'`` deep inside ``_get_merged_packs``. The sibling integration catalog reader already validates both the root - object and the nested mapping (see ``integrations/catalog/__init__.py``); the + object and the nested mapping (see ``integrations/catalog.py``); the preset catalog must stay consistent. """ from unittest.mock import patch, MagicMock @@ -3057,7 +3057,7 @@ def test_fetch_catalog_survives_unwritable_cache(self, project_dir, monkeypatch) """An unwritable cache dir doesn't fail a successful fetch. Cache writes are best-effort, mirroring the read side and the - ``integrations/catalog/__init__.py`` precedent: if ``mkdir``/``write_text`` + ``integrations/catalog.py`` precedent: if ``mkdir``/``write_text`` raises ``OSError`` (read-only checkout, permissions), the already-fetched-and-validated payload must still be returned — not swallowed into the broad except and re-raised as a @@ -3112,7 +3112,7 @@ def test_get_merged_packs_skips_non_mapping_entries(self, project_dir): but it doesn't (and shouldn't) validate every entry inside it — a single bad entry in an otherwise-valid catalog should be skipped, not crash the whole resolve path. Mirrors the per-entry skip in - ``integrations/catalog/__init__.py``: a malformed entry returns no error, + ``integrations/catalog.py``: a malformed entry returns no error, valid entries continue to merge normally. """ from unittest.mock import patch, MagicMock From b3c9bc91538f2149cdedc40987eaaf254e34f63b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:45:25 -0500 Subject: [PATCH 3/3] refactor: finish integration CLI hierarchy Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- design/cli.md | 32 +- src/specify_cli/integrations/__init__.py | 868 ++++++++++++++++- .../_command_scaffold_generation.py} | 2 +- src/specify_cli/integrations/_commands.py | 6 +- .../integrations/_install_commands.py | 6 - .../integrations/_migrate_commands.py | 25 - .../integrations/_query_commands.py | 22 - .../integrations/_scaffold_commands.py | 13 - src/specify_cli/integrations/bob/__init__.py | 2 +- .../integrations/catalog/__init__.py | 898 +----------------- .../integrations/catalog/command_add.py | 2 +- .../integrations/catalog/command_list.py | 2 +- .../integrations/catalog/command_remove.py | 2 +- src/specify_cli/integrations/command_info.py | 4 +- src/specify_cli/integrations/command_list.py | 2 +- .../integrations/command_scaffold.py | 5 +- .../integrations/command_search.py | 4 +- .../integrations/command_upgrade.py | 36 +- tests/integrations/test_events.py | 2 +- .../integrations/_catalog_helpers.py | 2 +- .../integrations/_scaffold_helpers.py} | 2 +- .../integrations/catalog/test_command_list.py | 2 +- .../integrations/test_catalog.py} | 10 +- .../integrations/test_command_scaffold.py | 7 +- .../test_command_scaffold_generation.py} | 6 +- .../integrations/test_command_search.py | 6 +- .../integrations/test_registration.py | 33 +- 27 files changed, 964 insertions(+), 1037 deletions(-) rename src/specify_cli/{integration_scaffold.py => integrations/_command_scaffold_generation.py} (99%) delete mode 100644 src/specify_cli/integrations/_install_commands.py delete mode 100644 src/specify_cli/integrations/_migrate_commands.py delete mode 100644 src/specify_cli/integrations/_query_commands.py delete mode 100644 src/specify_cli/integrations/_scaffold_commands.py rename tests/{integrations/_integration_scaffold_helpers.py => specify_cli/integrations/_scaffold_helpers.py} (88%) rename tests/{integrations/test_integration_catalog.py => specify_cli/integrations/test_catalog.py} (99%) rename tests/{integrations/test_integration_scaffold.py => specify_cli/integrations/test_command_scaffold_generation.py} (95%) diff --git a/design/cli.md b/design/cli.md index eb563da736..ff1ae26782 100644 --- a/design/cli.md +++ b/design/cli.md @@ -148,6 +148,12 @@ extensions/ The nested package's `__init__.py` owns its Typer application and registration. Shared helpers for that nested surface can live in `_helpers.py`. +Creating a nested CLI package does not transfer same-named domain behavior into +that package. If an existing domain module collides with a new nested command +namespace, keep the implementation in the parent domain package (or a focused +domain module there). Preserve an established import path through thin +compatibility exports from the nested package when required. + Do not add a nested `_commands.py` merely for symmetry. Create one only when the nested group develops substantial shared command infrastructure that no longer fits cleanly in `__init__.py` and `_helpers.py`. @@ -208,10 +214,30 @@ The primary `test_command_.py` suite verifies the public command surface. Phase-specific suites verify detailed invariants without obscuring the primary command behavior. -Not every test belongs in the mirrored command tree: +Domain source remains in the parent package's `__init__.py` or a focused +domain module without the `command_` prefix. Its mirrored tests use the domain +subject name, for example: + +```text +src/specify_cli/integrations/__init__.py # catalog domain API +tests/specify_cli/integrations/test_catalog.py + +src/specify_cli/integrations/command_search.py +tests/specify_cli/integrations/test_command_search.py +``` + +Do not put `test_.py` under a nested command directory merely because +the domain has the same name as that CLI namespace. The nested directory is +reserved for `test_command_.py` suites that exercise its actual +subcommands. + +Not every test is a command test, even when it belongs in the mirrored package +tree: -- Domain model, registry, manager, and catalog behavior remains in domain test - suites such as `tests/test_extensions.py`. +- Domain model, registry, manager, and catalog behavior belongs at the parent + package level, not under a nested command namespace and not in + `test_command_*.py`. Existing consolidated domain suites such as + `tests/test_extensions.py` may remain in place until separately reorganized. - Cross-domain CLI contracts remain with the broader integration tests. - Shared fixtures belong in the narrowest `conftest.py` that serves all of their consumers. diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index a7ed9d0e59..9f8dbd66fd 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -7,7 +7,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import hashlib +import json +import os +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +import yaml +from packaging import version as pkg_version + +from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited +from ..catalogs import CatalogEntry, CatalogStackBase if TYPE_CHECKING: from .base import IntegrationBase @@ -135,3 +148,856 @@ def _register_builtins() -> None: _register_builtins() + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class IntegrationCatalogError(Exception): + """Raised when a catalog operation fails.""" + + +class IntegrationValidationError(IntegrationCatalogError): + """Validation error for catalog config or catalog management operations.""" + + +class IntegrationDescriptorError(Exception): + """Raised when an integration.yml descriptor is invalid.""" + + +def _catalog_shape_error(payload: Any) -> Optional[str]: + """Return a human-readable reason if *payload* is not a valid integration + catalog document, else ``None``. + + Shared by the fresh-fetch and cache-read paths so both enforce the same + format contract: a JSON object carrying ``schema_version`` and a mapping + ``integrations``. Keeping a single validator prevents the two paths from + drifting (e.g. a cache that skips the ``schema_version`` check and lets an + older/poisoned payload bypass validation). + """ + if not isinstance(payload, dict): + return "expected a JSON object" + if "schema_version" not in payload or "integrations" not in payload: + return "missing required 'schema_version' or 'integrations' key" + if not isinstance(payload.get("integrations"), dict): + return "'integrations' must be a JSON object" + return None + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + +@dataclass +class IntegrationCatalogEntry(CatalogEntry): + """Represents a single catalog source in the catalog stack.""" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog +# --------------------------------------------------------------------------- + +class IntegrationCatalog(CatalogStackBase): + """Manages integration catalog fetching, caching, and searching.""" + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + CONFIG_FILENAME = "integration-catalogs.yml" + ENTRY_CLASS = IntegrationCatalogEntry + ERROR_TYPE = IntegrationCatalogError + VALIDATION_ERROR_TYPE = IntegrationValidationError + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.cache_dir = project_root / ".specify" / "integrations" / ".cache" + + def get_active_catalogs(self) -> List[IntegrationCatalogEntry]: + """Return the ordered list of active integration catalogs. + + Resolution: + 1. ``SPECKIT_INTEGRATION_CATALOG_URL`` env var + 2. Project ``.specify/integration-catalogs.yml`` + 3. User ``~/.specify/integration-catalogs.yml`` + 4. Built-in defaults (built-in + community) + """ + import sys + + env_value = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + if env_value: + self._validate_catalog_url(env_value) + if env_value != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default integration catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [ + IntegrationCatalogEntry( + url=env_value, + name="custom", + priority=1, + install_allowed=True, + description="Custom catalog via SPECKIT_INTEGRATION_CATALOG_URL", + ) + ] + + project_cfg = self.project_root / ".specify" / self.CONFIG_FILENAME + catalogs = self._load_catalog_config(project_cfg) + if catalogs is not None: + return catalogs + + user_cfg = Path.home() / ".specify" / self.CONFIG_FILENAME + catalogs = self._load_catalog_config(user_cfg) + if catalogs is not None: + return catalogs + + return [ + IntegrationCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Built-in catalog of installable integrations", + ), + IntegrationCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed integrations (discovery only)", + ), + ] + + # -- Fetching --------------------------------------------------------- + + def _fetch_single_catalog( + self, + entry: IntegrationCatalogEntry, + force_refresh: bool = False, + ) -> Dict[str, Any]: + """Fetch one catalog, with per-URL caching.""" + import urllib.error + + url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"catalog-{url_hash}.json" + cache_meta = self.cache_dir / f"catalog-{url_hash}-metadata.json" + + if not force_refresh and cache_file.exists() and cache_meta.exists(): + try: + meta = json.loads(cache_meta.read_text(encoding="utf-8")) + cached_at = datetime.fromisoformat(meta.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - cached_at).total_seconds() + if age < self.CACHE_DURATION: + cached = json.loads(cache_file.read_text(encoding="utf-8")) + # A poisoned/older-format cache must clear the SAME shape + # contract as a fresh fetch (via the shared validator) — + # otherwise a payload like [], {"integrations": []}, or one + # missing "schema_version" is returned and later crashes on + # .items()/.get() or silently bypasses the format contract. + # The ValueError is caught just below, which drops the + # corrupt cache and refetches from source. + shape_error = _catalog_shape_error(cached) + if shape_error is not None: + raise ValueError(f"cached catalog has invalid shape: {shape_error}") + return cached + except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError): + # Cache is invalid or stale metadata; delete and refetch from source. + try: + cache_file.unlink(missing_ok=True) + cache_meta.unlink(missing_ok=True) + except OSError: + pass # Cache cleanup is best-effort; ignore deletion failures. + + try: + from specify_cli.authentication.http import open_url + + with open_url(entry.url, timeout=10) as resp: + # Validate final URL after redirects + final_url = resp.geturl() + if final_url != entry.url: + self._validate_catalog_url(final_url) + catalog_data = json.loads( + read_response_limited( + resp, + max_bytes=MAX_JSON_METADATA_BYTES, + error_type=IntegrationCatalogError, + label=f"catalog from {entry.url}", + ).decode("utf-8") + ) + + shape_error = _catalog_shape_error(catalog_data) + if shape_error is not None: + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: {shape_error}" + ) + + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2), encoding="utf-8") + cache_meta.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, + indent=2, + ), + encoding="utf-8", + ) + except OSError: + pass # Cache is best-effort; proceed with fetched data + return catalog_data + + except urllib.error.URLError as exc: + raise IntegrationCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) + except UnicodeDecodeError as exc: + # A non-UTF-8 response body fails at .decode() before json.loads() + # ever runs, so JSONDecodeError below does not cover it (the two are + # sibling ValueError subclasses, not parent/child). Without this the + # raw UnicodeDecodeError escapes _get_merged_integrations()'s + # "warn and skip this catalog" handler and kills the whole command. + raise IntegrationCatalogError( + f"Catalog from {entry.url} is not valid UTF-8: {exc}" + ) + except json.JSONDecodeError as exc: + raise IntegrationCatalogError( + f"Invalid JSON in catalog from {entry.url}: {exc}" + ) + + def _get_merged_integrations( + self, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """Fetch and merge integrations from all active catalogs. + + Catalogs are processed in the order returned by + :meth:`get_active_catalogs`. On conflicts, the first catalog in that + order wins (lower numeric priority = higher precedence). Each dict is + annotated with ``_catalog_name`` and ``_install_allowed``. + """ + import sys + + active = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + any_success = False + + for entry in active: + try: + data = self._fetch_single_catalog(entry, force_refresh) + any_success = True + except IntegrationCatalogError as exc: + print( + f"Warning: Could not fetch catalog '{entry.name}': {exc}", + file=sys.stderr, + ) + continue + + for integ_id, integ_data in data.get("integrations", {}).items(): + if not isinstance(integ_data, dict): + continue + if integ_id not in merged: + merged[integ_id] = { + **integ_data, + "id": integ_id, + "_catalog_name": entry.name, + "_install_allowed": entry.install_allowed, + } + + if not any_success and active: + raise IntegrationCatalogError( + "Failed to fetch any integration catalog" + ) + + return list(merged.values()) + + # -- Search / info ---------------------------------------------------- + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalogs for integrations matching the given filters.""" + results: List[Dict[str, Any]] = [] + for item in self._get_merged_integrations(): + author_val = item.get("author", "") + if not isinstance(author_val, str): + author_val = str(author_val) if author_val is not None else "" + if author and author_val.lower() != author.lower(): + continue + if tag: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + if tag.lower() not in [t.lower() for t in tags_list if isinstance(t, str)]: + continue + if query: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + name_val = item.get("name", "") + desc_val = item.get("description", "") + id_val = item.get("id", "") + haystack = " ".join( + [ + str(name_val) if name_val else "", + str(desc_val) if desc_val else "", + str(id_val) if id_val else "", + ] + + [t for t in tags_list if isinstance(t, str)] + ).lower() + if query.lower() not in haystack: + continue + results.append(item) + return results + + def get_integration_info( + self, integration_id: str + ) -> Optional[Dict[str, Any]]: + """Return catalog metadata for a single integration, or None.""" + for item in self._get_merged_integrations(): + if item["id"] == integration_id: + return item + return None + + # -- Cache management ------------------------------------------------- + + def clear_cache(self) -> None: + """Remove all cached catalog files.""" + if self.cache_dir.exists(): + for pattern in ("catalog-*.json", "catalog-*-metadata.json"): + for f in self.cache_dir.glob(pattern): + f.unlink(missing_ok=True) + + # -- Catalog-source management ---------------------------------------- + + def get_catalog_configs(self) -> List[Dict[str, Any]]: + """Return the active catalog stack as a list of dicts. + + Thin adapter over :meth:`get_active_catalogs` that yields plain dicts + suitable for CLI rendering and JSON-like consumers. + """ + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in self.get_active_catalogs() + ] + + def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]: + """Return removable project-level catalog config entries, if configured.""" + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + entries = self._load_catalog_config(config_path) + if entries is None: + return None + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in entries + ] + + def add_catalog(self, url: str, name: Optional[str] = None) -> None: + """Add a catalog source to the project-level config file. + + The URL is normalized (whitespace stripped) and validated before being + written. Duplicate URLs are rejected, including near-duplicates that + differ only by surrounding whitespace. Priority is derived as + ``max(existing) + 1`` so the new entry sorts last in the resolution + order unless the user edits the file manually. + """ + url = url.strip() + if not url: + raise IntegrationValidationError("Catalog URL must be non-empty.") + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + + data: Dict[str, Any] = {"catalogs": []} + if config_path.exists(): + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise IntegrationValidationError( + f"Catalog config file {config_path} is corrupted " + "(expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise IntegrationValidationError( + f"Catalog config {config_path} has invalid 'catalogs' value: " + "must be a list." + ) + + # Validate each existing entry before mutating anything. Fail fast so + # we don't silently preserve a corrupt sibling entry or derive a new + # priority from a bogus value. + existing_priorities: List[int] = [] + valid_catalog_count = 0 + for idx, cat in enumerate(catalogs): + if not isinstance(cat, dict): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"expected a mapping, got {type(cat).__name__}." + ) + existing_url = str(cat.get("url", "")).strip() + if not existing_url: + continue + # Re-run the same URL validation used when loading, so a corrupt + # entry surfaces here instead of at the next `integration` call. + try: + self._validate_catalog_url(existing_url) + except IntegrationCatalogError as exc: + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: {exc}" + ) from exc + if existing_url == url: + raise IntegrationValidationError( + f"Catalog URL already configured: {url}" + ) + valid_catalog_count += 1 + if "priority" in cat: + raw_priority = cat.get("priority") + if isinstance(raw_priority, bool): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"'priority' must be an integer, got " + f"{type(raw_priority).__name__}." + ) + try: + normalized_priority = int(raw_priority) + except (TypeError, ValueError, OverflowError): + # OverflowError: int(float("inf")) — a ``priority: .inf``. + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"'priority' must be an integer, got " + f"{raw_priority!r}." + ) from None + existing_priorities.append(normalized_priority) + else: + # Match `_load_catalog_config()`'s defaulting rule so the new + # entry still sorts after implicit-priority siblings. + existing_priorities.append(idx + 1) + + max_priority = max(existing_priorities, default=0) + normalized_name = str(name).strip() if name is not None else "" + generated_name = f"catalog-{valid_catalog_count + 1}" + catalogs.append( + { + "name": normalized_name or generated_name, + "url": url, + "priority": max_priority + 1, + "install_allowed": True, + "description": "", + } + ) + data["catalogs"] = catalogs + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump( + data, + f, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + def remove_catalog(self, index: int) -> str: + """Remove a catalog source by 0-based index. + + ``index`` is interpreted in the same display order shown by + ``integration catalog list`` (i.e. sorted ascending by priority, + with missing priority defaulting to ``yaml_index + 1``, matching + ``_load_catalog_config()``). This way, the index a user sees in + ``catalog list`` is the index they pass to ``catalog remove``, + even if the underlying YAML lists entries in a different order + from how they sort by priority. + + Returns the removed catalog's name. + """ + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + if not config_path.exists(): + raise IntegrationValidationError("No catalog config file found.") + + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if data is None: + data = {} + if not isinstance(data, dict): + raise IntegrationValidationError( + f"Catalog config file {config_path} is corrupted " + "(expected a mapping)." + ) + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise IntegrationValidationError( + f"Catalog config {config_path} has invalid 'catalogs' value: " + "must be a list." + ) + + if not catalogs: + # An empty list is the kind of state that only happens if the + # user hand-edited the file; our own `remove_catalog` deletes + # the file when the last entry is popped. Surface a clear + # message instead of `out of range (0--1)`. + raise IntegrationValidationError( + "Catalog config contains no catalog entries." + ) + + # Map displayed index -> raw YAML index using the same priority + # defaulting as ``_load_catalog_config``. We deliberately stay + # tolerant here (no new validation errors) because the goal is + # only to mirror the order shown by ``catalog list``; entries + # that ``_load_catalog_config`` would have rejected outright + # would have failed ``catalog list`` already. + def _is_removable_catalog_entry(item: Any) -> bool: + if not isinstance(item, dict): + return False + raw_url = item.get("url") + if raw_url is None: + return False + return bool(str(raw_url).strip()) + + priority_pairs: List[Tuple[int, int]] = [] + for yaml_idx, item in enumerate(catalogs): + if not _is_removable_catalog_entry(item): + continue + + raw_priority = item.get("priority", yaml_idx + 1) + if isinstance(raw_priority, bool): + priority = yaml_idx + 1 + else: + try: + priority = int(raw_priority) + except (TypeError, ValueError, OverflowError): + # OverflowError: int(float("inf")) — a ``priority: .inf``. + priority = yaml_idx + 1 + priority_pairs.append((priority, yaml_idx)) + if not priority_pairs: + raise IntegrationValidationError( + "Catalog config contains no removable catalog entries." + ) + # Stable sort: ties keep their YAML order, matching list-view ordering. + priority_pairs.sort(key=lambda p: p[0]) + display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs] + + if index < 0 or index >= len(display_order): + raise IntegrationValidationError( + f"Catalog index {index} out of range (0-{len(display_order) - 1})." + ) + + target_yaml_idx = display_order[index] + removed = catalogs.pop(target_yaml_idx) + + if any(_is_removable_catalog_entry(item) for item in catalogs): + data["catalogs"] = catalogs + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump( + data, + f, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + else: + # Removing the final entry: delete the config file rather than + # leaving behind an empty `catalogs:` list. `_load_catalog_config` + # treats an empty list as an error, so leaving the file would + # break every subsequent `integration` command until the user + # manually deletes `.specify/integration-catalogs.yml`. + # Deleting the file lets the project fall back to built-in + # defaults, which matches the behavior before any + # `catalog add` was ever run. + try: + config_path.unlink(missing_ok=True) + except OSError as exc: + raise IntegrationValidationError( + f"Failed to delete catalog config {config_path}: {exc}" + ) from exc + + fallback_name = f"catalog-{index + 1}" + if isinstance(removed, dict): + removed_name = removed.get("name") + if removed_name is not None: + normalized_name = str(removed_name).strip() + if normalized_name: + return normalized_name + + removed_url = removed.get("url") + if removed_url is not None: + normalized_url = str(removed_url).strip() + if normalized_url: + return normalized_url + return fallback_name + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +class IntegrationDescriptor: + """Loads and validates an ``integration.yml`` descriptor. + + The descriptor mirrors ``extension.yml`` and ``preset.yml``:: + + schema_version: "1.0" + integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + requires: + speckit_version: ">=0.6.0" + tools: [...] + provides: + commands: [...] + scripts: [...] + """ + + SCHEMA_VERSION = "1.0" + REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"] + + def __init__(self, descriptor_path: Path) -> None: + self.path = descriptor_path + self.data = self._load(descriptor_path) + self._validate() + + # -- Loading ---------------------------------------------------------- + + @staticmethod + def _load(path: Path) -> dict: + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + raise IntegrationDescriptorError(f"Descriptor not found: {path}") + except (OSError, UnicodeError) as exc: + raise IntegrationDescriptorError( + f"Unable to read descriptor {path}: {exc}" + ) + try: + # ``safe_load`` returns None for BOTH an empty document and an + # explicit null scalar (``null``, ``~``, ``Null``, ``NULL``), so it + # cannot tell them apart on its own. ``compose`` yields no node + # only for a genuinely empty document. + node = yaml.compose(text) + data = yaml.safe_load(text) + is_empty_document = node is None or ( + data is None + and isinstance(node, yaml.nodes.ScalarNode) + and node.value == "" + and node.start_mark.index == node.end_mark.index + ) + except yaml.YAMLError as exc: + raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + # Only a genuinely EMPTY document becomes an empty mapping, so its + # missing-field errors are reported. Every non-mapping document -- + # including an explicit ``null``/``~`` and the falsy shapes ``[]``, + # ``false``, ``0``, ``''`` that a plain ``or {}`` would mask -- must + # reach ``_validate`` unchanged so it reports the wrong descriptor + # shape, like the truthy twins (``- a``, ``hello``) already do. + if is_empty_document: + data = {} + return data + + # -- Validation ------------------------------------------------------- + + def _validate(self) -> None: + if not isinstance(self.data, dict): + raise IntegrationDescriptorError( + f"Descriptor root must be a YAML mapping, got {type(self.data).__name__}" + ) + for field in self.REQUIRED_TOP_LEVEL: + if field not in self.data: + raise IntegrationDescriptorError( + f"Missing required field: {field}" + ) + + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise IntegrationDescriptorError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + integ = self.data["integration"] + if not isinstance(integ, dict): + raise IntegrationDescriptorError( + "'integration' must be a mapping" + ) + for field in ("id", "name", "version", "description"): + if field not in integ: + raise IntegrationDescriptorError( + f"Missing integration.{field}" + ) + if not isinstance(integ[field], str): + raise IntegrationDescriptorError( + f"integration.{field} must be a string, got {type(integ[field]).__name__}" + ) + + if not re.match(r"^[a-z0-9-]+$", integ["id"]): + raise IntegrationDescriptorError( + f"Invalid integration ID '{integ['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + try: + pkg_version.Version(integ["version"]) + except (pkg_version.InvalidVersion, TypeError): + raise IntegrationDescriptorError( + f"Invalid version '{integ['version']}'" + ) + + requires = self.data["requires"] + if not isinstance(requires, dict): + raise IntegrationDescriptorError( + "'requires' must be a mapping" + ) + if "speckit_version" not in requires: + raise IntegrationDescriptorError( + "Missing requires.speckit_version" + ) + if not isinstance(requires["speckit_version"], str) or not requires["speckit_version"].strip(): + raise IntegrationDescriptorError( + "requires.speckit_version must be a non-empty string" + ) + tools = requires.get("tools") + if tools is not None: + if not isinstance(tools, list): + raise IntegrationDescriptorError( + "requires.tools must be a list" + ) + for tool in tools: + if not isinstance(tool, dict): + raise IntegrationDescriptorError( + "Each requires.tools entry must be a mapping" + ) + tool_name = tool.get("name") + if not isinstance(tool_name, str) or not tool_name.strip(): + raise IntegrationDescriptorError( + "requires.tools entry 'name' must be a non-empty string" + ) + + provides = self.data["provides"] + if not isinstance(provides, dict): + raise IntegrationDescriptorError( + "'provides' must be a mapping" + ) + commands = provides.get("commands", []) + scripts = provides.get("scripts", []) + if "commands" in provides and not isinstance(commands, list): + raise IntegrationDescriptorError( + "Invalid provides.commands: expected a list" + ) + if "scripts" in provides and not isinstance(scripts, list): + raise IntegrationDescriptorError( + "Invalid provides.scripts: expected a list" + ) + if not commands and not scripts: + raise IntegrationDescriptorError( + "Integration must provide at least one command or script" + ) + for cmd in commands: + if not isinstance(cmd, dict): + raise IntegrationDescriptorError( + "Each command entry must be a mapping" + ) + if "name" not in cmd or "file" not in cmd: + raise IntegrationDescriptorError( + "Command entry missing 'name' or 'file'" + ) + cmd_name = cmd["name"] + cmd_file = cmd["file"] + if not isinstance(cmd_name, str) or not cmd_name.strip(): + raise IntegrationDescriptorError( + "Command entry 'name' must be a non-empty string" + ) + if not isinstance(cmd_file, str) or not cmd_file.strip(): + raise IntegrationDescriptorError( + "Command entry 'file' must be a non-empty string" + ) + if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor: + raise IntegrationDescriptorError( + f"Command entry 'file' must be a relative path without '..': {cmd_file}" + ) + for script_entry in scripts: + if not isinstance(script_entry, str) or not script_entry.strip(): + raise IntegrationDescriptorError( + "Script entry must be a non-empty string" + ) + if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor: + raise IntegrationDescriptorError( + f"Script entry must be a relative path without '..': {script_entry}" + ) + + # -- Property accessors ----------------------------------------------- + + @property + def id(self) -> str: + return self.data["integration"]["id"] + + @property + def name(self) -> str: + return self.data["integration"]["name"] + + @property + def version(self) -> str: + return self.data["integration"]["version"] + + @property + def description(self) -> str: + return self.data["integration"]["description"] + + @property + def requires_speckit_version(self) -> str: + return self.data["requires"]["speckit_version"] + + @property + def commands(self) -> List[Dict[str, Any]]: + return self.data.get("provides", {}).get("commands", []) + + @property + def scripts(self) -> List[str]: + return self.data.get("provides", {}).get("scripts", []) + + @property + def tools(self) -> List[Dict[str, Any]]: + return self.data.get("requires", {}).get("tools") or [] + + def get_hash(self) -> str: + """SHA-256 hash of the descriptor file.""" + h = hashlib.sha256() + with open(self.path, "rb") as fh: + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + return f"sha256:{h.hexdigest()}" diff --git a/src/specify_cli/integration_scaffold.py b/src/specify_cli/integrations/_command_scaffold_generation.py similarity index 99% rename from src/specify_cli/integration_scaffold.py rename to src/specify_cli/integrations/_command_scaffold_generation.py index f0ed210332..c9f20302fa 100644 --- a/src/specify_cli/integration_scaffold.py +++ b/src/specify_cli/integrations/_command_scaffold_generation.py @@ -1,4 +1,4 @@ -"""Developer helpers for scaffolding built-in integrations.""" +"""Generation phase for the ``specify integration scaffold`` command.""" from __future__ import annotations diff --git a/src/specify_cli/integrations/_commands.py b/src/specify_cli/integrations/_commands.py index 41171729f7..b8dd4542e7 100644 --- a/src/specify_cli/integrations/_commands.py +++ b/src/specify_cli/integrations/_commands.py @@ -1,8 +1,7 @@ """Shared infrastructure and registration for ``specify integration`` commands. -Command handlers belong in ``command_*.py`` modules. Thin compatibility -re-exports preserve established direct-import paths in the former grouped -command modules. +Command handlers belong in ``command_*.py`` modules. Compatibility exports +required by external CLI consumers remain at this registration boundary. """ from __future__ import annotations @@ -25,6 +24,7 @@ add_completion=False, ) + def register(app: typer.Typer) -> None: """Attach the integration command group to the root Typer app.""" from .catalog import register as register_catalog diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py deleted file mode 100644 index 23052805a0..0000000000 --- a/src/specify_cli/integrations/_install_commands.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Compatibility imports for the extracted install and uninstall commands.""" - -from .command_install import integration_install -from .command_uninstall import integration_uninstall - -__all__ = ["integration_install", "integration_uninstall"] diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py deleted file mode 100644 index c4470a3227..0000000000 --- a/src/specify_cli/integrations/_migrate_commands.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Compatibility imports for the extracted switch and upgrade commands.""" - -from .command_switch import integration_switch -from .command_upgrade import integration_upgrade -from ._command_upgrade_layout import ( - _PresetRegistryUnreadableError, - _installed_command_presets_affecting_agent, - _installed_presets_affecting_agent, - _legacy_command_root_changed, - _legacy_command_root_upgrade_pending, - _manifest_path_under, - _manifest_tracks_skill_layout, -) - -__all__ = [ - "_PresetRegistryUnreadableError", - "_installed_command_presets_affecting_agent", - "_installed_presets_affecting_agent", - "_legacy_command_root_changed", - "_legacy_command_root_upgrade_pending", - "_manifest_path_under", - "_manifest_tracks_skill_layout", - "integration_switch", - "integration_upgrade", -] diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py deleted file mode 100644 index 292ff79d4b..0000000000 --- a/src/specify_cli/integrations/_query_commands.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Compatibility imports for extracted query and catalog commands.""" - -from .catalog.command_add import integration_catalog_add -from .catalog.command_list import integration_catalog_list -from .catalog.command_remove import integration_catalog_remove -from .command_info import integration_info -from .command_list import integration_list -from .command_search import integration_search -from .command_status import _print_integration_status_report, integration_status -from .command_use import integration_use - -__all__ = [ - "_print_integration_status_report", - "integration_catalog_add", - "integration_catalog_list", - "integration_catalog_remove", - "integration_info", - "integration_list", - "integration_search", - "integration_status", - "integration_use", -] diff --git a/src/specify_cli/integrations/_scaffold_commands.py b/src/specify_cli/integrations/_scaffold_commands.py deleted file mode 100644 index 470edfdaf9..0000000000 --- a/src/specify_cli/integrations/_scaffold_commands.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Compatibility imports for the extracted scaffold command.""" - -from .command_scaffold import ( - INTEGRATION_SCAFFOLD_TYPES, - _IntegrationScaffoldType, - integration_scaffold, -) - -__all__ = [ - "INTEGRATION_SCAFFOLD_TYPES", - "_IntegrationScaffoldType", - "integration_scaffold", -] diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index b1b5ce9a54..821f03dd66 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -201,7 +201,7 @@ def is_skills_mode( *parsed_options* is typically empty: no flag was passed, and existing Bob 1.x installs never persisted a ``legacy_commands`` option to recover. This is independent of whether ``setup()`` runs — ``upgrade`` - *does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``), + *does* call :meth:`setup` (see ``command_upgrade.integration_upgrade``), but it passes those same empty *parsed_options*, so without disk detection the mode would resolve to the skills default. Defaulting to skills there would rewrite such a project's ``ai_skills`` flag to diff --git a/src/specify_cli/integrations/catalog/__init__.py b/src/specify_cli/integrations/catalog/__init__.py index 57feb7f01d..69667a4871 100644 --- a/src/specify_cli/integrations/catalog/__init__.py +++ b/src/specify_cli/integrations/catalog/__init__.py @@ -1,885 +1,35 @@ -"""Integration catalog domain API and nested CLI registration. +"""Registration for the nested ``specify integration catalog`` command group. -Provides: -- ``IntegrationCatalogEntry`` — single catalog source metadata. -- ``IntegrationCatalog`` — fetches, caches, and searches integration - catalogs (built-in + community). -- ``IntegrationDescriptor`` — loads and validates ``integration.yml``. - -The ``specify integration catalog`` handlers live in adjacent -``command_*.py`` modules. +Command handlers live in ``command_*.py`` modules. Catalog domain exports are +retained here for compatibility; their implementation belongs to the parent +``specify_cli.integrations`` package. """ from __future__ import annotations -import hashlib -import json -import os -import re -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - import typer -import yaml -from packaging import version as pkg_version - -from ..._download_security import MAX_JSON_METADATA_BYTES, read_response_limited -from ...catalogs import CatalogEntry, CatalogStackBase - - -# --------------------------------------------------------------------------- -# Errors -# --------------------------------------------------------------------------- - -class IntegrationCatalogError(Exception): - """Raised when a catalog operation fails.""" - - -class IntegrationValidationError(IntegrationCatalogError): - """Validation error for catalog config or catalog management operations.""" - - -class IntegrationDescriptorError(Exception): - """Raised when an integration.yml descriptor is invalid.""" - - -def _catalog_shape_error(payload: Any) -> Optional[str]: - """Return a human-readable reason if *payload* is not a valid integration - catalog document, else ``None``. - - Shared by the fresh-fetch and cache-read paths so both enforce the same - format contract: a JSON object carrying ``schema_version`` and a mapping - ``integrations``. Keeping a single validator prevents the two paths from - drifting (e.g. a cache that skips the ``schema_version`` check and lets an - older/poisoned payload bypass validation). - """ - if not isinstance(payload, dict): - return "expected a JSON object" - if "schema_version" not in payload or "integrations" not in payload: - return "missing required 'schema_version' or 'integrations' key" - if not isinstance(payload.get("integrations"), dict): - return "'integrations' must be a JSON object" - return None - - -# --------------------------------------------------------------------------- -# IntegrationCatalogEntry -# --------------------------------------------------------------------------- - -@dataclass -class IntegrationCatalogEntry(CatalogEntry): - """Represents a single catalog source in the catalog stack.""" - - -# --------------------------------------------------------------------------- -# IntegrationCatalog -# --------------------------------------------------------------------------- - -class IntegrationCatalog(CatalogStackBase): - """Manages integration catalog fetching, caching, and searching.""" - - DEFAULT_CATALOG_URL = ( - "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json" - ) - COMMUNITY_CATALOG_URL = ( - "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json" - ) - CACHE_DURATION = 3600 # 1 hour - CONFIG_FILENAME = "integration-catalogs.yml" - ENTRY_CLASS = IntegrationCatalogEntry - ERROR_TYPE = IntegrationCatalogError - VALIDATION_ERROR_TYPE = IntegrationValidationError - - def __init__(self, project_root: Path) -> None: - self.project_root = project_root - self.cache_dir = project_root / ".specify" / "integrations" / ".cache" - - def get_active_catalogs(self) -> List[IntegrationCatalogEntry]: - """Return the ordered list of active integration catalogs. - - Resolution: - 1. ``SPECKIT_INTEGRATION_CATALOG_URL`` env var - 2. Project ``.specify/integration-catalogs.yml`` - 3. User ``~/.specify/integration-catalogs.yml`` - 4. Built-in defaults (built-in + community) - """ - import sys - - env_value = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() - if env_value: - self._validate_catalog_url(env_value) - if env_value != self.DEFAULT_CATALOG_URL: - if not getattr(self, "_non_default_catalog_warning_shown", False): - print( - "Warning: Using non-default integration catalog. " - "Only use catalogs from sources you trust.", - file=sys.stderr, - ) - self._non_default_catalog_warning_shown = True - return [ - IntegrationCatalogEntry( - url=env_value, - name="custom", - priority=1, - install_allowed=True, - description="Custom catalog via SPECKIT_INTEGRATION_CATALOG_URL", - ) - ] - - project_cfg = self.project_root / ".specify" / self.CONFIG_FILENAME - catalogs = self._load_catalog_config(project_cfg) - if catalogs is not None: - return catalogs - - user_cfg = Path.home() / ".specify" / self.CONFIG_FILENAME - catalogs = self._load_catalog_config(user_cfg) - if catalogs is not None: - return catalogs - - return [ - IntegrationCatalogEntry( - url=self.DEFAULT_CATALOG_URL, - name="default", - priority=1, - install_allowed=True, - description="Built-in catalog of installable integrations", - ), - IntegrationCatalogEntry( - url=self.COMMUNITY_CATALOG_URL, - name="community", - priority=2, - install_allowed=False, - description="Community-contributed integrations (discovery only)", - ), - ] - - # -- Fetching --------------------------------------------------------- - - def _fetch_single_catalog( - self, - entry: IntegrationCatalogEntry, - force_refresh: bool = False, - ) -> Dict[str, Any]: - """Fetch one catalog, with per-URL caching.""" - import urllib.error - - url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] - cache_file = self.cache_dir / f"catalog-{url_hash}.json" - cache_meta = self.cache_dir / f"catalog-{url_hash}-metadata.json" - - if not force_refresh and cache_file.exists() and cache_meta.exists(): - try: - meta = json.loads(cache_meta.read_text(encoding="utf-8")) - cached_at = datetime.fromisoformat(meta.get("cached_at", "")) - if cached_at.tzinfo is None: - cached_at = cached_at.replace(tzinfo=timezone.utc) - age = (datetime.now(timezone.utc) - cached_at).total_seconds() - if age < self.CACHE_DURATION: - cached = json.loads(cache_file.read_text(encoding="utf-8")) - # A poisoned/older-format cache must clear the SAME shape - # contract as a fresh fetch (via the shared validator) — - # otherwise a payload like [], {"integrations": []}, or one - # missing "schema_version" is returned and later crashes on - # .items()/.get() or silently bypasses the format contract. - # The ValueError is caught just below, which drops the - # corrupt cache and refetches from source. - shape_error = _catalog_shape_error(cached) - if shape_error is not None: - raise ValueError(f"cached catalog has invalid shape: {shape_error}") - return cached - except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError): - # Cache is invalid or stale metadata; delete and refetch from source. - try: - cache_file.unlink(missing_ok=True) - cache_meta.unlink(missing_ok=True) - except OSError: - pass # Cache cleanup is best-effort; ignore deletion failures. - - try: - from specify_cli.authentication.http import open_url - - with open_url(entry.url, timeout=10) as resp: - # Validate final URL after redirects - final_url = resp.geturl() - if final_url != entry.url: - self._validate_catalog_url(final_url) - catalog_data = json.loads( - read_response_limited( - resp, - max_bytes=MAX_JSON_METADATA_BYTES, - error_type=IntegrationCatalogError, - label=f"catalog from {entry.url}", - ).decode("utf-8") - ) - - shape_error = _catalog_shape_error(catalog_data) - if shape_error is not None: - raise IntegrationCatalogError( - f"Invalid catalog format from {entry.url}: {shape_error}" - ) - - try: - self.cache_dir.mkdir(parents=True, exist_ok=True) - cache_file.write_text(json.dumps(catalog_data, indent=2), encoding="utf-8") - cache_meta.write_text( - json.dumps( - { - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": entry.url, - }, - indent=2, - ), - encoding="utf-8", - ) - except OSError: - pass # Cache is best-effort; proceed with fetched data - return catalog_data - - except urllib.error.URLError as exc: - raise IntegrationCatalogError( - f"Failed to fetch catalog from {entry.url}: {exc}" - ) - except UnicodeDecodeError as exc: - # A non-UTF-8 response body fails at .decode() before json.loads() - # ever runs, so JSONDecodeError below does not cover it (the two are - # sibling ValueError subclasses, not parent/child). Without this the - # raw UnicodeDecodeError escapes _get_merged_integrations()'s - # "warn and skip this catalog" handler and kills the whole command. - raise IntegrationCatalogError( - f"Catalog from {entry.url} is not valid UTF-8: {exc}" - ) - except json.JSONDecodeError as exc: - raise IntegrationCatalogError( - f"Invalid JSON in catalog from {entry.url}: {exc}" - ) - - def _get_merged_integrations( - self, force_refresh: bool = False - ) -> List[Dict[str, Any]]: - """Fetch and merge integrations from all active catalogs. - - Catalogs are processed in the order returned by - :meth:`get_active_catalogs`. On conflicts, the first catalog in that - order wins (lower numeric priority = higher precedence). Each dict is - annotated with ``_catalog_name`` and ``_install_allowed``. - """ - import sys - - active = self.get_active_catalogs() - merged: Dict[str, Dict[str, Any]] = {} - any_success = False - - for entry in active: - try: - data = self._fetch_single_catalog(entry, force_refresh) - any_success = True - except IntegrationCatalogError as exc: - print( - f"Warning: Could not fetch catalog '{entry.name}': {exc}", - file=sys.stderr, - ) - continue - - for integ_id, integ_data in data.get("integrations", {}).items(): - if not isinstance(integ_data, dict): - continue - if integ_id not in merged: - merged[integ_id] = { - **integ_data, - "id": integ_id, - "_catalog_name": entry.name, - "_install_allowed": entry.install_allowed, - } - - if not any_success and active: - raise IntegrationCatalogError( - "Failed to fetch any integration catalog" - ) - - return list(merged.values()) - - # -- Search / info ---------------------------------------------------- - - def search( - self, - query: Optional[str] = None, - tag: Optional[str] = None, - author: Optional[str] = None, - ) -> List[Dict[str, Any]]: - """Search catalogs for integrations matching the given filters.""" - results: List[Dict[str, Any]] = [] - for item in self._get_merged_integrations(): - author_val = item.get("author", "") - if not isinstance(author_val, str): - author_val = str(author_val) if author_val is not None else "" - if author and author_val.lower() != author.lower(): - continue - if tag: - raw_tags = item.get("tags", []) - tags_list = raw_tags if isinstance(raw_tags, list) else [] - if tag.lower() not in [t.lower() for t in tags_list if isinstance(t, str)]: - continue - if query: - raw_tags = item.get("tags", []) - tags_list = raw_tags if isinstance(raw_tags, list) else [] - name_val = item.get("name", "") - desc_val = item.get("description", "") - id_val = item.get("id", "") - haystack = " ".join( - [ - str(name_val) if name_val else "", - str(desc_val) if desc_val else "", - str(id_val) if id_val else "", - ] - + [t for t in tags_list if isinstance(t, str)] - ).lower() - if query.lower() not in haystack: - continue - results.append(item) - return results - - def get_integration_info( - self, integration_id: str - ) -> Optional[Dict[str, Any]]: - """Return catalog metadata for a single integration, or None.""" - for item in self._get_merged_integrations(): - if item["id"] == integration_id: - return item - return None - - # -- Cache management ------------------------------------------------- - - def clear_cache(self) -> None: - """Remove all cached catalog files.""" - if self.cache_dir.exists(): - for pattern in ("catalog-*.json", "catalog-*-metadata.json"): - for f in self.cache_dir.glob(pattern): - f.unlink(missing_ok=True) - - # -- Catalog-source management ---------------------------------------- - - def get_catalog_configs(self) -> List[Dict[str, Any]]: - """Return the active catalog stack as a list of dicts. - Thin adapter over :meth:`get_active_catalogs` that yields plain dicts - suitable for CLI rendering and JSON-like consumers. - """ - return [ - { - "name": e.name, - "url": e.url, - "priority": e.priority, - "install_allowed": e.install_allowed, - "description": e.description, - } - for e in self.get_active_catalogs() - ] - - def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]: - """Return removable project-level catalog config entries, if configured.""" - config_path = self.project_root / ".specify" / self.CONFIG_FILENAME - entries = self._load_catalog_config(config_path) - if entries is None: - return None - return [ - { - "name": e.name, - "url": e.url, - "priority": e.priority, - "install_allowed": e.install_allowed, - "description": e.description, - } - for e in entries - ] - - def add_catalog(self, url: str, name: Optional[str] = None) -> None: - """Add a catalog source to the project-level config file. - - The URL is normalized (whitespace stripped) and validated before being - written. Duplicate URLs are rejected, including near-duplicates that - differ only by surrounding whitespace. Priority is derived as - ``max(existing) + 1`` so the new entry sorts last in the resolution - order unless the user edits the file manually. - """ - url = url.strip() - if not url: - raise IntegrationValidationError("Catalog URL must be non-empty.") - self._validate_catalog_url(url) - config_path = self.project_root / ".specify" / self.CONFIG_FILENAME - - data: Dict[str, Any] = {"catalogs": []} - if config_path.exists(): - try: - raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeError) as exc: - raise IntegrationValidationError( - f"Failed to read catalog config {config_path}: {exc}" - ) from exc - if raw is None: - raw = {} - if not isinstance(raw, dict): - raise IntegrationValidationError( - f"Catalog config file {config_path} is corrupted " - "(expected a mapping)." - ) - data = raw - - catalogs = data.get("catalogs", []) - if not isinstance(catalogs, list): - raise IntegrationValidationError( - f"Catalog config {config_path} has invalid 'catalogs' value: " - "must be a list." - ) - - # Validate each existing entry before mutating anything. Fail fast so - # we don't silently preserve a corrupt sibling entry or derive a new - # priority from a bogus value. - existing_priorities: List[int] = [] - valid_catalog_count = 0 - for idx, cat in enumerate(catalogs): - if not isinstance(cat, dict): - raise IntegrationValidationError( - f"Invalid catalog entry at index {idx} in {config_path}: " - f"expected a mapping, got {type(cat).__name__}." - ) - existing_url = str(cat.get("url", "")).strip() - if not existing_url: - continue - # Re-run the same URL validation used when loading, so a corrupt - # entry surfaces here instead of at the next `integration` call. - try: - self._validate_catalog_url(existing_url) - except IntegrationCatalogError as exc: - raise IntegrationValidationError( - f"Invalid catalog entry at index {idx} in {config_path}: {exc}" - ) from exc - if existing_url == url: - raise IntegrationValidationError( - f"Catalog URL already configured: {url}" - ) - valid_catalog_count += 1 - if "priority" in cat: - raw_priority = cat.get("priority") - if isinstance(raw_priority, bool): - raise IntegrationValidationError( - f"Invalid catalog entry at index {idx} in {config_path}: " - f"'priority' must be an integer, got " - f"{type(raw_priority).__name__}." - ) - try: - normalized_priority = int(raw_priority) - except (TypeError, ValueError, OverflowError): - # OverflowError: int(float("inf")) — a ``priority: .inf``. - raise IntegrationValidationError( - f"Invalid catalog entry at index {idx} in {config_path}: " - f"'priority' must be an integer, got " - f"{raw_priority!r}." - ) from None - existing_priorities.append(normalized_priority) - else: - # Match `_load_catalog_config()`'s defaulting rule so the new - # entry still sorts after implicit-priority siblings. - existing_priorities.append(idx + 1) - - max_priority = max(existing_priorities, default=0) - normalized_name = str(name).strip() if name is not None else "" - generated_name = f"catalog-{valid_catalog_count + 1}" - catalogs.append( - { - "name": normalized_name or generated_name, - "url": url, - "priority": max_priority + 1, - "install_allowed": True, - "description": "", - } - ) - data["catalogs"] = catalogs - - config_path.parent.mkdir(parents=True, exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - yaml.dump( - data, - f, - default_flow_style=False, - sort_keys=False, - allow_unicode=True, - ) - - def remove_catalog(self, index: int) -> str: - """Remove a catalog source by 0-based index. - - ``index`` is interpreted in the same display order shown by - ``integration catalog list`` (i.e. sorted ascending by priority, - with missing priority defaulting to ``yaml_index + 1``, matching - ``_load_catalog_config()``). This way, the index a user sees in - ``catalog list`` is the index they pass to ``catalog remove``, - even if the underlying YAML lists entries in a different order - from how they sort by priority. - - Returns the removed catalog's name. - """ - config_path = self.project_root / ".specify" / self.CONFIG_FILENAME - if not config_path.exists(): - raise IntegrationValidationError("No catalog config file found.") - - try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError, UnicodeError) as exc: - raise IntegrationValidationError( - f"Failed to read catalog config {config_path}: {exc}" - ) from exc - if data is None: - data = {} - if not isinstance(data, dict): - raise IntegrationValidationError( - f"Catalog config file {config_path} is corrupted " - "(expected a mapping)." - ) - - catalogs = data.get("catalogs", []) - if not isinstance(catalogs, list): - raise IntegrationValidationError( - f"Catalog config {config_path} has invalid 'catalogs' value: " - "must be a list." - ) - - if not catalogs: - # An empty list is the kind of state that only happens if the - # user hand-edited the file; our own `remove_catalog` deletes - # the file when the last entry is popped. Surface a clear - # message instead of `out of range (0--1)`. - raise IntegrationValidationError( - "Catalog config contains no catalog entries." - ) - - # Map displayed index -> raw YAML index using the same priority - # defaulting as ``_load_catalog_config``. We deliberately stay - # tolerant here (no new validation errors) because the goal is - # only to mirror the order shown by ``catalog list``; entries - # that ``_load_catalog_config`` would have rejected outright - # would have failed ``catalog list`` already. - def _is_removable_catalog_entry(item: Any) -> bool: - if not isinstance(item, dict): - return False - raw_url = item.get("url") - if raw_url is None: - return False - return bool(str(raw_url).strip()) - - priority_pairs: List[Tuple[int, int]] = [] - for yaml_idx, item in enumerate(catalogs): - if not _is_removable_catalog_entry(item): - continue - - raw_priority = item.get("priority", yaml_idx + 1) - if isinstance(raw_priority, bool): - priority = yaml_idx + 1 - else: - try: - priority = int(raw_priority) - except (TypeError, ValueError, OverflowError): - # OverflowError: int(float("inf")) — a ``priority: .inf``. - priority = yaml_idx + 1 - priority_pairs.append((priority, yaml_idx)) - if not priority_pairs: - raise IntegrationValidationError( - "Catalog config contains no removable catalog entries." - ) - # Stable sort: ties keep their YAML order, matching list-view ordering. - priority_pairs.sort(key=lambda p: p[0]) - display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs] - - if index < 0 or index >= len(display_order): - raise IntegrationValidationError( - f"Catalog index {index} out of range (0-{len(display_order) - 1})." - ) - - target_yaml_idx = display_order[index] - removed = catalogs.pop(target_yaml_idx) - - if any(_is_removable_catalog_entry(item) for item in catalogs): - data["catalogs"] = catalogs - with open(config_path, "w", encoding="utf-8") as f: - yaml.dump( - data, - f, - default_flow_style=False, - sort_keys=False, - allow_unicode=True, - ) - else: - # Removing the final entry: delete the config file rather than - # leaving behind an empty `catalogs:` list. `_load_catalog_config` - # treats an empty list as an error, so leaving the file would - # break every subsequent `integration` command until the user - # manually deletes `.specify/integration-catalogs.yml`. - # Deleting the file lets the project fall back to built-in - # defaults, which matches the behavior before any - # `catalog add` was ever run. - try: - config_path.unlink(missing_ok=True) - except OSError as exc: - raise IntegrationValidationError( - f"Failed to delete catalog config {config_path}: {exc}" - ) from exc - - fallback_name = f"catalog-{index + 1}" - if isinstance(removed, dict): - removed_name = removed.get("name") - if removed_name is not None: - normalized_name = str(removed_name).strip() - if normalized_name: - return normalized_name - - removed_url = removed.get("url") - if removed_url is not None: - normalized_url = str(removed_url).strip() - if normalized_url: - return normalized_url - return fallback_name - - -# --------------------------------------------------------------------------- -# IntegrationDescriptor (integration.yml) -# --------------------------------------------------------------------------- - -class IntegrationDescriptor: - """Loads and validates an ``integration.yml`` descriptor. - - The descriptor mirrors ``extension.yml`` and ``preset.yml``:: - - schema_version: "1.0" - integration: - id: "my-agent" - name: "My Agent" - version: "1.0.0" - description: "Integration for My Agent" - author: "my-org" - requires: - speckit_version: ">=0.6.0" - tools: [...] - provides: - commands: [...] - scripts: [...] - """ - - SCHEMA_VERSION = "1.0" - REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"] - - def __init__(self, descriptor_path: Path) -> None: - self.path = descriptor_path - self.data = self._load(descriptor_path) - self._validate() - - # -- Loading ---------------------------------------------------------- - - @staticmethod - def _load(path: Path) -> dict: - try: - text = path.read_text(encoding="utf-8") - except FileNotFoundError: - raise IntegrationDescriptorError(f"Descriptor not found: {path}") - except (OSError, UnicodeError) as exc: - raise IntegrationDescriptorError( - f"Unable to read descriptor {path}: {exc}" - ) - try: - # ``safe_load`` returns None for BOTH an empty document and an - # explicit null scalar (``null``, ``~``, ``Null``, ``NULL``), so it - # cannot tell them apart on its own. ``compose`` yields no node - # only for a genuinely empty document. - node = yaml.compose(text) - data = yaml.safe_load(text) - is_empty_document = node is None or ( - data is None - and isinstance(node, yaml.nodes.ScalarNode) - and node.value == "" - and node.start_mark.index == node.end_mark.index - ) - except yaml.YAMLError as exc: - raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") - # Only a genuinely EMPTY document becomes an empty mapping, so its - # missing-field errors are reported. Every non-mapping document -- - # including an explicit ``null``/``~`` and the falsy shapes ``[]``, - # ``false``, ``0``, ``''`` that a plain ``or {}`` would mask -- must - # reach ``_validate`` unchanged so it reports the wrong descriptor - # shape, like the truthy twins (``- a``, ``hello``) already do. - if is_empty_document: - data = {} - return data - - # -- Validation ------------------------------------------------------- - - def _validate(self) -> None: - if not isinstance(self.data, dict): - raise IntegrationDescriptorError( - f"Descriptor root must be a YAML mapping, got {type(self.data).__name__}" - ) - for field in self.REQUIRED_TOP_LEVEL: - if field not in self.data: - raise IntegrationDescriptorError( - f"Missing required field: {field}" - ) - - if self.data["schema_version"] != self.SCHEMA_VERSION: - raise IntegrationDescriptorError( - f"Unsupported schema version: {self.data['schema_version']} " - f"(expected {self.SCHEMA_VERSION})" - ) - - integ = self.data["integration"] - if not isinstance(integ, dict): - raise IntegrationDescriptorError( - "'integration' must be a mapping" - ) - for field in ("id", "name", "version", "description"): - if field not in integ: - raise IntegrationDescriptorError( - f"Missing integration.{field}" - ) - if not isinstance(integ[field], str): - raise IntegrationDescriptorError( - f"integration.{field} must be a string, got {type(integ[field]).__name__}" - ) - - if not re.match(r"^[a-z0-9-]+$", integ["id"]): - raise IntegrationDescriptorError( - f"Invalid integration ID '{integ['id']}': " - "must be lowercase alphanumeric with hyphens only" - ) - - try: - pkg_version.Version(integ["version"]) - except (pkg_version.InvalidVersion, TypeError): - raise IntegrationDescriptorError( - f"Invalid version '{integ['version']}'" - ) - - requires = self.data["requires"] - if not isinstance(requires, dict): - raise IntegrationDescriptorError( - "'requires' must be a mapping" - ) - if "speckit_version" not in requires: - raise IntegrationDescriptorError( - "Missing requires.speckit_version" - ) - if not isinstance(requires["speckit_version"], str) or not requires["speckit_version"].strip(): - raise IntegrationDescriptorError( - "requires.speckit_version must be a non-empty string" - ) - tools = requires.get("tools") - if tools is not None: - if not isinstance(tools, list): - raise IntegrationDescriptorError( - "requires.tools must be a list" - ) - for tool in tools: - if not isinstance(tool, dict): - raise IntegrationDescriptorError( - "Each requires.tools entry must be a mapping" - ) - tool_name = tool.get("name") - if not isinstance(tool_name, str) or not tool_name.strip(): - raise IntegrationDescriptorError( - "requires.tools entry 'name' must be a non-empty string" - ) - - provides = self.data["provides"] - if not isinstance(provides, dict): - raise IntegrationDescriptorError( - "'provides' must be a mapping" - ) - commands = provides.get("commands", []) - scripts = provides.get("scripts", []) - if "commands" in provides and not isinstance(commands, list): - raise IntegrationDescriptorError( - "Invalid provides.commands: expected a list" - ) - if "scripts" in provides and not isinstance(scripts, list): - raise IntegrationDescriptorError( - "Invalid provides.scripts: expected a list" - ) - if not commands and not scripts: - raise IntegrationDescriptorError( - "Integration must provide at least one command or script" - ) - for cmd in commands: - if not isinstance(cmd, dict): - raise IntegrationDescriptorError( - "Each command entry must be a mapping" - ) - if "name" not in cmd or "file" not in cmd: - raise IntegrationDescriptorError( - "Command entry missing 'name' or 'file'" - ) - cmd_name = cmd["name"] - cmd_file = cmd["file"] - if not isinstance(cmd_name, str) or not cmd_name.strip(): - raise IntegrationDescriptorError( - "Command entry 'name' must be a non-empty string" - ) - if not isinstance(cmd_file, str) or not cmd_file.strip(): - raise IntegrationDescriptorError( - "Command entry 'file' must be a non-empty string" - ) - if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor: - raise IntegrationDescriptorError( - f"Command entry 'file' must be a relative path without '..': {cmd_file}" - ) - for script_entry in scripts: - if not isinstance(script_entry, str) or not script_entry.strip(): - raise IntegrationDescriptorError( - "Script entry must be a non-empty string" - ) - if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor: - raise IntegrationDescriptorError( - f"Script entry must be a relative path without '..': {script_entry}" - ) - - # -- Property accessors ----------------------------------------------- - - @property - def id(self) -> str: - return self.data["integration"]["id"] - - @property - def name(self) -> str: - return self.data["integration"]["name"] - - @property - def version(self) -> str: - return self.data["integration"]["version"] - - @property - def description(self) -> str: - return self.data["integration"]["description"] - - @property - def requires_speckit_version(self) -> str: - return self.data["requires"]["speckit_version"] - - @property - def commands(self) -> List[Dict[str, Any]]: - return self.data.get("provides", {}).get("commands", []) - - @property - def scripts(self) -> List[str]: - return self.data.get("provides", {}).get("scripts", []) - - @property - def tools(self) -> List[Dict[str, Any]]: - return self.data.get("requires", {}).get("tools") or [] +from .. import ( + IntegrationCatalog, + IntegrationCatalogEntry, + IntegrationCatalogError, + IntegrationDescriptor, + IntegrationDescriptorError, + IntegrationValidationError, + _catalog_shape_error, +) - def get_hash(self) -> str: - """SHA-256 hash of the descriptor file.""" - h = hashlib.sha256() - with open(self.path, "rb") as fh: - for chunk in iter(lambda: fh.read(8192), b""): - h.update(chunk) - return f"sha256:{h.hexdigest()}" +__all__ = [ + "IntegrationCatalog", + "IntegrationCatalogEntry", + "IntegrationCatalogError", + "IntegrationDescriptor", + "IntegrationDescriptorError", + "IntegrationValidationError", + "_catalog_shape_error", + "catalog_app", + "register", +] catalog_app = typer.Typer( diff --git a/src/specify_cli/integrations/catalog/command_add.py b/src/specify_cli/integrations/catalog/command_add.py index d901307c03..bde5690065 100644 --- a/src/specify_cli/integrations/catalog/command_add.py +++ b/src/specify_cli/integrations/catalog/command_add.py @@ -21,7 +21,7 @@ def integration_catalog_add( name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), ): """Add an integration catalog source to the project config.""" - from . import IntegrationCatalog, IntegrationCatalogError + from .. import IntegrationCatalog, IntegrationCatalogError from ... import _require_specify_project project_root = _require_specify_project() diff --git a/src/specify_cli/integrations/catalog/command_list.py b/src/specify_cli/integrations/catalog/command_list.py index 0a1cd0b5b3..fb79b4f997 100644 --- a/src/specify_cli/integrations/catalog/command_list.py +++ b/src/specify_cli/integrations/catalog/command_list.py @@ -13,7 +13,7 @@ @catalog_app.command("list") def integration_catalog_list(): """List configured integration catalog sources.""" - from . import IntegrationCatalog, IntegrationCatalogError + from .. import IntegrationCatalog, IntegrationCatalogError from ... import _require_specify_project project_root = _require_specify_project() diff --git a/src/specify_cli/integrations/catalog/command_remove.py b/src/specify_cli/integrations/catalog/command_remove.py index 16c9ad8aff..b9a7a37f00 100644 --- a/src/specify_cli/integrations/catalog/command_remove.py +++ b/src/specify_cli/integrations/catalog/command_remove.py @@ -13,7 +13,7 @@ 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 . import IntegrationCatalog, IntegrationCatalogError + from .. import IntegrationCatalog, IntegrationCatalogError from ... import _require_specify_project project_root = _require_specify_project() diff --git a/src/specify_cli/integrations/command_info.py b/src/specify_cli/integrations/command_info.py index 77a2185da7..2606d88bca 100644 --- a/src/specify_cli/integrations/command_info.py +++ b/src/specify_cli/integrations/command_info.py @@ -18,8 +18,8 @@ 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 ( + from . import ( + INTEGRATION_REGISTRY, IntegrationCatalog, IntegrationCatalogError, IntegrationValidationError, diff --git a/src/specify_cli/integrations/command_list.py b/src/specify_cli/integrations/command_list.py index 151ba831a2..7850e6cb1b 100644 --- a/src/specify_cli/integrations/command_list.py +++ b/src/specify_cli/integrations/command_list.py @@ -28,7 +28,7 @@ def integration_list( installed_keys = set(_installed_integration_keys(current)) if catalog: - from .catalog import IntegrationCatalog, IntegrationCatalogError + from . import IntegrationCatalog, IntegrationCatalogError ic = IntegrationCatalog(project_root) try: diff --git a/src/specify_cli/integrations/command_scaffold.py b/src/specify_cli/integrations/command_scaffold.py index fe4877a042..6d11298ac3 100644 --- a/src/specify_cli/integrations/command_scaffold.py +++ b/src/specify_cli/integrations/command_scaffold.py @@ -1,4 +1,5 @@ """The ``specify integration scaffold`` command.""" + from __future__ import annotations from enum import Enum @@ -7,7 +8,7 @@ import typer from .._console import console -from ..integration_scaffold import supported_integration_scaffold_types +from ._command_scaffold_generation import supported_integration_scaffold_types from ._commands import integration_app @@ -30,7 +31,7 @@ def integration_scaffold( ), ): """Create a minimal built-in integration package and test skeleton.""" - from ..integration_scaffold import scaffold_integration + from ._command_scaffold_generation import scaffold_integration # scaffold targets the Spec Kit *source* repo layout (_is_spec_kit_repo_root), # not a .specify/ member project, so SPECIFY_INIT_DIR does not apply here. diff --git a/src/specify_cli/integrations/command_search.py b/src/specify_cli/integrations/command_search.py index 1611cfce70..dac8f31a7f 100644 --- a/src/specify_cli/integrations/command_search.py +++ b/src/specify_cli/integrations/command_search.py @@ -20,8 +20,8 @@ def integration_search( 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 ( + from . import ( + INTEGRATION_REGISTRY, IntegrationCatalog, IntegrationCatalogError, IntegrationValidationError, diff --git a/src/specify_cli/integrations/command_upgrade.py b/src/specify_cli/integrations/command_upgrade.py index 6138f38a8e..7a805041d4 100644 --- a/src/specify_cli/integrations/command_upgrade.py +++ b/src/specify_cli/integrations/command_upgrade.py @@ -15,42 +15,16 @@ from ..integration_state import default_integration_key as _default_integration_key, installed_integration_keys as _installed_integration_keys from ._command_upgrade_layout import ( _PresetRegistryUnreadableError, + _installed_command_presets_affecting_agent, + _installed_presets_affecting_agent, + _legacy_command_root_changed, + _legacy_command_root_upgrade_pending, + _manifest_tracks_skill_layout, ) from ._commands import integration_app from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _refresh_init_options_speckit_version, _register_extensions_for_agent, _register_presets_for_agent, _resolve_integration_options, _resolve_integration_script_type, _unregister_enabled_extension_commands_for_agent, _update_init_options_for_integration, _write_integration_json -def _legacy_layout_helper(name: str): - """Resolve a layout helper through the former module for patch compatibility.""" - from . import _migrate_commands - - return getattr(_migrate_commands, name) - - -def _manifest_tracks_skill_layout(*args, **kwargs): - return _legacy_layout_helper("_manifest_tracks_skill_layout")(*args, **kwargs) - - -def _legacy_command_root_changed(*args, **kwargs): - return _legacy_layout_helper("_legacy_command_root_changed")(*args, **kwargs) - - -def _legacy_command_root_upgrade_pending(*args, **kwargs): - return _legacy_layout_helper("_legacy_command_root_upgrade_pending")( - *args, **kwargs - ) - - -def _installed_presets_affecting_agent(*args, **kwargs): - return _legacy_layout_helper("_installed_presets_affecting_agent")(*args, **kwargs) - - -def _installed_command_presets_affecting_agent(*args, **kwargs): - return _legacy_layout_helper("_installed_command_presets_affecting_agent")( - *args, **kwargs - ) - - @integration_app.command("upgrade") def integration_upgrade( key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 16304c78a4..946acc3c26 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -2996,7 +2996,7 @@ def test_fresh_manifest_upgrade_deletes_dispatcher_when_last(self, tmp_path): # Simulate the upgrade path: a fresh manifest (like # IntegrationManifest(key, project_root, version=...) in - # _migrate_commands) that never recorded the dispatcher. + # integration upgrade path) that never recorded the dispatcher. fresh = IntegrationManifest(claude.key, tmp_path, version="test") assert EVENTS_DISPATCHER_REL not in fresh.files install_integration_events(claude, tmp_path, fresh, {}) diff --git a/tests/specify_cli/integrations/_catalog_helpers.py b/tests/specify_cli/integrations/_catalog_helpers.py index 56441e8c4c..d2fc2ea646 100644 --- a/tests/specify_cli/integrations/_catalog_helpers.py +++ b/tests/specify_cli/integrations/_catalog_helpers.py @@ -62,7 +62,7 @@ def _make_project(self, tmp_path): def _patch_catalog(self, monkeypatch, integrations=None): """Return a stubbed `_get_merged_integrations` that yields *integrations*.""" - from specify_cli.integrations.catalog import IntegrationCatalog + from specify_cli.integrations import IntegrationCatalog data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS) diff --git a/tests/integrations/_integration_scaffold_helpers.py b/tests/specify_cli/integrations/_scaffold_helpers.py similarity index 88% rename from tests/integrations/_integration_scaffold_helpers.py rename to tests/specify_cli/integrations/_scaffold_helpers.py index 6f6401844d..2d4f28f49b 100644 --- a/tests/integrations/_integration_scaffold_helpers.py +++ b/tests/specify_cli/integrations/_scaffold_helpers.py @@ -1,4 +1,4 @@ -"""Shared setup for integration scaffold domain and command tests.""" +"""Shared setup for integration scaffold command tests.""" from pathlib import Path diff --git a/tests/specify_cli/integrations/catalog/test_command_list.py b/tests/specify_cli/integrations/catalog/test_command_list.py index c78db12b5c..68cd432882 100644 --- a/tests/specify_cli/integrations/catalog/test_command_list.py +++ b/tests/specify_cli/integrations/catalog/test_command_list.py @@ -115,7 +115,7 @@ def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch): """User-editable catalog name/url/description must not be parsed as Rich markup.""" from typer.testing import CliRunner from specify_cli import app - from specify_cli.integrations.catalog import IntegrationCatalog + from specify_cli.integrations import IntegrationCatalog runner = CliRunner() project = self._init_project(tmp_path) diff --git a/tests/integrations/test_integration_catalog.py b/tests/specify_cli/integrations/test_catalog.py similarity index 99% rename from tests/integrations/test_integration_catalog.py rename to tests/specify_cli/integrations/test_catalog.py index 316158c791..3d0c4a5e23 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/specify_cli/integrations/test_catalog.py @@ -1,4 +1,4 @@ -"""Tests for the integration catalog domain API.""" +"""Tests for the integration package's catalog domain API.""" import json @@ -7,7 +7,7 @@ from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401 -from specify_cli.integrations.catalog import ( +from specify_cli.integrations import ( IntegrationCatalog, IntegrationCatalogEntry, IntegrationCatalogError, @@ -334,11 +334,11 @@ def test_fetch_rejects_oversized_catalog_response( ): """Regression: _fetch_single_catalog must use read_response_limited with MAX_JSON_METADATA_BYTES, not unbounded resp.read().""" - from specify_cli.integrations.catalog import ( + from specify_cli.integrations import ( IntegrationCatalog, IntegrationCatalogError, ) - import specify_cli.integrations.catalog as catalog_module + import specify_cli.integrations as catalog_module monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) @@ -379,7 +379,7 @@ def fake_urlopen(req, timeout=10): monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen) - from specify_cli.integrations.catalog import IntegrationCatalogEntry + from specify_cli.integrations import IntegrationCatalogEntry entry = IntegrationCatalogEntry( url="https://example.com/catalog.json", diff --git a/tests/specify_cli/integrations/test_command_scaffold.py b/tests/specify_cli/integrations/test_command_scaffold.py index bac9e6c154..4a129856bf 100644 --- a/tests/specify_cli/integrations/test_command_scaffold.py +++ b/tests/specify_cli/integrations/test_command_scaffold.py @@ -1,16 +1,15 @@ """Tests for the ``specify integration scaffold`` command.""" -from pathlib import Path # noqa: F401 - from typer.testing import CliRunner from specify_cli import app from tests.conftest import strip_ansi -from tests.integrations._integration_scaffold_helpers import integration_repo_root as _repo_root +from tests.specify_cli.integrations._scaffold_helpers import integration_repo_root as _repo_root runner = CliRunner() + def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch): root = _repo_root(tmp_path) monkeypatch.chdir(root) @@ -60,7 +59,7 @@ def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkey root = _repo_root(tmp_path) monkeypatch.chdir(root) - import specify_cli.integration_scaffold as scaffold_module + import specify_cli.integrations._command_scaffold_generation as scaffold_module def boom(*args, **kwargs): raise PermissionError("Permission denied: read-only checkout") diff --git a/tests/integrations/test_integration_scaffold.py b/tests/specify_cli/integrations/test_command_scaffold_generation.py similarity index 95% rename from tests/integrations/test_integration_scaffold.py rename to tests/specify_cli/integrations/test_command_scaffold_generation.py index c858b5b6af..1385756f66 100644 --- a/tests/integrations/test_integration_scaffold.py +++ b/tests/specify_cli/integrations/test_command_scaffold_generation.py @@ -1,11 +1,11 @@ -"""Tests for the integration scaffolding domain API.""" +"""Tests for the integration scaffold generation phase.""" from pathlib import Path import pytest -from specify_cli.integration_scaffold import scaffold_integration -from tests.integrations._integration_scaffold_helpers import integration_repo_root as _repo_root +from specify_cli.integrations._command_scaffold_generation import scaffold_integration +from tests.specify_cli.integrations._scaffold_helpers import integration_repo_root as _repo_root @pytest.mark.parametrize( ("integration_type", "base_class", "commands_subdir", "args", "extension"), diff --git a/tests/specify_cli/integrations/test_command_search.py b/tests/specify_cli/integrations/test_command_search.py index 9a0a40244b..d478a95108 100644 --- a/tests/specify_cli/integrations/test_command_search.py +++ b/tests/specify_cli/integrations/test_command_search.py @@ -40,7 +40,7 @@ def test_search_validates_integration_json_before_catalog_lookup( "{bad json\n", encoding="utf-8" ) - from specify_cli.integrations.catalog import IntegrationCatalog + from specify_cli.integrations import IntegrationCatalog def fail_search(self, **kwargs): raise AssertionError("catalog search should not be called") @@ -64,7 +64,7 @@ def test_search_rejects_non_utf8_integration_json_before_catalog_lookup( # ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``. (project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00") - from specify_cli.integrations.catalog import IntegrationCatalog + from specify_cli.integrations import IntegrationCatalog def fail_search(self, **kwargs): raise AssertionError("catalog search should not be called") @@ -179,7 +179,7 @@ def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip( project = self._make_project(tmp_path) monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ") - from specify_cli.integrations.catalog import ( + from specify_cli.integrations import ( IntegrationCatalog, IntegrationCatalogError, ) diff --git a/tests/specify_cli/integrations/test_registration.py b/tests/specify_cli/integrations/test_registration.py index 0b65d8f836..6ef4256f74 100644 --- a/tests/specify_cli/integrations/test_registration.py +++ b/tests/specify_cli/integrations/test_registration.py @@ -54,22 +54,13 @@ def test_catalog_commands_registered_once_in_stable_order(): assert _commands.integration_catalog_app is catalog_app -def test_legacy_grouped_command_imports_resolve_to_extracted_handlers(): - from specify_cli.integrations import ( - _install_commands, - _migrate_commands, - _query_commands, - _scaffold_commands, +def test_catalog_package_preserves_domain_import_compatibility(): + from specify_cli.integrations import IntegrationCatalog + from specify_cli.integrations.catalog import ( + IntegrationCatalog as CompatibilityIntegrationCatalog, ) - from specify_cli.integrations.command_install import integration_install - from specify_cli.integrations.command_list import integration_list - from specify_cli.integrations.command_scaffold import integration_scaffold - from specify_cli.integrations.command_upgrade import integration_upgrade - assert _install_commands.integration_install is integration_install - assert _migrate_commands.integration_upgrade is integration_upgrade - assert _query_commands.integration_list is integration_list - assert _scaffold_commands.integration_scaffold is integration_scaffold + assert CompatibilityIntegrationCatalog is IntegrationCatalog def test_version_lookup_remains_late_bound_through_commands_module(monkeypatch): @@ -80,20 +71,6 @@ def test_version_lookup_remains_late_bound_through_commands_module(monkeypatch): assert _get_speckit_version() == "9.8.7-test" -def test_upgrade_layout_helpers_remain_patchable_through_legacy_module(monkeypatch): - from specify_cli.integrations import _migrate_commands - from specify_cli.integrations import command_upgrade - - sentinel = object() - monkeypatch.setattr( - _migrate_commands, - "_installed_presets_affecting_agent", - lambda *_args, **_kwargs: sentinel, - ) - - assert command_upgrade._installed_presets_affecting_agent(".", "copilot") is sentinel - - class TestParseIntegrationOptionsEqualsForm: def test_equals_form_parsed(self): """--commands-dir=./x should be parsed the same as --commands-dir ./x."""