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/__init__.py b/src/specify_cli/__init__.py index 9840698975..eee6dce00b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -578,8 +578,8 @@ def _require_specify_project() -> Path: # ===== Bundle Commands ===== -# Bundler subcommand group (specify bundle ...) — see commands/bundle/. -from .commands.bundle import register as _register_bundle_cmds # noqa: E402 +# Bundle subcommand group (specify bundle ...) — see bundles/_commands.py. +from .bundles._commands import register as _register_bundle_cmds # noqa: E402 _register_bundle_cmds(app) diff --git a/src/specify_cli/artifacts/_commands.py b/src/specify_cli/artifacts/_commands.py index 2c129df03e..7b49a825e0 100644 --- a/src/specify_cli/artifacts/_commands.py +++ b/src/specify_cli/artifacts/_commands.py @@ -1,16 +1,7 @@ -"""Typer sub-app for the `specify artifact` command group. +"""Shared infrastructure and registration for ``specify artifact`` commands. -Kept intentionally thin: the pure logic lives in ``specify_cli.artifacts``. -This module is only responsible for CLI wiring — argument parsing, JSON -serialization, exit-code selection, and error-envelope emission on stderr. - -Mirrors the shape used by ``src/specify_cli/presets/_commands.py`` and -``src/specify_cli/extensions/_commands.py``: a module-level Typer app plus a -``register(app)`` entry point invoked from ``src/specify_cli/__init__.py``. - -The user-facing contract for both subcommands — the ``list``/``info`` JSON -shapes, stack semantics (``active``/``hidden``, built-in rows, lookup IDs), and -the JSON error envelope — is documented in ``docs/reference/artifacts.md``. +Command handlers live in ``command_*.py`` modules. Domain behavior remains in +Typer-free modules in this package, following ``design/cli.md``. """ from __future__ import annotations @@ -23,12 +14,8 @@ import typer -from ..presets import PresetError from . import ( - ArtifactCatalog, ArtifactError, - ArtifactKind, - ArtifactResolutionError, NotASpecKitProjectError, ) @@ -74,7 +61,7 @@ def _emit_error_and_exit(exc: ArtifactError) -> None: def _require_json_flag(json_flag: bool) -> None: - """Enforce the opt-in ``--json`` contract shared by both subcommands. + """Enforce the opt-in ``--json`` contract shared by artifact commands. A text-mode formatter is intentionally deferred so the initial release can commit to exactly one output shape. Callers that omit ``--json`` @@ -91,111 +78,12 @@ def _require_json_flag(json_flag: bool) -> None: raise typer.Exit(code=2) -@artifact_app.command("list") -def artifact_list( - json_flag: bool = typer.Option( - False, - "--json", - help="Emit the inventory as a JSON array on stdout.", - ), -) -> None: - """List every command, template, script, and hook Spec Kit exposes.""" - _require_json_flag(json_flag) - try: - root = _resolve_project_root() - catalog = ArtifactCatalog(root) - rows = catalog.list_artifacts_with_stack() - except ArtifactError as exc: - _emit_error_and_exit(exc) - return # pragma: no cover — _emit_error_and_exit raises - except (OSError, PresetError): - _emit_error_and_exit(ArtifactResolutionError()) - return # pragma: no cover — _emit_error_and_exit raises - - sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)) - sys.stdout.write("\n") - - -@artifact_app.command("info") -def artifact_info( - name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."), - json_flag: bool = typer.Option( - False, - "--json", - help="Emit the composition stack as a JSON object on stdout.", - ), - kind: str | None = typer.Option( - None, - "--kind", - help="Narrow the lookup to one artifact family (command/template/script/hook).", - ), -) -> None: - """Show one artifact and its full composition stack.""" - _require_json_flag(json_flag) - - resolved_kind: ArtifactKind | None = None - if kind is not None: - if kind not in ("command", "template", "script", "hook"): - print( - f"invalid --kind {kind!r}: expected one of command, template, script, hook", - file=sys.stderr, - ) - raise typer.Exit(code=2) - resolved_kind = kind # type: ignore[assignment] - - try: - root = _resolve_project_root() - catalog = ArtifactCatalog(root) - payload = catalog.get_artifact_info(name, kind=resolved_kind) - except ArtifactError as exc: - _emit_error_and_exit(exc) - return # pragma: no cover - except (OSError, PresetError): - _emit_error_and_exit(ArtifactResolutionError()) - return # pragma: no cover - - sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)) - sys.stdout.write("\n") - - -@artifact_app.command("lookup") -def artifact_lookup( - lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."), - json_flag: bool = typer.Option( - False, - "--json", - help="Emit the validated manifest contribution used by Spec Kit as JSON.", - ), -) -> None: - """Resolve a stack lookupId to its effective preset or extension contribution.""" - _require_json_flag(json_flag) - try: - root = _resolve_project_root() - payload = ArtifactCatalog(root).get_contribution_info(lookup_id) - except ArtifactError as exc: - _emit_error_and_exit(exc) - return # pragma: no cover - except (OSError, PresetError): - _emit_error_and_exit(ArtifactResolutionError()) - return # pragma: no cover - - try: - rendered = json.dumps( - payload, - indent=2, - sort_keys=True, - ensure_ascii=False, - allow_nan=False, - ) - rendered.encode("utf-8") - except (TypeError, ValueError, UnicodeEncodeError): - _emit_error_and_exit(ArtifactResolutionError()) - return # pragma: no cover - - sys.stdout.write(rendered) - sys.stdout.write("\n") - - def register(app: typer.Typer) -> None: """Attach the artifact command group to the root Typer app.""" + # isort: off + from . import command_list # noqa: F401 — registers handler via decorator + from . import command_info # noqa: F401 — registers handler via decorator + from . import command_lookup # noqa: F401 — registers handler via decorator + # isort: on + app.add_typer(artifact_app, name="artifact") diff --git a/src/specify_cli/artifacts/catalog.py b/src/specify_cli/artifacts/catalog.py index d56a3b0a59..2780201cbf 100644 --- a/src/specify_cli/artifacts/catalog.py +++ b/src/specify_cli/artifacts/catalog.py @@ -5,8 +5,8 @@ * :meth:`ArtifactCatalog.list_artifacts` — flat inventory (id, name, kind, description). * :meth:`ArtifactCatalog.get_artifact_info` — one row plus its full ordered stack. -Everything else in this module is internal machinery. Callers outside -:mod:`specify_cli.artifacts._commands` should not import the private helpers. +Everything else in this module is internal machinery. Callers outside the +artifact command modules should not import the private helpers. """ from __future__ import annotations diff --git a/src/specify_cli/artifacts/command_info.py b/src/specify_cli/artifacts/command_info.py new file mode 100644 index 0000000000..da23fde9a5 --- /dev/null +++ b/src/specify_cli/artifacts/command_info.py @@ -0,0 +1,64 @@ +"""Implementation of ``specify artifact info``.""" + +from __future__ import annotations + +import json +import sys + +import typer + +from ..presets import PresetError +from . import ( + ArtifactCatalog, + ArtifactError, + ArtifactKind, + ArtifactResolutionError, +) +from ._commands import ( + _emit_error_and_exit, + _require_json_flag, + _resolve_project_root, + artifact_app, +) + + +@artifact_app.command("info") +def artifact_info( + name: str = typer.Argument(..., help="Artifact name, optionally 'kind:name'."), + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the composition stack as a JSON object on stdout.", + ), + kind: str | None = typer.Option( + None, + "--kind", + help="Narrow the lookup to one artifact family (command/template/script/hook).", + ), +) -> None: + """Show one artifact and its full composition stack.""" + _require_json_flag(json_flag) + + resolved_kind: ArtifactKind | None = None + if kind is not None: + if kind not in ("command", "template", "script", "hook"): + print( + f"invalid --kind {kind!r}: expected one of command, template, script, hook", + file=sys.stderr, + ) + raise typer.Exit(code=2) + resolved_kind = kind # type: ignore[assignment] + + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + payload = catalog.get_artifact_info(name, kind=resolved_kind) + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover + + sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") diff --git a/src/specify_cli/artifacts/command_list.py b/src/specify_cli/artifacts/command_list.py new file mode 100644 index 0000000000..163a59756e --- /dev/null +++ b/src/specify_cli/artifacts/command_list.py @@ -0,0 +1,42 @@ +"""Implementation of ``specify artifact list``.""" + +from __future__ import annotations + +import json +import sys + +import typer + +from ..presets import PresetError +from . import ArtifactCatalog, ArtifactError, ArtifactResolutionError +from ._commands import ( + _emit_error_and_exit, + _require_json_flag, + _resolve_project_root, + artifact_app, +) + + +@artifact_app.command("list") +def artifact_list( + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the inventory as a JSON array on stdout.", + ), +) -> None: + """List every command, template, script, and hook Spec Kit exposes.""" + _require_json_flag(json_flag) + try: + root = _resolve_project_root() + catalog = ArtifactCatalog(root) + rows = catalog.list_artifacts_with_stack() + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover — _emit_error_and_exit raises + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover — _emit_error_and_exit raises + + sys.stdout.write(json.dumps(rows, indent=2, sort_keys=True, ensure_ascii=False)) + sys.stdout.write("\n") diff --git a/src/specify_cli/artifacts/command_lookup.py b/src/specify_cli/artifacts/command_lookup.py new file mode 100644 index 0000000000..e3f34411c2 --- /dev/null +++ b/src/specify_cli/artifacts/command_lookup.py @@ -0,0 +1,55 @@ +"""Implementation of ``specify artifact lookup``.""" + +from __future__ import annotations + +import json +import sys + +import typer + +from ..presets import PresetError +from . import ArtifactCatalog, ArtifactError, ArtifactResolutionError +from ._commands import ( + _emit_error_and_exit, + _require_json_flag, + _resolve_project_root, + artifact_app, +) + + +@artifact_app.command("lookup") +def artifact_lookup( + lookup_id: str = typer.Argument(..., help="Contribution lookupId from an artifact stack."), + json_flag: bool = typer.Option( + False, + "--json", + help="Emit the validated manifest contribution used by Spec Kit as JSON.", + ), +) -> None: + """Resolve a stack lookupId to its effective preset or extension contribution.""" + _require_json_flag(json_flag) + try: + root = _resolve_project_root() + payload = ArtifactCatalog(root).get_contribution_info(lookup_id) + except ArtifactError as exc: + _emit_error_and_exit(exc) + return # pragma: no cover + except (OSError, PresetError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover + + try: + rendered = json.dumps( + payload, + indent=2, + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + ) + rendered.encode("utf-8") + except (TypeError, ValueError, UnicodeEncodeError): + _emit_error_and_exit(ArtifactResolutionError()) + return # pragma: no cover + + sys.stdout.write(rendered) + sys.stdout.write("\n") diff --git a/src/specify_cli/bundler.py b/src/specify_cli/bundler.py new file mode 100644 index 0000000000..cec21f026c --- /dev/null +++ b/src/specify_cli/bundler.py @@ -0,0 +1,5 @@ +"""Compatibility import for the renamed :mod:`specify_cli.bundles` package.""" + +from .bundles import BundlerError + +__all__ = ["BundlerError"] diff --git a/src/specify_cli/bundler/commands_impl/__init__.py b/src/specify_cli/bundler/commands_impl/__init__.py deleted file mode 100644 index ae91e9190a..0000000000 --- a/src/specify_cli/bundler/commands_impl/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Bundler command-implementation helpers (kept thin; logic lives in services).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/lib/__init__.py b/src/specify_cli/bundler/lib/__init__.py deleted file mode 100644 index f0c89c4a0f..0000000000 --- a/src/specify_cli/bundler/lib/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Shared, dependency-light helpers for the bundler (YAML/JSON IO, versioning, project detection).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/models/__init__.py b/src/specify_cli/bundler/models/__init__.py deleted file mode 100644 index 2a5136287b..0000000000 --- a/src/specify_cli/bundler/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Bundler data models (manifest, catalog, records).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/services/__init__.py b/src/specify_cli/bundler/services/__init__.py deleted file mode 100644 index 1db5b56614..0000000000 --- a/src/specify_cli/bundler/services/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Bundler services (catalog stack, resolver, installer, conflict, validator, packager).""" -from __future__ import annotations diff --git a/src/specify_cli/bundler/__init__.py b/src/specify_cli/bundles/__init__.py similarity index 86% rename from src/specify_cli/bundler/__init__.py rename to src/specify_cli/bundles/__init__.py index dac5347b67..f5a0d18b99 100644 --- a/src/specify_cli/bundler/__init__.py +++ b/src/specify_cli/bundles/__init__.py @@ -3,7 +3,8 @@ This package holds the models, services, and helpers behind the ``specify bundle`` subcommand. It is intentionally free of any Typer/CLI imports so the orchestration logic can be unit-tested independently of the command surface (Constitution -Principle I). The CLI wiring lives in ``specify_cli.commands.bundle``. +Principle I). The CLI wiring lives in ``specify_cli.bundles._commands`` and adjacent +``command_*.py`` modules. """ from __future__ import annotations diff --git a/src/specify_cli/bundles/_commands.py b/src/specify_cli/bundles/_commands.py new file mode 100644 index 0000000000..5619ecc04b --- /dev/null +++ b/src/specify_cli/bundles/_commands.py @@ -0,0 +1,163 @@ +"""Shared infrastructure and registration for ``specify bundle`` commands. + +Command handlers live in ``command_*.py`` modules. The nested ``catalog`` +namespace registers through ``bundles.catalog``; domain behavior remains in +Typer-free modules in this package. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import err_console +from . import BundlerError +from .project import active_integration +from .records import load_records + +bundle_app = typer.Typer( + name="bundle", + help="Discover, install, and author Spec Kit bundles", + add_completion=False, +) + + +def _fail(message: str) -> None: + """Print an actionable error to stderr and exit non-zero.""" + # Use the stderr console so the error never lands on stdout, which under + # ``--json`` carries the machine-readable payload and must stay parseable. + # Escape the message: every caller passes ``str(exc)`` from a BundlerError + # that interpolates untrusted data (a CLI argument, a catalog url, a + # bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag + # -- silently swallowing the text, or raising MarkupError on an unbalanced + # closer and replacing the whole message with a traceback. + err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None) + raise typer.Exit(code=1) + + +def _user_config_dir() -> Path: + # User-scope Spec Kit config lives under ~/.specify (same convention as + # auth.json, extension/preset catalogs). Passing this through to the source + # stack is what makes the documented project > user > built-in precedence + # reachable from the CLI. + return Path.home() / ".specify" + + +def _build_stack(project_root: Path, *, offline: bool): + from .adapters import make_catalog_fetcher + from .catalog_stack import CatalogStack + + fetcher = make_catalog_fetcher(allow_network=not offline) + return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir()) + + +def _speckit_version() -> str: + from .._assets import get_speckit_version + + return get_speckit_version() + + +def _trust_level(verified: bool) -> str: + """Trust framing for a catalog entry (FR-010): org-curated vs community.""" + return "verified" if verified else "community" + + +def _trust_badge(verified: bool) -> str: + return "[green]✔ verified[/green]" if verified else "[yellow]community[/yellow]" + + +def _default_script_type() -> str: + """OS-appropriate default script flavor (FR-013).""" + import os + + return "ps" if os.name == "nt" else "sh" + + +def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None: + """Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery. + + Reuses the real ``specify init`` command callback in-process (Principle I) + with ``--here --force`` so it is non-interactive and merges into the current + directory. + """ + from .. import app + + init_cb = next( + c.callback + for c in app.registered_commands + if c.callback and c.callback.__name__ == "init" + ) + try: + init_cb( + project_name=None, + script_type=script_type, + ignore_agent_tools=True, + here=True, + force=True, + skip_tls=False, + debug=False, + github_token=None, + offline=offline, + preset=None, + integration=integration, + integration_options=None, + extensions=None, + trust_extension_urls=False, + ) + except typer.Exit as exc: + if exc.exit_code: + raise BundlerError( + f"Failed to initialize a Spec Kit project (integration '{integration}')." + ) from exc + + +def _resolve_init_integration(override: str | None, manifest) -> str: + """Precedence (FR-013): explicit override → bundle-declared → default.""" + from .._agent_config import resolve_default_init_integration + + if override: + return override + if manifest is not None and manifest.integration is not None: + return manifest.integration.id + return resolve_default_init_integration() + + +def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]: + """Return informational overlaps between *manifest* and installed bundles.""" + if manifest is None: + return [] + try: + from .conflict import detect_conflicts + + report = detect_conflicts( + manifest, + active_integration(project_root), + load_records(project_root), + ) + return list(report.overlaps) + except BundlerError: + return [] + + +def register(app: typer.Typer) -> None: + """Attach the bundle command group to the root Typer app.""" + from .catalog import register as register_catalog + + register_catalog(bundle_app) + + # isort: off + from . import command_search # noqa: F401 — registers handler via decorator + from . import command_info # noqa: F401 — registers handler via decorator + from . import command_list # noqa: F401 — registers handler via decorator + from . import command_install # noqa: F401 — registers handler via decorator + from . import command_add # noqa: F401 — registers handler via decorator + from . import command_update # noqa: F401 — registers handler via decorator + from . import command_remove # noqa: F401 — registers handler via decorator + from . import command_validate # noqa: F401 — registers handler via decorator + from . import command_build # noqa: F401 — registers handler via decorator + from . import command_init # noqa: F401 — registers handler via decorator + # isort: on + + app.add_typer(bundle_app, name="bundle") diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundles/adapters.py similarity index 97% rename from src/specify_cli/bundler/services/adapters.py rename to src/specify_cli/bundles/adapters.py index 7700f5e63e..69b0f903a1 100644 --- a/src/specify_cli/bundler/services/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -19,12 +19,12 @@ from urllib.parse import ParseResult, urlparse from urllib.request import url2pathname -from ..._assets import _locate_core_pack, _repo_root -from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited -from .. import BundlerError -from ..lib.yamlio import load_json, loads_json -from ..models.catalog import CatalogSource -from ..models.manifest import ComponentRef +from .._assets import _locate_core_pack, _repo_root +from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited +from . import BundlerError +from .yamlio import load_json, loads_json +from .catalogs import CatalogSource +from .manifest import ComponentRef COMMUNITY_CATALOG_URL = ( "https://raw.githubusercontent.com/github/spec-kit/main/" @@ -218,7 +218,7 @@ def _http_get_json(source_id: str, url: str) -> dict: HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end rather than only on the initial URL. """ - from ...authentication.http import RedirectPolicyError, open_url + from ..authentication.http import RedirectPolicyError, open_url def _validate_redirect(_old_url: str, new_url: str) -> None: _validate_remote_url(source_id, new_url) diff --git a/src/specify_cli/bundles/catalog/__init__.py b/src/specify_cli/bundles/catalog/__init__.py new file mode 100644 index 0000000000..dc2e1c6651 --- /dev/null +++ b/src/specify_cli/bundles/catalog/__init__.py @@ -0,0 +1,20 @@ +"""Registration for the nested ``specify bundle catalog`` command group.""" + +from __future__ import annotations + +import typer + +catalog_app = typer.Typer( + name="catalog", + help="Manage bundle catalog sources", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the catalog command group to the bundle 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/bundles/catalog/command_add.py b/src/specify_cli/bundles/catalog/command_add.py new file mode 100644 index 0000000000..b734878f1c --- /dev/null +++ b/src/specify_cli/bundles/catalog/command_add.py @@ -0,0 +1,41 @@ +"""Implementation of ``specify bundle catalog add``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from ..._console import console +from .. import BundlerError +from .._commands import _fail +from ..project import require_project_root +from . import catalog_app + + +@catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(..., help="Catalog URL"), + policy: str = typer.Option( + "install-allowed", "--policy", help="install-allowed | discovery-only" + ), + priority: int = typer.Option( + 10, "--priority", help="Source priority (lower = higher)" + ), + source_id: str = typer.Option(None, "--id", help="Explicit source id"), +) -> None: + """Register a project-scoped catalog source and persist it.""" + try: + project_root = require_project_root() + from ..catalog_config import add_source + + source = add_source( + project_root, url, policy=policy, priority=priority, source_id=source_id + ) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' " + f"(priority {source.priority}, {source.install_policy.value})." + ) diff --git a/src/specify_cli/bundles/catalog/command_list.py b/src/specify_cli/bundles/catalog/command_list.py new file mode 100644 index 0000000000..b3bbb42fb9 --- /dev/null +++ b/src/specify_cli/bundles/catalog/command_list.py @@ -0,0 +1,38 @@ +"""Implementation of ``specify bundle catalog list``.""" + +from __future__ import annotations + +from rich.markup import escape as _escape_markup + +from ..._console import console +from .. import BundlerError +from .._commands import _fail, _user_config_dir +from ..project import require_project_root +from . import catalog_app + + +@catalog_app.command("list") +def catalog_list() -> None: + """Print the active, priority-ordered catalog stack with scope and policy.""" + try: + project_root = require_project_root() + from ..catalogs import Scope, load_source_stack + + sources = load_source_stack(project_root, user_config_dir=_user_config_dir()) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + "\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n" + ) + only_builtin = all(s.scope == Scope.BUILTIN for s in sources) + for source in sources: + console.print( + f" [bold]{_escape_markup(str(source.id))}[/bold] " + f"priority={source.priority} " + f"policy={source.install_policy.value} scope={source.scope.value}" + ) + console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]") + if only_builtin: + console.print("\n[dim]Using the built-in default stack.[/dim]") diff --git a/src/specify_cli/bundles/catalog/command_remove.py b/src/specify_cli/bundles/catalog/command_remove.py new file mode 100644 index 0000000000..2f7b07eb4b --- /dev/null +++ b/src/specify_cli/bundles/catalog/command_remove.py @@ -0,0 +1,31 @@ +"""Implementation of ``specify bundle catalog remove``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from ..._console import console +from .. import BundlerError +from .._commands import _fail +from ..project import require_project_root +from . import catalog_app + + +@catalog_app.command("remove") +def catalog_remove( + id_or_url: str = typer.Argument(..., help="Source id or url to remove"), +) -> None: + """Remove a project-scoped catalog source (built-in defaults can't be deleted).""" + try: + project_root = require_project_root() + from ..catalog_config import remove_source + + removed = remove_source(project_root, id_or_url) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Removed catalog source '{_escape_markup(str(removed))}'." + ) diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundles/catalog_config.py similarity index 98% rename from src/specify_cli/bundler/commands_impl/catalog_config.py rename to src/specify_cli/bundles/catalog_config.py index f763a21c65..abd9156b90 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundles/catalog_config.py @@ -10,9 +10,9 @@ from urllib.parse import urlparse import re -from .. import BundlerError -from ..lib.yamlio import dump_yaml, ensure_within, load_yaml -from ..models.catalog import ( +from . import BundlerError +from .yamlio import dump_yaml, ensure_within, load_yaml +from .catalogs import ( CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, BUILTIN_DEFAULT_STACK, diff --git a/src/specify_cli/bundler/services/catalog_stack.py b/src/specify_cli/bundles/catalog_stack.py similarity index 98% rename from src/specify_cli/bundler/services/catalog_stack.py rename to src/specify_cli/bundles/catalog_stack.py index a6c1d23522..6f96fd658b 100644 --- a/src/specify_cli/bundler/services/catalog_stack.py +++ b/src/specify_cli/bundles/catalog_stack.py @@ -10,8 +10,8 @@ from pathlib import Path from typing import Callable -from .. import BundlerError -from ..models.catalog import ( +from . import BundlerError +from .catalogs import ( CatalogEntry, CatalogSource, load_catalog_payload, diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundles/catalogs.py similarity index 99% rename from src/specify_cli/bundler/models/catalog.py rename to src/specify_cli/bundles/catalogs.py index 2ef882d576..839fc42204 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundles/catalogs.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any -from .. import BundlerError -from ..lib.yamlio import ensure_within, load_yaml +from . import BundlerError +from .yamlio import ensure_within, load_yaml CONFIG_FILENAME = "bundle-catalogs.yml" # Supported bundle-catalogs.yml schema (major version). Both readers of the diff --git a/src/specify_cli/bundles/command_add.py b/src/specify_cli/bundles/command_add.py new file mode 100644 index 0000000000..004ef1117f --- /dev/null +++ b/src/specify_cli/bundles/command_add.py @@ -0,0 +1,32 @@ +"""Implementation of ``specify bundle add``.""" + +from __future__ import annotations + +import typer + +from ._commands import bundle_app +from .command_install import bundle_install + + +@bundle_app.command("add") +def bundle_add( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + refresh: bool = typer.Option( + False, + "--refresh", + help="Refresh owned components from this bundle source", + ), +) -> None: + """Install a bundle's full component set (alias for install).""" + bundle_install( + bundle_id=bundle_id, + integration=integration, + offline=offline, + refresh=refresh, + ) diff --git a/src/specify_cli/bundles/command_build.py b/src/specify_cli/bundles/command_build.py new file mode 100644 index 0000000000..2ec488aa81 --- /dev/null +++ b/src/specify_cli/bundles/command_build.py @@ -0,0 +1,38 @@ +"""Implementation of ``specify bundle build``.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app + + +@bundle_app.command("build") +def bundle_build( + path: Path = typer.Option(None, "--path", help="Bundle directory (default: cwd)"), + output: Path = typer.Option( + None, "--output", help="Output directory for the artifact" + ), +) -> None: + """Produce a single versioned distributable artifact (.zip).""" + try: + bundle_dir = (path or Path.cwd()).resolve() + if bundle_dir.is_file(): + bundle_dir = bundle_dir.parent + from .packager import build_bundle + + result = build_bundle(bundle_dir, output_dir=output) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} " + f"({result.file_count} files) → " + f"{_escape_markup(str(result.artifact_path))}" + ) diff --git a/src/specify_cli/bundles/command_info.py b/src/specify_cli/bundles/command_info.py new file mode 100644 index 0000000000..f1c315ac1f --- /dev/null +++ b/src/specify_cli/bundles/command_info.py @@ -0,0 +1,157 @@ +"""Implementation of ``specify bundle info``.""" + +from __future__ import annotations + +import json as _json +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import ( + _build_stack, + _bundle_overlaps, + _fail, + _trust_badge, + _trust_level, + bundle_app, +) +from .project import find_project_root +from .sources import _download_manifest + + +@bundle_app.command("info") +def bundle_info( + bundle_id: str = typer.Argument(..., help="Bundle id to inspect"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """Show full metadata and the fully expanded component set (== what install adds).""" + try: + project_root = find_project_root() or Path.cwd() + stack = _build_stack(project_root, offline=offline) + resolved = stack.resolve(bundle_id) + # `info` must show the fully expanded component set that `install` would + # apply (contracts/cli-commands.md). Expansion happens regardless of + # install policy — discovery-only bundles stay inspectable; only + # `install` is refused. But if the manifest itself can't be resolved + # (e.g. --offline against an https:// download_url, or a download + # failure), fail loudly and exit non-zero rather than silently + # degrading to catalog `provides` counts, so users never mistake an + # unverifiable bundle for a known/installable one. + manifest = _download_manifest(resolved, offline=offline) + except BundlerError as exc: + _fail(str(exc)) + return + + overlaps = _bundle_overlaps(project_root, manifest, offline=offline) + components = _manifest_component_view(manifest) + + entry = resolved.entry + if as_json: + payload = { + "id": entry.id, + "name": entry.name, + "version": entry.version, + "role": entry.role, + "description": entry.description, + "author": entry.author, + "license": entry.license, + "source": resolved.source.id, + "install_policy": resolved.source.install_policy.value, + "provides": entry.provides, + "requires": {"speckit_version": entry.requires_speckit_version}, + "verified": entry.verified, + "trust": _trust_level(entry.verified), + "integration": ( + manifest.integration.id if manifest and manifest.integration else None + ), + "components": components, + "overlaps": overlaps, + } + print(_json.dumps(payload, indent=2)) + return + + console.print( + f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] " + f"v{_escape_markup(str(entry.version))} — " + f"{_escape_markup(str(entry.name))}" + ) + console.print(f" Role: {_escape_markup(str(entry.role))}") + console.print(f" {_escape_markup(str(entry.description))}") + console.print( + f" Author: {_escape_markup(str(entry.author))} " + f"License: {_escape_markup(str(entry.license))}" + ) + console.print( + f" Source: {_escape_markup(str(resolved.source.id))} " + f"({resolved.source.install_policy.value})" + ) + console.print(f" Trust: {_trust_badge(entry.verified)}") + if entry.requires_speckit_version: + console.print( + f" Requires Spec Kit: " + f"{_escape_markup(str(entry.requires_speckit_version))}" + ) + if manifest and manifest.integration: + console.print(f" Integration: {_escape_markup(str(manifest.integration.id))}") + + if components: + console.print("\n [bold]Components[/bold] (added on install):") + for kind in ("extensions", "presets", "steps", "workflows"): + items = [c for c in components if c["kind"] == kind] + if not items: + continue + console.print(f" [bold]{kind}:[/bold]") + for item in items: + console.print(f" - {_escape_markup(_format_component(item))}") + else: + console.print("\n [bold]Provides:[/bold]") + for kind in ("extensions", "presets", "steps", "workflows"): + count = entry.provides.get(kind, 0) + if count: + console.print(f" {kind}: {_escape_markup(str(count))}") + + if overlaps: + console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]") + for overlap in overlaps: + console.print(f" [yellow]-[/yellow] {_escape_markup(str(overlap))}") + + if not resolved.install_allowed: + console.print( + "\n [yellow]This source is discovery-only; the bundle cannot be " + "installed from here.[/yellow]" + ) + + +def _manifest_component_view(manifest) -> list[dict]: + """Flatten a manifest's components to JSON-friendly dicts (id, version, ...).""" + if manifest is None: + return [] + view: list[dict] = [] + for component in manifest.components: + item = { + "kind": component.kind, + "id": component.id, + "version": component.version, + } + if component.priority is not None: + item["priority"] = component.priority + if component.strategy is not None: + item["strategy"] = component.strategy + view.append(item) + return view + + +def _format_component(item: dict) -> str: + label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"] + extras = [] + if item.get("priority") is not None: + extras.append(f"priority={item['priority']}") + if item.get("strategy") is not None: + extras.append(f"strategy={item['strategy']}") + if extras: + label += f" ({', '.join(extras)})" + return label diff --git a/src/specify_cli/bundles/command_init.py b/src/specify_cli/bundles/command_init.py new file mode 100644 index 0000000000..0b2e8133c1 --- /dev/null +++ b/src/specify_cli/bundles/command_init.py @@ -0,0 +1,51 @@ +"""Implementation of ``specify bundle init``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import ( + _default_script_type, + _fail, + _resolve_init_integration, + _run_init, + bundle_app, +) +from .command_install import bundle_install +from .project import require_project_root + + +@bundle_app.command("init") +def bundle_init( + bundle: str = typer.Argument(None, help="Optional bundle to install after init"), + integration: str = typer.Option(None, "--integration", help="Integration override"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Ensure the project is initialized (idempotent), then optionally install a bundle.""" + from .project import find_project_root + + try: + project_root = find_project_root() + if project_root is None: + init_integration = _resolve_init_integration(integration, None) + console.print( + f"[cyan]Initializing a Spec Kit project with integration " + f"'{_escape_markup(str(init_integration))}'…[/cyan]" + ) + _run_init( + init_integration, script_type=_default_script_type(), offline=offline + ) + project_root = require_project_root() + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Spec Kit project ready at " + f"{_escape_markup(str(project_root))}." + ) + if bundle: + bundle_install(bundle, integration=integration, offline=offline) diff --git a/src/specify_cli/bundles/command_install.py b/src/specify_cli/bundles/command_install.py new file mode 100644 index 0000000000..c80a96f18c --- /dev/null +++ b/src/specify_cli/bundles/command_install.py @@ -0,0 +1,137 @@ +"""Implementation of ``specify bundle install``.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import ( + _build_stack, + _bundle_overlaps, + _default_script_type, + _fail, + _resolve_init_integration, + _run_init, + _speckit_version, + bundle_app, +) +from .project import active_integration, require_project_root +from .sources import ( + _download_manifest, + _local_manifest_source, + _validate_manifest_structure, +) + + +@bundle_app.command("install") +def bundle_install( + bundle_id: str = typer.Argument( + ..., + help="Bundle id (from the catalog stack) or a local path to a .zip " + "artifact, bundle directory, or bundle.yml", + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + refresh: bool = typer.Option( + False, + "--refresh", + help="Refresh owned components from this bundle source", + ), +) -> None: + """Install a bundle's full component set through each primitive's machinery. + + ``bundle_id`` may be a catalog bundle id, or a local path to a built + artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local + sources install directly without consulting the catalog stack. Use + ``--refresh`` to update owned components from a newer local source. + """ + try: + from .project import find_project_root + from .adapters import DefaultPrimitiveInstaller + from .installer import install_bundle + from .resolver import resolve_install_plan + + project_root = find_project_root() + + local_manifest = _local_manifest_source(bundle_id) + if local_manifest is not None: + manifest = local_manifest + _validate_manifest_structure( + manifest, + source=f"Local bundle source {bundle_id!r}", + ) + else: + stack = _build_stack(project_root or Path.cwd(), offline=offline) + resolved = stack.resolve(bundle_id) + + if not resolved.install_allowed: + raise BundlerError( + f"Bundle '{bundle_id}' resolves only from a discovery-only source " + f"('{resolved.source.id}'); it cannot be installed from there." + ) + manifest = _download_manifest(resolved, offline=offline) + + if project_root is None: + init_integration = _resolve_init_integration(integration, manifest) + # Resolve all hard compatibility gates before ``specify init``. + # Otherwise an incompatible but structurally valid bundle would + # initialize a project and only then fail its version/integration + # checks, leaving state behind after a failed install. + resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=init_integration, + integration_explicit=True, + ) + console.print( + f"[cyan]No Spec Kit project here; initializing with integration " + f"'{_escape_markup(str(init_integration))}'…[/cyan]" + ) + _run_init( + init_integration, script_type=_default_script_type(), offline=offline + ) + project_root = require_project_root() + + for overlap in _bundle_overlaps(project_root, manifest, offline=offline): + console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}") + + # For an already-initialized project, the project's recorded active + # integration is authoritative — an explicit --integration must not be + # able to bypass the FR-019 integration-clash guard. The override only + # selects the integration at init time (handled above) or confirms the + # target when the active integration cannot be determined. + detected = active_integration(project_root) + plan = resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=detected if detected is not None else integration, + integration_explicit=bool(integration) and detected is None, + ) + for warning in plan.warnings: + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") + + result = install_bundle( + project_root, + plan, + DefaultPrimitiveInstaller(allow_network=not offline), + manifest=manifest, + refresh=refresh, + ) + except BundlerError as exc: + _fail(str(exc)) + return + + refresh_summary = ( + f", {len(result.refreshed)} refreshed, {len(result.uninstalled)} removed" + if refresh + else "" + ) + console.print( + f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' " + f"({len(result.installed)} added, {len(result.skipped)} already present" + f"{refresh_summary})." + ) diff --git a/src/specify_cli/bundles/command_list.py b/src/specify_cli/bundles/command_list.py new file mode 100644 index 0000000000..42acea2c38 --- /dev/null +++ b/src/specify_cli/bundles/command_list.py @@ -0,0 +1,45 @@ +"""Implementation of ``specify bundle list``.""" + +from __future__ import annotations + +import json as _json + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app +from .project import require_project_root +from .records import load_records + + +@bundle_app.command("list") +def bundle_list( + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """List bundles currently installed in the project with versions.""" + try: + project_root = require_project_root() + records = load_records(project_root) + except BundlerError as exc: + _fail(str(exc)) + return + + if as_json: + print(_json.dumps([r.to_dict() for r in records], indent=2)) + return + + if not records: + console.print("[yellow]No bundles installed.[/yellow]") + console.print("\nInstall one with: [cyan]specify bundle install [/cyan]") + return + + console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n") + for record in records: + console.print( + f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] " + f"v{_escape_markup(str(record.version))} " + f"[dim]({len(record.contributed_components)} components, " + f"installed {_escape_markup(str(record.installed_at))})[/dim]" + ) diff --git a/src/specify_cli/bundles/command_remove.py b/src/specify_cli/bundles/command_remove.py new file mode 100644 index 0000000000..69c729ff3f --- /dev/null +++ b/src/specify_cli/bundles/command_remove.py @@ -0,0 +1,32 @@ +"""Implementation of ``specify bundle remove``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app +from .project import require_project_root + + +@bundle_app.command("remove") +def bundle_remove( + bundle_id: str = typer.Argument(..., help="Installed bundle id to remove"), +) -> None: + """Uninstall only the components this bundle contributed (no collateral removals).""" + try: + project_root = require_project_root() + from .adapters import DefaultPrimitiveInstaller + from .installer import remove_bundle + + result = remove_bundle(project_root, bundle_id, DefaultPrimitiveInstaller()) + except BundlerError as exc: + _fail(str(exc)) + return + + console.print( + f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' " + f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)." + ) diff --git a/src/specify_cli/bundles/command_search.py b/src/specify_cli/bundles/command_search.py new file mode 100644 index 0000000000..5c62def010 --- /dev/null +++ b/src/specify_cli/bundles/command_search.py @@ -0,0 +1,65 @@ +"""Implementation of ``specify bundle search``.""" + +from __future__ import annotations + +import json as _json +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import bundle_app, _build_stack, _fail, _trust_badge, _trust_level +from .project import find_project_root + + +@bundle_app.command("search") +def bundle_search( + query: str = typer.Argument("", help="Optional text query"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), + as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), +) -> None: + """List matching bundles across the active catalog stack.""" + try: + project_root = find_project_root() or Path.cwd() + stack = _build_stack(project_root, offline=offline) + results = stack.search(query) + except BundlerError as exc: + _fail(str(exc)) + return + + if as_json: + payload = [ + { + "id": r.entry.id, + "name": r.entry.name, + "role": r.entry.role, + "version": r.entry.version, + "description": r.entry.description, + "source": r.source.id, + "install_policy": r.source.install_policy.value, + "verified": r.entry.verified, + "trust": _trust_level(r.entry.verified), + } + for r in results + ] + print(_json.dumps(payload, indent=2)) + return + + if not results: + console.print("[yellow]No matching bundles found.[/yellow]") + return + + console.print("\n[bold cyan]Bundles:[/bold cyan]\n") + for r in results: + policy = "[dim](discovery-only)[/dim]" if not r.source.install_allowed else "" + console.print( + f" [bold]{_escape_markup(str(r.entry.id))}[/bold] " + f"v{_escape_markup(str(r.entry.version))} — " + f"{_escape_markup(str(r.entry.name))} " + f"[dim]({_escape_markup(str(r.entry.role))})[/dim] " + f"{_trust_badge(r.entry.verified)} {policy}" + ) + console.print(f" {_escape_markup(str(r.entry.description))}") + console.print(f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]") diff --git a/src/specify_cli/bundles/command_update.py b/src/specify_cli/bundles/command_update.py new file mode 100644 index 0000000000..753085ed9c --- /dev/null +++ b/src/specify_cli/bundles/command_update.py @@ -0,0 +1,69 @@ +"""Implementation of ``specify bundle update``.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _build_stack, _fail, _speckit_version, bundle_app +from .project import active_integration, require_project_root +from .records import load_records +from .sources import _download_manifest + + +@bundle_app.command("update") +def bundle_update( + bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), + all_bundles: bool = typer.Option( + False, "--all", help="Update every installed bundle" + ), + integration: str = typer.Option(None, "--integration", help="Override integration"), + offline: bool = typer.Option(False, "--offline", help="Do not access the network"), +) -> None: + """Re-resolve and refresh a bundle's components via each primitive's update path.""" + try: + project_root = require_project_root() + records = load_records(project_root) + if not all_bundles and not bundle_id: + raise BundlerError("Specify a bundle id or use --all.") + targets = [r.bundle_id for r in records] if all_bundles else [bundle_id] + if not targets: + console.print("[yellow]No installed bundles to update.[/yellow]") + return + + stack = _build_stack(project_root, offline=offline) + from .adapters import DefaultPrimitiveInstaller + from .installer import install_bundle + from .resolver import resolve_install_plan + + installer = DefaultPrimitiveInstaller(allow_network=not offline) + for target in targets: + if not any(r.bundle_id == target for r in records): + raise BundlerError(f"Bundle '{target}' is not installed.") + resolved = stack.resolve(target) + if not resolved.install_allowed: + raise BundlerError( + f"Bundle '{target}' resolves only from a discovery-only source " + f"('{resolved.source.id}'); it cannot be updated from there. " + "Update requires an install-allowed source (FR-025)." + ) + manifest = _download_manifest(resolved, offline=offline) + detected = active_integration(project_root) + plan = resolve_install_plan( + manifest, + speckit_version=_speckit_version(), + active_integration=detected if detected is not None else integration, + integration_explicit=bool(integration) and detected is None, + ) + install_bundle( + project_root, plan, installer, manifest=manifest, refresh=True + ) + console.print( + f"[green]✓[/green] Updated '{_escape_markup(str(target))}' " + f"to v{_escape_markup(str(plan.version))}." + ) + except BundlerError as exc: + _fail(str(exc)) + return diff --git a/src/specify_cli/bundles/command_validate.py b/src/specify_cli/bundles/command_validate.py new file mode 100644 index 0000000000..be7aeea3c8 --- /dev/null +++ b/src/specify_cli/bundles/command_validate.py @@ -0,0 +1,65 @@ +"""Implementation of ``specify bundle validate``.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import BundlerError +from ._commands import _fail, bundle_app + + +@bundle_app.command("validate") +def bundle_validate( + path: Path = typer.Option( + None, "--path", help="Bundle directory or bundle.yml (default: cwd)" + ), + offline: bool = typer.Option( + False, + "--offline", + help="Do not access catalogs; verify references against bundled/installed only", + ), +) -> None: + """Report whether the manifest is well-formed and references resolve.""" + try: + manifest_path = _resolve_manifest_path(path) + from .project import find_project_root + from .manifest import BundleManifest + from .references import make_reference_checker + from .validator import validate_manifest + + manifest = BundleManifest.from_file(manifest_path) + ref_root = find_project_root(manifest_path.parent) or Path.cwd() + ref_warnings: list[str] = [] + checker = make_reference_checker( + ref_root, allow_network=not offline, warnings=ref_warnings + ) + report = validate_manifest(manifest, reference_checker=checker) + report.warnings.extend(ref_warnings) + except BundlerError as exc: + _fail(str(exc)) + return + + for warning in report.warnings: + console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") + if not report.ok: + console.print("[red]Manifest is invalid:[/red]") + for error in report.errors: + console.print(f" [red]-[/red] {_escape_markup(str(error))}") + raise typer.Exit(code=1) + console.print( + f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} " + "is well-formed and valid." + ) + + +def _resolve_manifest_path(path: Path | None) -> Path: + target = (path or Path.cwd()).resolve() + if target.is_dir(): + target = target / "bundle.yml" + if not target.exists(): + raise BundlerError(f"No bundle.yml found at '{target}'.") + return target diff --git a/src/specify_cli/bundler/services/conflict.py b/src/specify_cli/bundles/conflict.py similarity index 95% rename from src/specify_cli/bundler/services/conflict.py rename to src/specify_cli/bundles/conflict.py index e7cf356283..78938c5887 100644 --- a/src/specify_cli/bundler/services/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -10,8 +10,8 @@ from dataclasses import dataclass, field -from ..models.manifest import BundleManifest -from ..models.records import InstalledBundleRecord +from .manifest import BundleManifest +from .records import InstalledBundleRecord @dataclass diff --git a/src/specify_cli/bundler/services/installer.py b/src/specify_cli/bundles/installer.py similarity index 98% rename from src/specify_cli/bundler/services/installer.py rename to src/specify_cli/bundles/installer.py index cd877864c5..77bfba7a6e 100644 --- a/src/specify_cli/bundler/services/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -15,9 +15,9 @@ from pathlib import Path from typing import Protocol -from .. import BundlerError -from ..models.manifest import BundleManifest, ComponentRef -from ..models.records import ( +from . import BundlerError +from .manifest import BundleManifest, ComponentRef +from .records import ( InstalledBundleRecord, components_still_needed, find_record, diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundles/manifest.py similarity index 99% rename from src/specify_cli/bundler/models/manifest.py rename to src/specify_cli/bundles/manifest.py index 39684b2327..71304bc682 100644 --- a/src/specify_cli/bundler/models/manifest.py +++ b/src/specify_cli/bundles/manifest.py @@ -11,9 +11,9 @@ from pathlib import Path from typing import Any -from .. import BundlerError -from ..lib.versioning import is_semver -from ..lib.yamlio import load_yaml +from . import BundlerError +from .versioning import is_semver +from .yamlio import load_yaml SUPPORTED_SCHEMA_VERSIONS = {"1.0"} PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundles/packager.py similarity index 98% rename from src/specify_cli/bundler/services/packager.py rename to src/specify_cli/bundles/packager.py index 4e14934e0a..bd80bf7b7c 100644 --- a/src/specify_cli/bundler/services/packager.py +++ b/src/specify_cli/bundles/packager.py @@ -13,9 +13,9 @@ from dataclasses import dataclass from pathlib import Path -from .. import BundlerError -from ..lib.yamlio import ensure_within -from ..models.manifest import BundleManifest +from . import BundlerError +from .yamlio import ensure_within +from .manifest import BundleManifest from .validator import validate_manifest # Files/dirs never included in an artifact. diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundles/primitives.py similarity index 94% rename from src/specify_cli/bundler/services/primitives.py rename to src/specify_cli/bundles/primitives.py index 94bbc0b8ee..b32342e68d 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -24,8 +24,8 @@ from pathlib import Path from typing import Protocol -from .. import BundlerError -from ..models.manifest import ComponentRef +from . import BundlerError +from .manifest import ComponentRef DEFAULT_PRIORITY = 10 @@ -46,7 +46,7 @@ def _assert_pinned_version( actual = str(advertised).strip() if not actual: return - from ..lib.versioning import parse_version + from .versioning import parse_version try: matches = parse_version(actual) == parse_version(pinned) @@ -144,7 +144,7 @@ def _delegate_command(action: str, label: str, call) -> None: class _PresetKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...presets import PresetManager + from ..presets import PresetManager self._root = project_root self._allow_network = allow_network @@ -163,8 +163,8 @@ def refresh(self, component: ComponentRef) -> None: self._do_install(component, force=True) def _do_install(self, component: ComponentRef, *, force: bool) -> None: - from ... import get_speckit_version - from ..._assets import _locate_bundled_preset + from .. import get_speckit_version + from .._assets import _locate_bundled_preset speckit_version = get_speckit_version() priority = DEFAULT_PRIORITY if component.priority is None else component.priority @@ -192,7 +192,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: "network access; re-run without --offline." ) - from ...presets import PresetCatalog + from ..presets import PresetCatalog catalog = PresetCatalog(self._root) info = catalog.get_pack_info(component.id) @@ -231,7 +231,7 @@ def remove(self, component: ComponentRef) -> None: class _ExtensionKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...extensions import ExtensionManager + from ..extensions import ExtensionManager self._root = project_root self._allow_network = allow_network @@ -250,8 +250,8 @@ def refresh(self, component: ComponentRef) -> None: self._do_install(component, force=True) def _do_install(self, component: ComponentRef, *, force: bool) -> None: - from ... import get_speckit_version - from ..._assets import _locate_bundled_extension + from .. import get_speckit_version + from .._assets import _locate_bundled_extension speckit_version = get_speckit_version() priority = DEFAULT_PRIORITY if component.priority is None else component.priority @@ -280,7 +280,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: "network access; re-run without --offline." ) - from ...extensions import ExtensionCatalog + from ..extensions import ExtensionCatalog catalog = ExtensionCatalog(self._root) info = catalog.get_extension_info(component.id) @@ -322,7 +322,7 @@ def remove(self, component: ComponentRef) -> None: class _WorkflowKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...workflows.catalog import WorkflowRegistry + from ..workflows.catalog import WorkflowRegistry self._root = project_root self._allow_network = allow_network @@ -335,13 +335,13 @@ def is_installed(self, component: ComponentRef) -> bool: return False def install(self, component: ComponentRef) -> None: - from ..._assets import _locate_bundled_workflow + from .._assets import _locate_bundled_workflow bundled = _locate_bundled_workflow(component.id) if bundled is not None: workflow_file = bundled / "workflow.yml" try: - from ...workflows.engine import WorkflowDefinition + from ..workflows.engine import WorkflowDefinition definition = WorkflowDefinition.from_yaml(workflow_file) except (OSError, ValueError) as exc: @@ -356,7 +356,7 @@ def install(self, component: ComponentRef) -> None: _assert_pinned_version( "Workflow", component.id, component.version, definition.version ) - from ... import workflow_add + from .. import workflow_add with _chdir(self._root): _delegate_command( @@ -373,7 +373,7 @@ def install(self, component: ComponentRef) -> None: "requires network access; re-run without --offline." ) self._assert_pinned_version(component) - from ... import workflow_add + from .. import workflow_add with _chdir(self._root): _delegate_command( @@ -390,7 +390,7 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: if not component.version: return try: - from ...workflows.catalog import WorkflowCatalog + from ..workflows.catalog import WorkflowCatalog info = WorkflowCatalog(self._root).get_workflow_info(component.id) except Exception: # noqa: BLE001 - catalog unreachable: cannot enforce @@ -401,7 +401,7 @@ def _assert_pinned_version(self, component: ComponentRef) -> None: ) def remove(self, component: ComponentRef) -> None: - from ... import workflow_remove + from .. import workflow_remove with _chdir(self._root): _delegate_command( @@ -412,7 +412,7 @@ def remove(self, component: ComponentRef) -> None: class _StepKindManager: def __init__(self, project_root: Path, allow_network: bool) -> None: - from ...workflows.catalog import StepRegistry + from ..workflows.catalog import StepRegistry self._root = project_root self._allow_network = allow_network @@ -431,7 +431,7 @@ def install(self, component: ComponentRef) -> None: "is disabled. Installing or refreshing this component requires " "network access; re-run without --offline." ) - from ... import workflow_step_add + from .. import workflow_step_add with _chdir(self._root): _delegate_command( @@ -472,7 +472,7 @@ def refresh(self, component: ComponentRef) -> None: # came back but stayed unregistered: ``workflow step list`` # stopped showing it and ``workflow step add`` then refused with # "Step directory already exists". - from ...workflows.catalog import StepRegistry + from ..workflows.catalog import StepRegistry current = StepRegistry(self._root) if metadata is not None and not current.is_installed(component.id): @@ -491,7 +491,7 @@ def refresh(self, component: ComponentRef) -> None: shutil.rmtree(backup_dir.parent, ignore_errors=True) def remove(self, component: ComponentRef) -> None: - from ... import workflow_step_remove + from .. import workflow_step_remove with _chdir(self._root): _delegate_command( diff --git a/src/specify_cli/bundler/lib/project.py b/src/specify_cli/bundles/project.py similarity index 98% rename from src/specify_cli/bundler/lib/project.py rename to src/specify_cli/bundles/project.py index c895bf579d..6476b5c054 100644 --- a/src/specify_cli/bundler/lib/project.py +++ b/src/specify_cli/bundles/project.py @@ -3,8 +3,8 @@ from pathlib import Path -from ..._project import _resolve_init_dir_override -from .. import BundlerError +from .._project import _resolve_init_dir_override +from . import BundlerError from .yamlio import ensure_within, load_json DEFAULT_INTEGRATION = "copilot" diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundles/records.py similarity index 99% rename from src/specify_cli/bundler/models/records.py rename to src/specify_cli/bundles/records.py index 748b23759a..1b0628833e 100644 --- a/src/specify_cli/bundler/models/records.py +++ b/src/specify_cli/bundles/records.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any -from .. import BundlerError -from ..lib.yamlio import dump_json, ensure_within, load_json +from . import BundlerError +from .yamlio import dump_json, ensure_within, load_json from .manifest import COMPONENT_KINDS, ComponentRef, _text RECORDS_FILENAME = "bundle-records.json" diff --git a/src/specify_cli/bundler/services/references.py b/src/specify_cli/bundles/references.py similarity index 86% rename from src/specify_cli/bundler/services/references.py rename to src/specify_cli/bundles/references.py index b5419237d5..822b3d991a 100644 --- a/src/specify_cli/bundler/services/references.py +++ b/src/specify_cli/bundles/references.py @@ -12,36 +12,36 @@ from pathlib import Path -from ..models.manifest import ComponentRef +from .manifest import ComponentRef def _resolved_locally(root: Path, component: ComponentRef) -> bool: kind = component.kind try: if kind == "presets": - from ..._assets import _locate_bundled_preset - from ...presets import PresetManager + from .._assets import _locate_bundled_preset + from ..presets import PresetManager if _locate_bundled_preset(component.id) is not None: return True return PresetManager(root).get_pack(component.id) is not None if kind == "extensions": - from ..._assets import _locate_bundled_extension - from ...extensions import ExtensionManager + from .._assets import _locate_bundled_extension + from ..extensions import ExtensionManager if _locate_bundled_extension(component.id) is not None: return True return ExtensionManager(root).registry.is_installed(component.id) if kind == "workflows": - from ..._assets import _locate_bundled_workflow - from ...workflows.catalog import WorkflowRegistry + from .._assets import _locate_bundled_workflow + from ..workflows.catalog import WorkflowRegistry if _locate_bundled_workflow(component.id) is not None: return True return WorkflowRegistry(root).is_installed(component.id) if kind == "steps": - from ...workflows import BUILTIN_STEP_TYPES - from ...workflows.catalog import StepRegistry + from ..workflows import BUILTIN_STEP_TYPES + from ..workflows.catalog import StepRegistry # Step types ship with Spec Kit as built-ins (shell, gate, if, ...) # rather than as an on-disk asset directory, so there is no @@ -66,19 +66,19 @@ def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | None: kind = component.kind try: if kind == "presets": - from ...presets import PresetCatalog + from ..presets import PresetCatalog return PresetCatalog(root).get_pack_info(component.id) is not None if kind == "extensions": - from ...extensions import ExtensionCatalog + from ..extensions import ExtensionCatalog return ExtensionCatalog(root).get_extension_info(component.id) is not None if kind == "workflows": - from ...workflows.catalog import WorkflowCatalog + from ..workflows.catalog import WorkflowCatalog return WorkflowCatalog(root).get_workflow_info(component.id) is not None if kind == "steps": - from ...workflows.catalog import StepCatalog + from ..workflows.catalog import StepCatalog return StepCatalog(root).get_step_info(component.id) is not None except Exception: # noqa: BLE001 - catalog may be unreachable/misconfigured diff --git a/src/specify_cli/bundler/services/resolver.py b/src/specify_cli/bundles/resolver.py similarity index 97% rename from src/specify_cli/bundler/services/resolver.py rename to src/specify_cli/bundles/resolver.py index 9d9c61e79f..0e8f4ec274 100644 --- a/src/specify_cli/bundler/services/resolver.py +++ b/src/specify_cli/bundles/resolver.py @@ -10,9 +10,9 @@ from dataclasses import dataclass, field from pathlib import Path -from .. import BundlerError -from ..lib.versioning import satisfies -from ..models.manifest import BundleManifest, ComponentRef +from . import BundlerError +from .versioning import satisfies +from .manifest import BundleManifest, ComponentRef @dataclass diff --git a/src/specify_cli/bundles/sources.py b/src/specify_cli/bundles/sources.py new file mode 100644 index 0000000000..3d78d29736 --- /dev/null +++ b/src/specify_cli/bundles/sources.py @@ -0,0 +1,346 @@ +"""Resolve local and remote bundle manifests for bundle consumers.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .._download_security import MAX_DOWNLOAD_BYTES, read_response_limited +from . import BundlerError + +# ZIP magic-byte signatures cover local headers, empty archives, and spanning markers. +_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") + + +def _local_manifest_source(arg: str): + """Return a :class:`BundleManifest` if *arg* points at a local bundle. + + Supports a built ``.zip`` artifact, a bundle directory, or a ``bundle.yml`` + file. Returns ``None`` when *arg* is not an existing path, so callers fall + back to catalog-stack resolution by bundle id. + """ + from .manifest import BundleManifest + + candidate = Path(arg).expanduser() + if not candidate.exists(): + return None + + if candidate.is_dir(): + manifest_path = candidate / "bundle.yml" + if not manifest_path.exists(): + raise BundlerError(f"No bundle.yml found in '{candidate}'.") + return BundleManifest.from_file(manifest_path) + + if candidate.suffix == ".zip": + import yaml as _yaml + + from .._download_security import open_zip_bounded, read_zip_member_limited + + with open_zip_bounded(candidate, error_type=BundlerError) as archive: + try: + archive.getinfo("bundle.yml") + except KeyError as exc: + raise BundlerError( + f"Artifact '{candidate}' does not contain a bundle.yml." + ) from exc + raw = read_zip_member_limited( + archive, + "bundle.yml", + error_type=BundlerError, + label="bundle manifest", + ) + # The bounded-zip helpers above keep archive failures inside the + # BundlerError contract, but the manifest bytes need the same + # treatment as yamlio.load_yaml: decode as UTF-8 explicitly — + # feeding PyYAML the byte stream would let its Reader auto-detect + # a UTF-16 BOM and accept a manifest the directory and bundle.yml + # sources reject. + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Could not read bundle.yml inside '{candidate}': {exc}" + ) from exc + try: + data = _yaml.safe_load(text) + except _yaml.YAMLError as exc: + # The sibling directory/bundle.yml branches reach YAML through + # load_yaml(), which turns a parse failure into a BundlerError. This + # branch parses inline, so without this it raises a raw YAMLError -- + # neither a ValueError nor an OSError -- which escapes + # bundle_install()'s `except BundlerError` as a traceback. + raise BundlerError( + f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" + ) from exc + return BundleManifest.from_dict(data) + + if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): + return BundleManifest.from_file(candidate) + + raise BundlerError( + f"'{candidate}' is not a recognised bundle source (.zip artifact, bundle " + "directory, or bundle.yml)." + ) + + +def _download_manifest(resolved, *, offline: bool): + """Resolve a bundle's manifest from its catalog ``download_url``. + + Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost), + matching the extensions/presets/workflows catalog systems. Remote URLs are + fetched with the shared authenticated, redirect-validated HTTP client, and + only when not ``--offline``. + + Local and ``file://`` sources are intentionally not resolved here: to + install a bundle from disk, pass the path positionally + (``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a + ``.zip`` artifact also works), which :func:`_local_manifest_source` handles + before catalog resolution and which never touches ``download_url``. + """ + from urllib.parse import urlparse + + url = resolved.entry.download_url + if not url: + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve " + "its manifest." + ) + # A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``) + # makes urlparse raise ValueError. Surface it as the documented + # BundlerError, like the sibling ``_validate_remote_url``, rather than + # leaking a raw ValueError past the callers, which only catch BundlerError. + try: + parsed = urlparse(url) + except ValueError: + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has a malformed download_url: {url}" + ) from None + scheme = parsed.scheme.lower() + + # ``file://`` URLs and bare filesystem paths (including Windows drive paths + # like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme) + # are not valid catalog download URLs. Catalog URLs are HTTPS-only across + # every catalog system; installing from disk is done by passing the path + # positionally, which never reaches URL resolution. Give an actionable + # error rather than accepting a scheme the rest of the codebase rejects. + if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url): + raise BundlerError( + f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url " + f"({url}); catalog download URLs must be HTTPS (http for localhost) — " + "a file:// URL, a local filesystem path, or a scheme-less value " + "(e.g. 'example.com/bundle.zip') is not accepted. " + "To install a bundle from disk, pass the path directly: " + "'specify bundle install '." + ) + + # Validate the scheme/host *before* the offline gate so an invalid or + # non-HTTPS download_url reports the real problem in every mode, rather + # than a misleading "Network access disabled" under --offline. + # (_download_remote_manifest re-checks this, but only once network access + # is permitted.) HTTPS-only, http allowed for localhost. + _require_https(f"bundle '{resolved.entry.id}'", url) + + if offline: + raise BundlerError( + f"Network access disabled; cannot download bundle '{resolved.entry.id}' " + f"from {url}." + ) + manifest = _download_remote_manifest( + resolved.entry.id, + url, + expected_sha256=getattr(resolved.entry, "sha256", None), + ) + _validate_catalog_manifest(resolved.entry, manifest) + return manifest + + +def _require_https(label: str, url: str) -> None: + from urllib.parse import urlparse + + # urlparse / hostname access raise ValueError on a malformed authority; + # keep the documented BundlerError contract (older Pythons surface this via + # the .hostname access below rather than at the urlparse call). + try: + parsed = urlparse(url) + hostname = parsed.hostname + # Accessing ``port`` performs urllib's syntax/range validation. + _ = parsed.port + except ValueError: + raise BundlerError( + f"Refusing to download {label}: URL is malformed: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise BundlerError(f"Refusing to download {label} over non-HTTPS URL: {url}") + if not parsed.hostname: + raise BundlerError(f"Refusing to download {label} from URL with no host: {url}") + + +def _download_remote_manifest( + entry_id: str, + url: str, + *, + expected_sha256: str | None = None, +): + """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" + import tempfile + from pathlib import PurePosixPath + from urllib.parse import urlparse as _urlparse + + import yaml as _yaml + + from ..authentication.http import github_provider_hosts, open_url + from .._github_http import resolve_github_release_asset_api_url + from .manifest import BundleManifest + from ..shared_infra import verify_archive_sha256 + + def _validate_redirect(old_url: str, new_url: str) -> None: + _require_https(f"bundle '{entry_id}'", new_url) + + _require_https(f"bundle '{entry_id}'", url) + + # For private/SSO-protected GitHub repos, browser release download URLs + # (https://github.com///releases/download//) + # redirect to an HTML/SSO page instead of delivering the asset. Resolve + # such URLs to the GitHub REST API asset URL so the authenticated client + # can download the actual file. + extra_headers = None + effective_url = url + resolved = resolve_github_release_asset_api_url( + url, open_url, timeout=30, github_hosts=github_provider_hosts() + ) + if resolved: + effective_url = resolved + _require_https(f"bundle '{entry_id}'", effective_url) + extra_headers = {"Accept": "application/octet-stream"} + + # Human-readable description of where the bytes came from, reused across + # all post-download error messages so failures point at the catalog URL + # (and resolved API URL, if any) instead of an opaque temp path. + if effective_url != url: + _source_desc = f"{url} (resolved to {effective_url})" + else: + _source_desc = url + + try: + with open_url( + effective_url, + timeout=30, + redirect_validator=_validate_redirect, + extra_headers=extra_headers, + ) as resp: + _require_https(f"bundle '{entry_id}'", resp.geturl()) + raw = read_response_limited( + resp, + max_bytes=MAX_DOWNLOAD_BYTES, + error_type=BundlerError, + label=f"bundle '{entry_id}' download", + ) + verify_archive_sha256( + raw, + expected_sha256, + entry_id, + BundlerError, + ) + except BundlerError: + raise + except Exception as exc: # noqa: BLE001 + # Report the original catalog URL so users know which entry to fix, + # and include the resolved URL when it differs for easier debugging. + raise BundlerError( + f"Failed to download bundle '{entry_id}' from {_source_desc}: {exc}" + ) from exc + + # A .zip artifact is written to a temp file and parsed via the local-source + # path (which extracts bundle.yml); any other payload is treated as YAML. + # Detection uses the path component of the original catalog URL (via + # PurePosixPath so query strings and fragments are ignored, and URL paths + # are always treated as POSIX regardless of host OS), falling back to the + # module-level _ZIP_SIGNATURES magic-byte check for direct REST API asset + # URLs which carry no file extension. + _url_ext = PurePosixPath(_urlparse(url).path).suffix.lower() + try: + if _url_ext == ".zip" or raw[:4] in _ZIP_SIGNATURES: + with tempfile.TemporaryDirectory() as tmp: + artifact = Path(tmp) / "bundle.zip" + artifact.write_bytes(raw) + # Wrap ZIP parsing so any failure (BadZipFile, missing + # bundle.yml, etc.) references the source URL rather than the + # opaque temporary path, consistent with the download-error + # handling above. + try: + manifest = _local_manifest_source(str(artifact)) + except Exception as exc: # noqa: BLE001 + raise BundlerError( + f"Downloaded artifact for bundle '{entry_id}' from " + f"{_source_desc} is not a valid bundle: {exc}" + ) from exc + # _local_manifest_source returns None only when the file does + # not exist; since we just wrote *artifact* that cannot happen + # here. The explicit guard ensures callers never receive None + # and silently degrade instead of raising a clear error. + if manifest is None: + raise BundlerError( + f"Downloaded artifact for bundle '{entry_id}' from " + f"{_source_desc} is not a valid bundle." + ) + return manifest + + # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract -- + # instead of feeding PyYAML the raw byte stream. PyYAML's Reader + # auto-detects a UTF-16 BOM and would silently *accept* a manifest + # that the local directory/bundle.yml sources reject, letting this + # remote-download path diverge from them (see the sibling .zip fix + # for _local_manifest_source, which had the identical bug). + try: + text = raw.decode("utf-8") + except UnicodeError as exc: + raise BundlerError( + f"Downloaded content for bundle '{entry_id}' from " + f"{_source_desc} could not be read: {exc}" + ) from exc + data = _yaml.safe_load(text) + return BundleManifest.from_dict(data) + except BundlerError: + raise + except _yaml.YAMLError as exc: + raise BundlerError( + f"Downloaded content for bundle '{entry_id}' from {_source_desc} " + f"is not valid YAML: {exc}" + ) from exc + except Exception as exc: # noqa: BLE001 + raise BundlerError( + f"Failed to parse downloaded bundle '{entry_id}' from {_source_desc}: {exc}" + ) from exc + + +def _validate_manifest_structure(manifest, *, source: str) -> None: + """Reject a malformed manifest before any project mutation can occur.""" + from .validator import validate_manifest + + report = validate_manifest(manifest) + if report.ok: + return + raise BundlerError( + f"{source} contains an invalid bundle manifest:\n - " + + "\n - ".join(report.errors) + ) + + +def _validate_catalog_manifest(entry, manifest) -> None: + """Bind a downloaded manifest to the catalog identity that selected it.""" + if manifest.bundle.id != entry.id: + raise BundlerError( + f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to " + f"a manifest for {manifest.bundle.id!r}." + ) + if manifest.bundle.version != entry.version: + raise BundlerError( + f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares " + f"{entry.version!r}, but the manifest declares " + f"{manifest.bundle.version!r}." + ) + _validate_manifest_structure( + manifest, + source=f"Downloaded bundle {entry.id!r}", + ) diff --git a/src/specify_cli/bundler/services/validator.py b/src/specify_cli/bundles/validator.py similarity index 93% rename from src/specify_cli/bundler/services/validator.py rename to src/specify_cli/bundles/validator.py index a1b3ae6c93..0259b1800a 100644 --- a/src/specify_cli/bundler/services/validator.py +++ b/src/specify_cli/bundles/validator.py @@ -10,9 +10,9 @@ from dataclasses import dataclass, field from typing import Callable -from .. import BundlerError -from ..lib.versioning import parse_constraint -from ..models.manifest import BundleManifest, ComponentRef +from . import BundlerError +from .versioning import parse_constraint +from .manifest import BundleManifest, ComponentRef # A reference checker returns None when resolvable, or an error string. ReferenceChecker = Callable[[ComponentRef], str | None] diff --git a/src/specify_cli/bundler/lib/versioning.py b/src/specify_cli/bundles/versioning.py similarity index 99% rename from src/specify_cli/bundler/lib/versioning.py rename to src/specify_cli/bundles/versioning.py index 552f21950c..da980de94a 100644 --- a/src/specify_cli/bundler/lib/versioning.py +++ b/src/specify_cli/bundles/versioning.py @@ -6,7 +6,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version -from .. import BundlerError +from . import BundlerError # Common SemVer prerelease spellings (``1.2.3-rc1``, ``1.2.3-alpha.1``) that # PEP 440 / ``packaging`` rejects verbatim. Normalized to PEP 440 before diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundles/yamlio.py similarity index 99% rename from src/specify_cli/bundler/lib/yamlio.py rename to src/specify_cli/bundles/yamlio.py index a63d05ba4e..40b3be4924 100644 --- a/src/specify_cli/bundler/lib/yamlio.py +++ b/src/specify_cli/bundles/yamlio.py @@ -1,7 +1,7 @@ """YAML/JSON read-write helpers with path confinement (Constitution Principles IV & V). All reads/writes go through these functions so that: -- IO failures degrade into actionable :class:`~specify_cli.bundler.BundlerError`s +- IO failures degrade into actionable :class:`~specify_cli.bundles.BundlerError`s rather than raw tracebacks, and - every path can be confined to an allowed root via :func:`ensure_within`. """ @@ -17,7 +17,7 @@ import yaml -from .. import BundlerError +from . import BundlerError def ensure_within(root: Path, candidate: Path) -> Path: diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py deleted file mode 100644 index fb7a0c73c2..0000000000 --- a/src/specify_cli/commands/bundle/__init__.py +++ /dev/null @@ -1,1135 +0,0 @@ -"""``specify bundle`` command group — discover, install, author Spec Kit bundles. - -This module is the CLI/UX layer only (Principle I: thin commands over services). -Each command resolves a project, builds a catalog stack, delegates to a bundler -service, and renders Rich output. ``--json`` emits machine-readable data on -stdout; human logs go to stderr/console. -""" -from __future__ import annotations - -import json as _json -import re -from pathlib import Path - -import typer -from rich.markup import escape as _escape_markup - -from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited -from ..._console import console, err_console -from ...bundler import BundlerError -from ...bundler.lib.project import ( - active_integration, - find_project_root, - require_project_root, -) -from ...bundler.models.records import load_records - -bundle_app = typer.Typer( - name="bundle", - help="Discover, install, and author Spec Kit bundles", - add_completion=False, -) - -bundle_catalog_app = typer.Typer( - name="catalog", - help="Manage bundle catalog sources", - add_completion=False, -) -bundle_app.add_typer(bundle_catalog_app, name="catalog") - - -# ===== helpers ===== - - -def _fail(message: str) -> None: - """Print an actionable error to stderr and exit non-zero.""" - # Use the stderr console so the error never lands on stdout, which under - # ``--json`` carries the machine-readable payload and must stay parseable. - # Escape the message: every caller passes ``str(exc)`` from a BundlerError - # that interpolates untrusted data (a CLI argument, a catalog url, a - # bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag - # -- silently swallowing the text, or raising MarkupError on an unbalanced - # closer and replacing the whole message with a traceback. - err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None) - raise typer.Exit(code=1) - - -def _user_config_dir() -> Path: - # User-scope Spec Kit config lives under ~/.specify (same convention as - # auth.json, extension/preset catalogs). Passing this through to the source - # stack is what makes the documented project > user > built-in precedence - # reachable from the CLI. - return Path.home() / ".specify" - - -def _build_stack(project_root: Path, *, offline: bool): - from ...bundler.services.adapters import make_catalog_fetcher - from ...bundler.services.catalog_stack import CatalogStack - - fetcher = make_catalog_fetcher(allow_network=not offline) - return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir()) - - -def _speckit_version() -> str: - from ..._assets import get_speckit_version - - return get_speckit_version() - - -def _trust_level(verified: bool) -> str: - """Trust framing for a catalog entry (FR-010): org-curated vs community.""" - return "verified" if verified else "community" - - -def _trust_badge(verified: bool) -> str: - return ( - "[green]✔ verified[/green]" - if verified - else "[yellow]community[/yellow]" - ) - - -def _default_script_type() -> str: - """OS-appropriate default script flavor (FR-013).""" - import os - - return "ps" if os.name == "nt" else "sh" - - -def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None: - """Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery. - - Reuses the real ``specify init`` command callback in-process (Principle I) - with ``--here --force`` so it is non-interactive and merges into the current - directory. - """ - from ... import app - - init_cb = next( - c.callback - for c in app.registered_commands - if c.callback and c.callback.__name__ == "init" - ) - try: - init_cb( - project_name=None, - script_type=script_type, - ignore_agent_tools=True, - here=True, - force=True, - skip_tls=False, - debug=False, - github_token=None, - offline=offline, - preset=None, - integration=integration, - integration_options=None, - extensions=None, - trust_extension_urls=False, - ) - except typer.Exit as exc: - if exc.exit_code: - raise BundlerError( - f"Failed to initialize a Spec Kit project (integration '{integration}')." - ) from exc - - -def _resolve_init_integration(override: str | None, manifest) -> str: - """Precedence (FR-013): explicit override → bundle-declared → default.""" - from ..._agent_config import resolve_default_init_integration - - if override: - return override - if manifest is not None and manifest.integration is not None: - return manifest.integration.id - return resolve_default_init_integration() - - -# ===== Consume ===== - - -@bundle_app.command("search") -def bundle_search( - query: str = typer.Argument("", help="Optional text query"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), -) -> None: - """List matching bundles across the active catalog stack.""" - try: - project_root = find_project_root() or Path.cwd() - stack = _build_stack(project_root, offline=offline) - results = stack.search(query) - except BundlerError as exc: - _fail(str(exc)) - return - - if as_json: - payload = [ - { - "id": r.entry.id, - "name": r.entry.name, - "role": r.entry.role, - "version": r.entry.version, - "description": r.entry.description, - "source": r.source.id, - "install_policy": r.source.install_policy.value, - "verified": r.entry.verified, - "trust": _trust_level(r.entry.verified), - } - for r in results - ] - print(_json.dumps(payload, indent=2)) - return - - if not results: - console.print("[yellow]No matching bundles found.[/yellow]") - return - - console.print("\n[bold cyan]Bundles:[/bold cyan]\n") - for r in results: - policy = ( - "[dim](discovery-only)[/dim]" - if not r.source.install_allowed - else "" - ) - console.print( - f" [bold]{_escape_markup(str(r.entry.id))}[/bold] " - f"v{_escape_markup(str(r.entry.version))} — " - f"{_escape_markup(str(r.entry.name))} " - f"[dim]({_escape_markup(str(r.entry.role))})[/dim] " - f"{_trust_badge(r.entry.verified)} {policy}" - ) - console.print(f" {_escape_markup(str(r.entry.description))}") - console.print( - f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]" - ) - - -@bundle_app.command("info") -def bundle_info( - bundle_id: str = typer.Argument(..., help="Bundle id to inspect"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), -) -> None: - """Show full metadata and the fully expanded component set (== what install adds).""" - try: - project_root = find_project_root() or Path.cwd() - stack = _build_stack(project_root, offline=offline) - resolved = stack.resolve(bundle_id) - # `info` must show the fully expanded component set that `install` would - # apply (contracts/cli-commands.md). Expansion happens regardless of - # install policy — discovery-only bundles stay inspectable; only - # `install` is refused. But if the manifest itself can't be resolved - # (e.g. --offline against an https:// download_url, or a download - # failure), fail loudly and exit non-zero rather than silently - # degrading to catalog `provides` counts, so users never mistake an - # unverifiable bundle for a known/installable one. - manifest = _download_manifest(resolved, offline=offline) - except BundlerError as exc: - _fail(str(exc)) - return - - overlaps = _bundle_overlaps(project_root, manifest, offline=offline) - components = _manifest_component_view(manifest) - - entry = resolved.entry - if as_json: - payload = { - "id": entry.id, - "name": entry.name, - "version": entry.version, - "role": entry.role, - "description": entry.description, - "author": entry.author, - "license": entry.license, - "source": resolved.source.id, - "install_policy": resolved.source.install_policy.value, - "provides": entry.provides, - "requires": {"speckit_version": entry.requires_speckit_version}, - "verified": entry.verified, - "trust": _trust_level(entry.verified), - "integration": (manifest.integration.id if manifest and manifest.integration else None), - "components": components, - "overlaps": overlaps, - } - print(_json.dumps(payload, indent=2)) - return - - console.print( - f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] " - f"v{_escape_markup(str(entry.version))} — " - f"{_escape_markup(str(entry.name))}" - ) - console.print(f" Role: {_escape_markup(str(entry.role))}") - console.print(f" {_escape_markup(str(entry.description))}") - console.print( - f" Author: {_escape_markup(str(entry.author))} " - f"License: {_escape_markup(str(entry.license))}" - ) - console.print( - f" Source: {_escape_markup(str(resolved.source.id))} " - f"({resolved.source.install_policy.value})" - ) - console.print(f" Trust: {_trust_badge(entry.verified)}") - if entry.requires_speckit_version: - console.print( - f" Requires Spec Kit: " - f"{_escape_markup(str(entry.requires_speckit_version))}" - ) - if manifest and manifest.integration: - console.print( - f" Integration: {_escape_markup(str(manifest.integration.id))}" - ) - - if components: - console.print("\n [bold]Components[/bold] (added on install):") - for kind in ("extensions", "presets", "steps", "workflows"): - items = [c for c in components if c["kind"] == kind] - if not items: - continue - console.print(f" [bold]{kind}:[/bold]") - for item in items: - console.print( - f" - {_escape_markup(_format_component(item))}" - ) - else: - console.print("\n [bold]Provides:[/bold]") - for kind in ("extensions", "presets", "steps", "workflows"): - count = entry.provides.get(kind, 0) - if count: - console.print(f" {kind}: {_escape_markup(str(count))}") - - if overlaps: - console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]") - for overlap in overlaps: - console.print( - f" [yellow]-[/yellow] {_escape_markup(str(overlap))}" - ) - - if not resolved.install_allowed: - console.print( - "\n [yellow]This source is discovery-only; the bundle cannot be " - "installed from here.[/yellow]" - ) - - -@bundle_app.command("list") -def bundle_list( - as_json: bool = typer.Option(False, "--json", help="Emit JSON to stdout"), -) -> None: - """List bundles currently installed in the project with versions.""" - try: - project_root = require_project_root() - records = load_records(project_root) - except BundlerError as exc: - _fail(str(exc)) - return - - if as_json: - print(_json.dumps([r.to_dict() for r in records], indent=2)) - return - - if not records: - console.print("[yellow]No bundles installed.[/yellow]") - console.print("\nInstall one with: [cyan]specify bundle install [/cyan]") - return - - console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n") - for record in records: - console.print( - f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] " - f"v{_escape_markup(str(record.version))} " - f"[dim]({len(record.contributed_components)} components, " - f"installed {_escape_markup(str(record.installed_at))})[/dim]" - ) - - -@bundle_app.command("install") -def bundle_install( - bundle_id: str = typer.Argument( - ..., - help="Bundle id (from the catalog stack) or a local path to a .zip " - "artifact, bundle directory, or bundle.yml", - ), - integration: str = typer.Option(None, "--integration", help="Override integration"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - refresh: bool = typer.Option( - False, "--refresh", help="Refresh owned components from this bundle source", - ), -) -> None: - """Install a bundle's full component set through each primitive's machinery. - - ``bundle_id`` may be a catalog bundle id, or a local path to a built - artifact (``.zip``), a bundle directory, or a ``bundle.yml`` file. Local - sources install directly without consulting the catalog stack. Use - ``--refresh`` to update owned components from a newer local source. - """ - try: - from ...bundler.lib.project import find_project_root - from ...bundler.services.adapters import DefaultPrimitiveInstaller - from ...bundler.services.installer import install_bundle - from ...bundler.services.resolver import resolve_install_plan - - project_root = find_project_root() - - local_manifest = _local_manifest_source(bundle_id) - if local_manifest is not None: - manifest = local_manifest - _validate_manifest_structure( - manifest, - source=f"Local bundle source {bundle_id!r}", - ) - else: - stack = _build_stack(project_root or Path.cwd(), offline=offline) - resolved = stack.resolve(bundle_id) - - if not resolved.install_allowed: - raise BundlerError( - f"Bundle '{bundle_id}' resolves only from a discovery-only source " - f"('{resolved.source.id}'); it cannot be installed from there." - ) - manifest = _download_manifest(resolved, offline=offline) - - if project_root is None: - init_integration = _resolve_init_integration(integration, manifest) - # Resolve all hard compatibility gates before ``specify init``. - # Otherwise an incompatible but structurally valid bundle would - # initialize a project and only then fail its version/integration - # checks, leaving state behind after a failed install. - resolve_install_plan( - manifest, - speckit_version=_speckit_version(), - active_integration=init_integration, - integration_explicit=True, - ) - console.print( - f"[cyan]No Spec Kit project here; initializing with integration " - f"'{_escape_markup(str(init_integration))}'…[/cyan]" - ) - _run_init(init_integration, script_type=_default_script_type(), offline=offline) - project_root = require_project_root() - - for overlap in _bundle_overlaps(project_root, manifest, offline=offline): - console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}") - - # For an already-initialized project, the project's recorded active - # integration is authoritative — an explicit --integration must not be - # able to bypass the FR-019 integration-clash guard. The override only - # selects the integration at init time (handled above) or confirms the - # target when the active integration cannot be determined. - detected = active_integration(project_root) - plan = resolve_install_plan( - manifest, - speckit_version=_speckit_version(), - active_integration=detected if detected is not None else integration, - integration_explicit=bool(integration) and detected is None, - ) - for warning in plan.warnings: - console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") - - result = install_bundle( - project_root, - plan, - DefaultPrimitiveInstaller(allow_network=not offline), - manifest=manifest, - refresh=refresh, - ) - except BundlerError as exc: - _fail(str(exc)) - return - - refresh_summary = ( - f", {len(result.refreshed)} refreshed, {len(result.uninstalled)} removed" - if refresh else "" - ) - console.print( - f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' " - f"({len(result.installed)} added, {len(result.skipped)} already present" - f"{refresh_summary})." - ) - - -@bundle_app.command("add") -def bundle_add( - bundle_id: str = typer.Argument( - ..., - help="Bundle id (from the catalog stack) or a local path to a .zip " - "artifact, bundle directory, or bundle.yml", - ), - integration: str = typer.Option(None, "--integration", help="Override integration"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), - refresh: bool = typer.Option( - False, "--refresh", help="Refresh owned components from this bundle source", - ), -) -> None: - """Install a bundle's full component set (alias for install).""" - return bundle_install( - bundle_id=bundle_id, - integration=integration, - offline=offline, - refresh=refresh, - ) - - -@bundle_app.command("update") -def bundle_update( - bundle_id: str = typer.Argument(None, help="Bundle id, or omit with --all"), - all_bundles: bool = typer.Option(False, "--all", help="Update every installed bundle"), - integration: str = typer.Option(None, "--integration", help="Override integration"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), -) -> None: - """Re-resolve and refresh a bundle's components via each primitive's update path.""" - try: - project_root = require_project_root() - records = load_records(project_root) - if not all_bundles and not bundle_id: - raise BundlerError("Specify a bundle id or use --all.") - targets = ( - [r.bundle_id for r in records] - if all_bundles - else [bundle_id] - ) - if not targets: - console.print("[yellow]No installed bundles to update.[/yellow]") - return - - stack = _build_stack(project_root, offline=offline) - from ...bundler.services.adapters import DefaultPrimitiveInstaller - from ...bundler.services.installer import install_bundle - from ...bundler.services.resolver import resolve_install_plan - - installer = DefaultPrimitiveInstaller(allow_network=not offline) - for target in targets: - if not any(r.bundle_id == target for r in records): - raise BundlerError(f"Bundle '{target}' is not installed.") - resolved = stack.resolve(target) - if not resolved.install_allowed: - raise BundlerError( - f"Bundle '{target}' resolves only from a discovery-only source " - f"('{resolved.source.id}'); it cannot be updated from there. " - "Update requires an install-allowed source (FR-025)." - ) - manifest = _download_manifest(resolved, offline=offline) - detected = active_integration(project_root) - plan = resolve_install_plan( - manifest, - speckit_version=_speckit_version(), - active_integration=detected if detected is not None else integration, - integration_explicit=bool(integration) and detected is None, - ) - install_bundle(project_root, plan, installer, manifest=manifest, refresh=True) - console.print( - f"[green]✓[/green] Updated '{_escape_markup(str(target))}' " - f"to v{_escape_markup(str(plan.version))}." - ) - except BundlerError as exc: - _fail(str(exc)) - return - - -@bundle_app.command("remove") -def bundle_remove( - bundle_id: str = typer.Argument(..., help="Installed bundle id to remove"), -) -> None: - """Uninstall only the components this bundle contributed (no collateral removals).""" - try: - project_root = require_project_root() - from ...bundler.services.adapters import DefaultPrimitiveInstaller - from ...bundler.services.installer import remove_bundle - - result = remove_bundle(project_root, bundle_id, DefaultPrimitiveInstaller()) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' " - f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)." - ) - - -# ===== Author ===== - - -@bundle_app.command("validate") -def bundle_validate( - path: Path = typer.Option( - None, "--path", help="Bundle directory or bundle.yml (default: cwd)" - ), - offline: bool = typer.Option( - False, - "--offline", - help="Do not access catalogs; verify references against bundled/installed only", - ), -) -> None: - """Report whether the manifest is well-formed and references resolve.""" - try: - manifest_path = _resolve_manifest_path(path) - from ...bundler.lib.project import find_project_root - from ...bundler.models.manifest import BundleManifest - from ...bundler.services.references import make_reference_checker - from ...bundler.services.validator import validate_manifest - - manifest = BundleManifest.from_file(manifest_path) - ref_root = find_project_root(manifest_path.parent) or Path.cwd() - ref_warnings: list[str] = [] - checker = make_reference_checker( - ref_root, allow_network=not offline, warnings=ref_warnings - ) - report = validate_manifest(manifest, reference_checker=checker) - report.warnings.extend(ref_warnings) - except BundlerError as exc: - _fail(str(exc)) - return - - for warning in report.warnings: - console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}") - if not report.ok: - console.print("[red]Manifest is invalid:[/red]") - for error in report.errors: - console.print(f" [red]-[/red] {_escape_markup(str(error))}") - raise typer.Exit(code=1) - console.print( - f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} " - "is well-formed and valid." - ) - - -@bundle_app.command("build") -def bundle_build( - path: Path = typer.Option( - None, "--path", help="Bundle directory (default: cwd)" - ), - output: Path = typer.Option(None, "--output", help="Output directory for the artifact"), -) -> None: - """Produce a single versioned distributable artifact (.zip).""" - try: - bundle_dir = (path or Path.cwd()).resolve() - if bundle_dir.is_file(): - bundle_dir = bundle_dir.parent - from ...bundler.services.packager import build_bundle - - result = build_bundle(bundle_dir, output_dir=output) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} " - f"({result.file_count} files) → " - f"{_escape_markup(str(result.artifact_path))}" - ) - - -@bundle_app.command("init") -def bundle_init( - bundle: str = typer.Argument(None, help="Optional bundle to install after init"), - integration: str = typer.Option(None, "--integration", help="Integration override"), - offline: bool = typer.Option(False, "--offline", help="Do not access the network"), -) -> None: - """Ensure the project is initialized (idempotent), then optionally install a bundle.""" - from ...bundler.lib.project import find_project_root - - try: - project_root = find_project_root() - if project_root is None: - init_integration = _resolve_init_integration(integration, None) - console.print( - f"[cyan]Initializing a Spec Kit project with integration " - f"'{_escape_markup(str(init_integration))}'…[/cyan]" - ) - _run_init(init_integration, script_type=_default_script_type(), offline=offline) - project_root = require_project_root() - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Spec Kit project ready at " - f"{_escape_markup(str(project_root))}." - ) - if bundle: - bundle_install(bundle, integration=integration, offline=offline) - - -# ===== Catalog management ===== - - -@bundle_catalog_app.command("list") -def catalog_list() -> None: - """Print the active, priority-ordered catalog stack with scope and policy.""" - try: - project_root = require_project_root() - from ...bundler.models.catalog import Scope, load_source_stack - - sources = load_source_stack(project_root, user_config_dir=_user_config_dir()) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print("\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n") - only_builtin = all(s.scope == Scope.BUILTIN for s in sources) - for source in sources: - console.print( - f" [bold]{_escape_markup(str(source.id))}[/bold] " - f"priority={source.priority} " - f"policy={source.install_policy.value} scope={source.scope.value}" - ) - console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]") - if only_builtin: - console.print("\n[dim]Using the built-in default stack.[/dim]") - - -@bundle_catalog_app.command("add") -def catalog_add( - url: str = typer.Argument(..., help="Catalog URL"), - policy: str = typer.Option( - "install-allowed", "--policy", help="install-allowed | discovery-only" - ), - priority: int = typer.Option(10, "--priority", help="Source priority (lower = higher)"), - source_id: str = typer.Option(None, "--id", help="Explicit source id"), -) -> None: - """Register a project-scoped catalog source and persist it.""" - try: - project_root = require_project_root() - from ...bundler.commands_impl.catalog_config import add_source - - source = add_source(project_root, url, policy=policy, priority=priority, source_id=source_id) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' " - f"(priority {source.priority}, {source.install_policy.value})." - ) - - -@bundle_catalog_app.command("remove") -def catalog_remove( - id_or_url: str = typer.Argument(..., help="Source id or url to remove"), -) -> None: - """Remove a project-scoped catalog source (built-in defaults can't be deleted).""" - try: - project_root = require_project_root() - from ...bundler.commands_impl.catalog_config import remove_source - - removed = remove_source(project_root, id_or_url) - except BundlerError as exc: - _fail(str(exc)) - return - - console.print( - f"[green]✓[/green] Removed catalog source " - f"'{_escape_markup(str(removed))}'." - ) - - -# ZIP magic-byte signatures used to detect .zip payloads from REST API asset -# URLs, which carry no file extension. The three signatures cover all valid -# ZIP variants (PK\x03\x04 = local file header, PK\x05\x06 = empty archive, -# PK\x07\x08 = spanning marker) without the false-positive risk of checking -# only the 2-byte "PK" prefix. -_ZIP_SIGNATURES = (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08") - - -# ===== internal helpers ===== - - -def _manifest_component_view(manifest) -> list[dict]: - """Flatten a manifest's components to JSON-friendly dicts (id, version, ...).""" - if manifest is None: - return [] - view: list[dict] = [] - for component in manifest.components: - item = { - "kind": component.kind, - "id": component.id, - "version": component.version, - } - if component.priority is not None: - item["priority"] = component.priority - if component.strategy is not None: - item["strategy"] = component.strategy - view.append(item) - return view - - -def _format_component(item: dict) -> str: - label = f"{item['id']} v{item['version']}" if item.get("version") else item["id"] - extras = [] - if item.get("priority") is not None: - extras.append(f"priority={item['priority']}") - if item.get("strategy") is not None: - extras.append(f"strategy={item['strategy']}") - if extras: - label += f" ({', '.join(extras)})" - return label - - -def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]: - """Return informational overlaps between *manifest* and installed bundles.""" - if manifest is None: - return [] - try: - from ...bundler.services.conflict import detect_conflicts - - report = detect_conflicts( - manifest, - active_integration(project_root), - load_records(project_root), - ) - return list(report.overlaps) - except BundlerError: - return [] - - -def _local_manifest_source(arg: str): - """Return a :class:`BundleManifest` if *arg* points at a local bundle. - - Supports a built ``.zip`` artifact, a bundle directory, or a ``bundle.yml`` - file. Returns ``None`` when *arg* is not an existing path, so callers fall - back to catalog-stack resolution by bundle id. - """ - from ...bundler.models.manifest import BundleManifest - - candidate = Path(arg).expanduser() - if not candidate.exists(): - return None - - if candidate.is_dir(): - manifest_path = candidate / "bundle.yml" - if not manifest_path.exists(): - raise BundlerError(f"No bundle.yml found in '{candidate}'.") - return BundleManifest.from_file(manifest_path) - - if candidate.suffix == ".zip": - import yaml as _yaml - - from ..._download_security import open_zip_bounded, read_zip_member_limited - - with open_zip_bounded(candidate, error_type=BundlerError) as archive: - try: - archive.getinfo("bundle.yml") - except KeyError as exc: - raise BundlerError( - f"Artifact '{candidate}' does not contain a bundle.yml." - ) from exc - raw = read_zip_member_limited( - archive, - "bundle.yml", - error_type=BundlerError, - label="bundle manifest", - ) - # The bounded-zip helpers above keep archive failures inside the - # BundlerError contract, but the manifest bytes need the same - # treatment as yamlio.load_yaml: decode as UTF-8 explicitly — - # feeding PyYAML the byte stream would let its Reader auto-detect - # a UTF-16 BOM and accept a manifest the directory and bundle.yml - # sources reject. - try: - text = raw.decode("utf-8") - except UnicodeError as exc: - raise BundlerError( - f"Could not read bundle.yml inside '{candidate}': {exc}" - ) from exc - try: - data = _yaml.safe_load(text) - except _yaml.YAMLError as exc: - # The sibling directory/bundle.yml branches reach YAML through - # load_yaml(), which turns a parse failure into a BundlerError. This - # branch parses inline, so without this it raises a raw YAMLError -- - # neither a ValueError nor an OSError -- which escapes - # bundle_install()'s `except BundlerError` as a traceback. - raise BundlerError( - f"Invalid YAML in bundle.yml inside '{candidate}': {exc}" - ) from exc - return BundleManifest.from_dict(data) - - if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"): - return BundleManifest.from_file(candidate) - - raise BundlerError( - f"'{candidate}' is not a recognised bundle source (.zip artifact, bundle " - "directory, or bundle.yml)." - ) - - -def _resolve_manifest_path(path: Path | None) -> Path: - target = (path or Path.cwd()).resolve() - if target.is_dir(): - target = target / "bundle.yml" - if not target.exists(): - raise BundlerError(f"No bundle.yml found at '{target}'.") - return target - - -def _download_manifest(resolved, *, offline: bool): - """Resolve a bundle's manifest from its catalog ``download_url``. - - Catalog ``download_url``s are HTTPS-only (``http`` allowed for localhost), - matching the extensions/presets/workflows catalog systems. Remote URLs are - fetched with the shared authenticated, redirect-validated HTTP client, and - only when not ``--offline``. - - Local and ``file://`` sources are intentionally not resolved here: to - install a bundle from disk, pass the path positionally - (``specify bundle install ./path/to/bundle.yml`` — a bundle directory or a - ``.zip`` artifact also works), which :func:`_local_manifest_source` handles - before catalog resolution and which never touches ``download_url``. - """ - from urllib.parse import urlparse - - url = resolved.entry.download_url - if not url: - raise BundlerError( - f"Catalog entry '{resolved.entry.id}' has no download_url; cannot resolve " - "its manifest." - ) - # A malformed authority (e.g. an unclosed IPv6 bracket ``https://[::1``) - # makes urlparse raise ValueError. Surface it as the documented - # BundlerError, like the sibling ``_validate_remote_url``, rather than - # leaking a raw ValueError past the callers, which only catch BundlerError. - try: - parsed = urlparse(url) - except ValueError: - raise BundlerError( - f"Catalog entry '{resolved.entry.id}' has a malformed download_url: {url}" - ) from None - scheme = parsed.scheme.lower() - - # ``file://`` URLs and bare filesystem paths (including Windows drive paths - # like ``C:\bundle.yml``, which urlparse reads as a single-letter scheme) - # are not valid catalog download URLs. Catalog URLs are HTTPS-only across - # every catalog system; installing from disk is done by passing the path - # positionally, which never reaches URL resolution. Give an actionable - # error rather than accepting a scheme the rest of the codebase rejects. - if scheme in ("", "file") or re.match(r"^[A-Za-z]:[\\/]", url): - raise BundlerError( - f"Catalog entry '{resolved.entry.id}' has a non-HTTP(S) download_url " - f"({url}); catalog download URLs must be HTTPS (http for localhost) — " - "a file:// URL, a local filesystem path, or a scheme-less value " - "(e.g. 'example.com/bundle.zip') is not accepted. " - "To install a bundle from disk, pass the path directly: " - "'specify bundle install '." - ) - - # Validate the scheme/host *before* the offline gate so an invalid or - # non-HTTPS download_url reports the real problem in every mode, rather - # than a misleading "Network access disabled" under --offline. - # (_download_remote_manifest re-checks this, but only once network access - # is permitted.) HTTPS-only, http allowed for localhost. - _require_https(f"bundle '{resolved.entry.id}'", url) - - if offline: - raise BundlerError( - f"Network access disabled; cannot download bundle '{resolved.entry.id}' " - f"from {url}." - ) - manifest = _download_remote_manifest( - resolved.entry.id, - url, - expected_sha256=getattr(resolved.entry, "sha256", None), - ) - _validate_catalog_manifest(resolved.entry, manifest) - return manifest - - -def _require_https(label: str, url: str) -> None: - from urllib.parse import urlparse - - # urlparse / hostname access raise ValueError on a malformed authority; - # keep the documented BundlerError contract (older Pythons surface this via - # the .hostname access below rather than at the urlparse call). - try: - parsed = urlparse(url) - hostname = parsed.hostname - # Accessing ``port`` performs urllib's syntax/range validation. - _ = parsed.port - except ValueError: - raise BundlerError( - f"Refusing to download {label}: URL is malformed: {url}" - ) from None - is_localhost = hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): - raise BundlerError( - f"Refusing to download {label} over non-HTTPS URL: {url}" - ) - if not parsed.hostname: - raise BundlerError(f"Refusing to download {label} from URL with no host: {url}") - - -def _download_remote_manifest( - entry_id: str, - url: str, - *, - expected_sha256: str | None = None, -): - """Fetch a remote bundle artifact over HTTPS and extract its manifest.""" - import tempfile - from pathlib import PurePosixPath - from urllib.parse import urlparse as _urlparse - - import yaml as _yaml - - from ...authentication.http import github_provider_hosts, open_url - from ..._github_http import resolve_github_release_asset_api_url - from ...bundler.models.manifest import BundleManifest - from ...shared_infra import verify_archive_sha256 - - def _validate_redirect(old_url: str, new_url: str) -> None: - _require_https(f"bundle '{entry_id}'", new_url) - - _require_https(f"bundle '{entry_id}'", url) - - # For private/SSO-protected GitHub repos, browser release download URLs - # (https://github.com///releases/download//) - # redirect to an HTML/SSO page instead of delivering the asset. Resolve - # such URLs to the GitHub REST API asset URL so the authenticated client - # can download the actual file. - extra_headers = None - effective_url = url - resolved = resolve_github_release_asset_api_url( - url, open_url, timeout=30, github_hosts=github_provider_hosts() - ) - if resolved: - effective_url = resolved - _require_https(f"bundle '{entry_id}'", effective_url) - extra_headers = {"Accept": "application/octet-stream"} - - # Human-readable description of where the bytes came from, reused across - # all post-download error messages so failures point at the catalog URL - # (and resolved API URL, if any) instead of an opaque temp path. - if effective_url != url: - _source_desc = f"{url} (resolved to {effective_url})" - else: - _source_desc = url - - try: - with open_url( - effective_url, - timeout=30, - redirect_validator=_validate_redirect, - extra_headers=extra_headers, - ) as resp: - _require_https(f"bundle '{entry_id}'", resp.geturl()) - raw = read_response_limited( - resp, - max_bytes=MAX_DOWNLOAD_BYTES, - error_type=BundlerError, - label=f"bundle '{entry_id}' download", - ) - verify_archive_sha256( - raw, - expected_sha256, - entry_id, - BundlerError, - ) - except BundlerError: - raise - except Exception as exc: # noqa: BLE001 - # Report the original catalog URL so users know which entry to fix, - # and include the resolved URL when it differs for easier debugging. - raise BundlerError( - f"Failed to download bundle '{entry_id}' from {_source_desc}: {exc}" - ) from exc - - # A .zip artifact is written to a temp file and parsed via the local-source - # path (which extracts bundle.yml); any other payload is treated as YAML. - # Detection uses the path component of the original catalog URL (via - # PurePosixPath so query strings and fragments are ignored, and URL paths - # are always treated as POSIX regardless of host OS), falling back to the - # module-level _ZIP_SIGNATURES magic-byte check for direct REST API asset - # URLs which carry no file extension. - _url_ext = PurePosixPath(_urlparse(url).path).suffix.lower() - try: - if _url_ext == ".zip" or raw[:4] in _ZIP_SIGNATURES: - with tempfile.TemporaryDirectory() as tmp: - artifact = Path(tmp) / "bundle.zip" - artifact.write_bytes(raw) - # Wrap ZIP parsing so any failure (BadZipFile, missing - # bundle.yml, etc.) references the source URL rather than the - # opaque temporary path, consistent with the download-error - # handling above. - try: - manifest = _local_manifest_source(str(artifact)) - except Exception as exc: # noqa: BLE001 - raise BundlerError( - f"Downloaded artifact for bundle '{entry_id}' from " - f"{_source_desc} is not a valid bundle: {exc}" - ) from exc - # _local_manifest_source returns None only when the file does - # not exist; since we just wrote *artifact* that cannot happen - # here. The explicit guard ensures callers never receive None - # and silently degrade instead of raising a clear error. - if manifest is None: - raise BundlerError( - f"Downloaded artifact for bundle '{entry_id}' from " - f"{_source_desc} is not a valid bundle." - ) - return manifest - - # Decode as UTF-8 explicitly -- matching yamlio.load_yaml's contract -- - # instead of feeding PyYAML the raw byte stream. PyYAML's Reader - # auto-detects a UTF-16 BOM and would silently *accept* a manifest - # that the local directory/bundle.yml sources reject, letting this - # remote-download path diverge from them (see the sibling .zip fix - # for _local_manifest_source, which had the identical bug). - try: - text = raw.decode("utf-8") - except UnicodeError as exc: - raise BundlerError( - f"Downloaded content for bundle '{entry_id}' from " - f"{_source_desc} could not be read: {exc}" - ) from exc - data = _yaml.safe_load(text) - return BundleManifest.from_dict(data) - except BundlerError: - raise - except _yaml.YAMLError as exc: - raise BundlerError( - f"Downloaded content for bundle '{entry_id}' from {_source_desc} " - f"is not valid YAML: {exc}" - ) from exc - except Exception as exc: # noqa: BLE001 - raise BundlerError( - f"Failed to parse downloaded bundle '{entry_id}' from " - f"{_source_desc}: {exc}" - ) from exc - - -def _validate_manifest_structure(manifest, *, source: str) -> None: - """Reject a malformed manifest before any project mutation can occur.""" - from ...bundler.services.validator import validate_manifest - - report = validate_manifest(manifest) - if report.ok: - return - raise BundlerError( - f"{source} contains an invalid bundle manifest:\n - " - + "\n - ".join(report.errors) - ) - - -def _validate_catalog_manifest(entry, manifest) -> None: - """Bind a downloaded manifest to the catalog identity that selected it.""" - if manifest.bundle.id != entry.id: - raise BundlerError( - f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to " - f"a manifest for {manifest.bundle.id!r}." - ) - if manifest.bundle.version != entry.version: - raise BundlerError( - f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares " - f"{entry.version!r}, but the manifest declares " - f"{manifest.bundle.version!r}." - ) - _validate_manifest_structure( - manifest, - source=f"Downloaded bundle {entry.id!r}", - ) - - -def register(app: typer.Typer) -> None: - """Attach the bundle command group to the root Typer app.""" - app.add_typer(bundle_app, name="bundle") diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index ad38366938..9f8dbd66fd 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -1,12 +1,26 @@ -"""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 -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 @@ -134,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/_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..b8dd4542e7 100644 --- a/src/specify_cli/integrations/_commands.py +++ b/src/specify_cli/integrations/_commands.py @@ -1,9 +1,14 @@ -"""specify integration * commands — app objects and register() entry point.""" +"""Shared infrastructure and registration for ``specify integration`` commands. + +Command handlers belong in ``command_*.py`` modules. Compatibility exports +required by external CLI consumers remain at this registration boundary. +""" 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 +24,24 @@ add_completion=False, ) -integration_catalog_app = typer.Typer( - name="catalog", - help="Manage integration catalog sources", - add_completion=False, -) -integration_app.add_typer(integration_catalog_app, name="catalog") - def register(app: typer.Typer) -> None: - from . import _install_commands # noqa: F401 — registers handlers via decorators - from . import _migrate_commands # noqa: F401 - from . import _query_commands # noqa: F401 - from . import _scaffold_commands # noqa: F401 + """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 + app.add_typer(integration_app, name="integration") diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py deleted file mode 100644 index 2e71c26e94..0000000000 --- a/src/specify_cli/integrations/_migrate_commands.py +++ /dev/null @@ -1,903 +0,0 @@ -"""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, -) - - -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") diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py deleted file mode 100644 index 0cd254879a..0000000000 --- a/src/specify_cli/integrations/_query_commands.py +++ /dev/null @@ -1,573 +0,0 @@ -"""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") diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py index 2f92f22c01..fe9a0390bc 100644 --- a/src/specify_cli/integrations/amp/__init__.py +++ b/src/specify_cli/integrations/amp/__init__.py @@ -1,6 +1,7 @@ """Amp CLI integration.""" from collections.abc import Mapping, Sequence +from pathlib import Path from typing import Any from ..base import MarkdownIntegration @@ -30,6 +31,7 @@ def build_exec_args( output_json: bool = True, integration_args: Sequence[str] | None = None, integration_options: Mapping[str, Any] | None = None, + project_root: Path | None = None, ) -> list[str] | None: self.validate_runtime_config(integration_args, integration_options) args = [self._resolve_executable()] 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.py deleted file mode 100644 index b8d76cb9c6..0000000000 --- a/src/specify_cli/integrations/catalog.py +++ /dev/null @@ -1,878 +0,0 @@ -"""Integration catalog — discovery, validation, and upgrade support. - -Provides: -- ``IntegrationCatalogEntry`` — single catalog source metadata. -- ``IntegrationCatalog`` — fetches, caches, and searches integration - catalogs (built-in + community). -- ``IntegrationDescriptor`` — loads and validates ``integration.yml``. -""" - -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 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 [] - - 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/integrations/catalog/__init__.py b/src/specify_cli/integrations/catalog/__init__.py new file mode 100644 index 0000000000..69667a4871 --- /dev/null +++ b/src/specify_cli/integrations/catalog/__init__.py @@ -0,0 +1,48 @@ +"""Registration for the nested ``specify integration catalog`` command group. + +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 typer + +from .. import ( + IntegrationCatalog, + IntegrationCatalogEntry, + IntegrationCatalogError, + IntegrationDescriptor, + IntegrationDescriptorError, + IntegrationValidationError, + _catalog_shape_error, +) + +__all__ = [ + "IntegrationCatalog", + "IntegrationCatalogEntry", + "IntegrationCatalogError", + "IntegrationDescriptor", + "IntegrationDescriptorError", + "IntegrationValidationError", + "_catalog_shape_error", + "catalog_app", + "register", +] + + +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..bde5690065 --- /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..fb79b4f997 --- /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..b9a7a37f00 --- /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..2606d88bca --- /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, + 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/_install_commands.py b/src/specify_cli/integrations/command_install.py similarity index 59% rename from src/specify_cli/integrations/_install_commands.py rename to src/specify_cli/integrations/command_install.py index fc39dc8863..f666928c39 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/command_install.py @@ -1,4 +1,4 @@ -"""specify integration install / uninstall command handlers.""" +"""The ``specify integration install`` command.""" from __future__ import annotations import os @@ -6,7 +6,6 @@ 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, @@ -19,21 +18,7 @@ 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, -) +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") @@ -212,118 +197,3 @@ def integration_install( 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}") diff --git a/src/specify_cli/integrations/command_list.py b/src/specify_cli/integrations/command_list.py new file mode 100644 index 0000000000..7850e6cb1b --- /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 . 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/_scaffold_commands.py b/src/specify_cli/integrations/command_scaffold.py similarity index 90% rename from src/specify_cli/integrations/_scaffold_commands.py rename to src/specify_cli/integrations/command_scaffold.py index 4a5d392dca..6d11298ac3 100644 --- a/src/specify_cli/integrations/_scaffold_commands.py +++ b/src/specify_cli/integrations/command_scaffold.py @@ -1,4 +1,5 @@ -"""specify integration scaffold command handler.""" +"""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 new file mode 100644 index 0000000000..dac8f31a7f --- /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, + 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..7a805041d4 --- /dev/null +++ b/src/specify_cli/integrations/command_upgrade.py @@ -0,0 +1,348 @@ +"""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, + _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 + + +@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/_commands.py b/src/specify_cli/presets/_commands.py index fd895e1486..b419293dbd 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -1,38 +1,13 @@ -"""specify preset * command handlers — app objects and register() entry point. +"""Typer command group and shared CLI infrastructure for ``specify preset``.""" -Moved out of __init__.py (PR-6/8). Handlers reference helpers that remain in -the package root (`_require_specify_project`, `get_speckit_version`, -`_locate_bundled_preset`, `_display_project_path`) via lazy `from .. import` -calls inside each function so test monkeypatching of `specify_cli.` -keeps working. -""" from __future__ import annotations -import os -import re -import shlex -from pathlib import Path - import typer -import yaml -from rich.markup import escape as _escape_markup from .._console import console -from .._installed_list_json import ( - InstalledListJSONCommand, - emit_json, - emit_json_error, - installed_list_item, -) -from .._project import resolve_specify_project_root -from .._download_security import ( - archive_format_from_name, - archive_suffix, - detect_archive_format, - is_https_or_localhost_http, - is_safe_download_redirect, - read_response_limited, -) +from .._download_security import read_response_limited as _read_response_limited + +read_response_limited = _read_response_limited preset_app = typer.Typer( name="preset", @@ -40,27 +15,15 @@ add_completion=False, ) -preset_catalog_app = typer.Typer( - name="catalog", - help="Manage preset catalogs", - add_completion=False, -) -preset_app.add_typer(preset_catalog_app, name="catalog") - - -#: Lowest priority a user may request. Lower numbers win resolution, so the -#: stack is anchored at 1 rather than 0 to leave no unreachable slot above the -#: highest-precedence preset. +# Lowest priority a user may request. Lower numbers win resolution, so the +# stack is anchored at 1 rather than 0 to leave no unreachable slot above the +# highest-precedence preset. MINIMUM_PRESET_PRIORITY = 1 def _render_powershell_argv(argv: list[str]) -> str: - """Render argv as a copy-pastable PowerShell command. + """Render argv as a copy-pastable PowerShell command.""" - PowerShell single-quoted strings are literal except that an embedded single - quote is escaped by doubling it. The call operator is required because the - executable name is quoted too. - """ def quote_arg(arg: str) -> str: return "'" + arg.replace("'", "''") + "'" @@ -68,14 +31,7 @@ def quote_arg(arg: str) -> str: def _validate_priority(priority: int) -> None: - """Reject a non-positive priority before any destructive work begins. - - Shared by add, set-priority, and update so the three commands cannot drift - apart on the accepted range or the message they print. update in particular - must call this *before* removing the installed preset: validating only - inside add would leave the preset removed and print a retry command - carrying the same rejected priority. - """ + """Reject a non-positive priority before destructive work begins.""" if priority < MINIMUM_PRESET_PRIORITY: console.print( "[red]Error:[/red] Priority must be a positive integer " @@ -85,1049 +41,59 @@ def _validate_priority(priority: int) -> None: def _warn_unmet_extension_dependencies(manager, manifest) -> None: - """Warn when a preset's declared extension dependencies are unsatisfied. + """Preserve the legacy helper import path for external consumers.""" + from .command_add import _warn_unmet_extension_dependencies as implementation - A preset whose command overrides call into an extension is inert without - it, but the overrides still fall through to the core workflow, so nothing - breaks -- it just silently does less than the user expects. Naming the - missing extension and the command that installs it turns that silence into - something actionable. See issue #4231. - """ - from ..extensions._commands import _command_safe_id + implementation(manager, manifest) - unmet = manager.find_unmet_extension_dependencies(manifest) - if not unmet: - return - console.print() - console.print("[yellow]![/yellow] This preset depends on extensions that are not satisfied:") - needs_catalog = False - for dep in unmet: - uses_catalog = False - extension_id = _escape_markup(dep["id"]) - # The displayed id only needs Rich escaping, but a suggested command - # has to survive Typer's parser: `^[a-z0-9-]+$` admits a leading - # hyphen, so an id like `--force` would render as an option rather - # than the positional argument. _command_safe_id substitutes a - # placeholder in that case, the same way extension commands do. - command_id = _command_safe_id(dep["id"]) - reason = dep["reason"] - # The remediation has to match the reason. `extension add` refuses an - # already-installed extension without --force, and `extension update` - # only moves forward to the catalog release. A general PEP 440 - # constraint may require an exact version, an upper bound, or a - # downgrade, so do not promise that update will satisfy it. - if reason == "missing": - console.print(f" [yellow]{extension_id}[/yellow] is not installed") - label, remedy = "Install with", f"specify extension add {command_id}" - uses_catalog = True - elif reason == "corrupt": - console.print( - f" [yellow]{extension_id}[/yellow] has an unreadable " - "registry entry" - ) - # is_installed() still counts the key, so a plain add is refused. - label = "Reinstall with" - remedy = f"specify extension add {command_id} --force" - uses_catalog = True - elif reason == "stale": - console.print( - f" [yellow]{extension_id}[/yellow] is registered but its " - "files are missing" - ) - label = "Reinstall with" - remedy = f"specify extension add {command_id} --force" - uses_catalog = True - elif reason == "disabled": - console.print(f" [yellow]{extension_id}[/yellow] is installed but disabled") - label, remedy = "Enable with", f"specify extension enable {command_id}" - else: - console.print( - f" [yellow]{extension_id}[/yellow] " - f"{_escape_markup(dep['installed'])} does not satisfy " - f"{_escape_markup(dep['version'])}" - ) - label = "Needs" - remedy = ( - f"a release of {command_id} satisfying " - f"{_escape_markup(dep['version'])}" - ) - console.print(f" {label}: {remedy}") - needs_catalog = needs_catalog or uses_catalog - console.print() - # The consequence differs by reason and must not be overstated. An - # unavailable extension contributes nothing, so those features are simply - # inert. A version mismatch is the opposite: the extension is installed and - # enabled, so the preset does invoke it -- the combination is just untested - # against the declared constraint, which is not the same as "safe". - console.print("[dim]The preset is installed.[/dim]") - if any( - dep["reason"] in ("missing", "corrupt", "stale", "disabled") - for dep in unmet - ): - console.print( - "[dim]Anything relying on an unavailable extension does nothing " - "until that is resolved.[/dim]" - ) - if any(dep["reason"] == "version" for dep in unmet): - console.print( - "[dim]Where only a version constraint is unmet the extension is " - "still used, so it may not behave as the preset expects.[/dim]" - ) - if needs_catalog: - # `extension add ` resolves through the catalogs, and the default - # community catalog is discovery-only, so installing by id is refused - # for anything listed only there -- true of every extension motivating - # this feature. Knowing which applies would mean a catalog fetch, and - # this runs on an install path that touches no network, so describe - # the outcome instead of asserting the command succeeds. The rejection - # itself prints the exact --from form, so this is a signpost rather - # than a dead end. - console.print( - "[dim]If an extension is listed only in a discovery-only catalog, " - "that command is refused and prints the " - "--from form to use instead.[/dim]" - ) - - -# ===== Preset Commands ===== - - -@preset_app.command("list", cls=InstalledListJSONCommand) -def preset_list( - json_output: bool = typer.Option(False, "--json", help="Output installed presets as JSON"), -): - """List installed presets.""" - from .. import _require_specify_project - from . import PresetManager +def preset_add(*args, **kwargs): + """Preserve the legacy add-handler import path for external consumers.""" + from .command_add import preset_add as implementation - if json_output: - try: - project_root = resolve_specify_project_root() - manager = PresetManager(project_root) - installed = manager.list_installed() - installed = sorted( - installed, - key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), - ) - emit_json( - [installed_list_item(pack, include_hooks=False) for pack in installed] - ) - return - except Exception as error: - emit_json_error(error) + return implementation(*args, **kwargs) - project_root = _require_specify_project() - manager = PresetManager(project_root) - installed = manager.list_installed() - if not installed: - console.print("[yellow]No presets installed.[/yellow]") - console.print("\nInstall a preset with:") - console.print(" [cyan]specify preset add [/cyan]") - return +def preset_remove(*args, **kwargs): + """Preserve the legacy remove-handler import path for external consumers.""" + from .command_remove import preset_remove as implementation - # Sort by actual resolution precedence: lower priority number wins, ties - # broken by preset id (matching PresetRegistry.list_by_priority()). This - # keeps the printed order aligned with how presets are composed/resolved. - installed = sorted( - installed, - key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), - ) - - console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n") - for pack in installed: - status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" - pri = pack.get('priority', 10) - name = _escape_markup(str(pack['name'])) - pack_id = _escape_markup(str(pack['id'])) - version = _escape_markup(str(pack['version'])) - console.print(f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}") - console.print(f" {_escape_markup(str(pack['description']))}") - tags = pack.get("tags", []) - if isinstance(tags, list) and tags: - tags_str = _escape_markup(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print(f" [dim]Templates: {pack['template_count']}[/dim]") - console.print() - - console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]") - - -@preset_app.command("add") -def preset_add( - preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), - from_url: str = typer.Option( - None, - "--from", - help="Install from a .zip, .tar.gz, or .tgz URL", - ), - dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install a preset.""" - from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version - from . import ( - PresetManager, - PresetCatalog, - PresetError, - PresetValidationError, - PresetCompatibilityError, - ) + return implementation(*args, **kwargs) - project_root = _require_specify_project() - # Validate priority - _validate_priority(priority) - manager = PresetManager(project_root) - speckit_version = get_speckit_version() - - try: - if dev: - dev_path = Path(dev).resolve() - if not dev_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {dev}") - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") - manifest = manager.install_from_directory(dev_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif from_url: - # Validate URL scheme before downloading - from urllib.parse import urlparse as _urlparse - - try: - _parsed = _urlparse(from_url) - _parsed.port - except ValueError: - console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") - raise typer.Exit(1) - - def _validate_download_redirect(old_url, new_url): - if not is_safe_download_redirect(old_url, new_url): - import urllib.error - - raise urllib.error.URLError( - "redirect target must use HTTPS without entering a local " - "target, or stay within loopback over HTTP" - ) - - if not is_https_or_localhost_http(from_url): - console.print( - "[red]Error:[/red] URL must use HTTPS with a hostname and be " - "a valid URL with a host. HTTP is only allowed for localhost, " - "127.0.0.1, and ::1." - ) - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...") - import urllib.error - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - archive_path = Path(tmpdir) / "preset.archive" - try: - from specify_cli.authentication.http import open_url as _open_url - from specify_cli.authentication.http import github_provider_hosts - from specify_cli._github_http import resolve_github_release_asset_api_url - - _preset_extra_headers = None - _resolved_from_url = resolve_github_release_asset_api_url( - from_url, _open_url, github_hosts=github_provider_hosts() - ) - if _resolved_from_url: - from_url = _resolved_from_url - _preset_extra_headers = {"Accept": "application/octet-stream"} - - with _open_url( - from_url, - timeout=60, - extra_headers=_preset_extra_headers, - redirect_validator=_validate_download_redirect, - ) as response: - final_url = response.geturl() if hasattr(response, "geturl") else from_url - if not is_https_or_localhost_http(final_url): - console.print( - "[red]Error:[/red] Preset URL redirected to a disallowed URL: " - f"{final_url}. Redirect targets must use HTTPS with a hostname, " - "or HTTP for localhost (127.0.0.1, ::1)." - ) - raise typer.Exit(1) - archive_data = read_response_limited( - response, - error_type=PresetError, - label=f"preset {from_url}", - ) - content_type = ( - response.getheader("Content-Type") - if hasattr(response, "getheader") - else None - ) - archive_path.write_bytes(archive_data) - format_source = ( - final_url - if archive_format_from_name(final_url) is not None - else from_url - ) - archive_format = detect_archive_format( - archive_path, - source_name=format_source, - content_type=content_type, - error_type=PresetError, - ) - detected_path = archive_path.with_suffix( - archive_suffix(archive_format) - ) - os.replace(archive_path, detected_path) - archive_path = detected_path - except (urllib.error.URLError, PresetError) as e: - console.print( - f"[red]Error:[/red] Failed to download: " - f"{_escape_markup(str(e))}" - ) - raise typer.Exit(1) - - manifest = manager.install_from_zip( - archive_path, - speckit_version, - priority, - ) - - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif preset_id: - # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(preset_id) - if bundled_path: - console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") - manifest = manager.install_from_directory(bundled_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - else: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(preset_id) - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") - raise typer.Exit(1) - - # Bundled presets should have been caught above; if we reach - # here the bundled files are missing from the installation. - if pack_info.get("bundled") and not pack_info.get("download_url"): - from ..extensions import REINSTALL_COMMAND - console.print( - f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "\nThis usually means the spec-kit installation is incomplete or corrupted." - ) - console.print("Try reinstalling spec-kit:") - console.print(f" {REINSTALL_COMMAND}") - raise typer.Exit(1) - - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") - console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") - raise typer.Exit(1) - - console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") - - try: - archive_path = catalog.download_pack(preset_id) - manifest = manager.install_from_zip( - archive_path, - speckit_version, - priority, - catalog_name=pack_info.get("_catalog_name"), - ) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - finally: - if 'archive_path' in locals() and archive_path.exists(): - archive_path.unlink(missing_ok=True) - else: - console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") - raise typer.Exit(1) - - # Every install path above binds `manifest` and the no-source branch - # exits, so one call here covers --dev, --from, and catalog installs - # alike. Warns rather than fails: the preset is installed and its - # overrides fall through to the core workflow without the extension. - _warn_unmet_extension_dependencies(manager, manifest) - - except PresetCompatibilityError as e: - console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except PresetValidationError as e: - console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except PresetError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) +def preset_update(*args, **kwargs): + """Preserve the legacy update-handler import path for external consumers.""" + from .command_update import preset_update as implementation - -@preset_app.command("remove") -def preset_remove( - preset_id: str = typer.Argument(..., help="Preset ID to remove"), -): - """Remove an installed preset.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - if manager.remove(preset_id): - console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") - else: - console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") - raise typer.Exit(1) - - -@preset_app.command("update") -def preset_update( - preset_id: str = typer.Argument(..., help="Installed preset ID to replace"), - from_url: str = typer.Option( - None, - "--from", - help="Install the replacement from a .zip, .tar.gz, or .tgz URL", - ), - dev: str = typer.Option( - None, - "--dev", - help="Install the replacement from a local directory (development mode)", - ), - priority: int = typer.Option( - 10, - "--priority", - help="Resolution priority for the replacement (default 10)", - ), -): - """Replace an installed preset using the normal remove and add flows.""" - from .. import _require_specify_project - from . import PresetManager - - if from_url is not None and dev is not None: - console.print("[red]Error:[/red] --from and --dev are mutually exclusive") - raise typer.Exit(1) - if from_url == "": - console.print("[red]Error:[/red] --from must not be empty") - raise typer.Exit(1) - if dev == "": - console.print("[red]Error:[/red] --dev must not be empty") - raise typer.Exit(1) - - # Validate priority before removal. add rejects the same range, but only - # after remove has already run, which would leave the preset removed and - # the printed retry command carrying the rejected priority. - _validate_priority(priority) - - project_root = _require_specify_project() - manager = PresetManager(project_root) - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Keep update deliberately destructive: remove performs its complete normal - # reconciliation before add resolves and installs the replacement. - preset_remove(preset_id) - - retry_args = ["specify", "preset", "add"] - retry_options = [] - if from_url is not None: - retry_options.extend(["--from", from_url]) - if dev is not None: - retry_options.extend(["--dev", dev]) - retry_options.extend(["--priority", str(priority)]) - if preset_id.startswith("-"): - retry_args.extend([*retry_options, "--", preset_id]) - else: - retry_args.extend([preset_id, *retry_options]) - - def report_add_failure() -> None: - if os.name == "nt": - retry_label = "Retry in PowerShell: " - rendered_args = _render_powershell_argv(retry_args) - else: - retry_label = "Retry with: " - rendered_args = shlex.join(retry_args) - console.print( - "[red]Error:[/red] Preset update failed; the previous preset was removed." - ) - console.print( - f"{retry_label}[cyan]" - f"{_escape_markup(rendered_args)}" - "[/cyan]", - soft_wrap=True, - ) - - try: - preset_add( - preset_id=preset_id, - from_url=from_url, - dev=dev, - priority=priority, - ) - except typer.Exit as error: - report_add_failure() - raise typer.Exit(error.exit_code or 1) - except Exception as error: - console.print(f"[red]Error:[/red] {_escape_markup(str(error))}") - report_add_failure() - raise typer.Exit(1) - - -@preset_app.command("search") -def preset_search( - query: str = typer.Argument(None, help="Search query"), - tag: str = typer.Option(None, "--tag", help="Filter by tag"), - author: str = typer.Option(None, "--author", help="Filter by author"), -): - """Search for presets in the catalog.""" - from .. import _require_specify_project - from . import PresetCatalog, PresetError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except PresetError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - if not results: - console.print("[yellow]No presets found matching your criteria.[/yellow]") - return - - console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") - for pack in results: - name = _escape_markup(str(pack.get("name", pack["id"]))) - pack_id = _escape_markup(str(pack["id"])) - version = _escape_markup(str(pack.get("version", "?"))) - console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}") - console.print( - f" {_escape_markup(str(pack.get('description', '')))}" - ) - tags = pack.get("tags", []) - if isinstance(tags, list) and tags: - tags_str = _escape_markup(", ".join(str(t) for t in tags)) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print() - - -@preset_app.command("resolve") -def preset_resolve( - template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), -): - """Show which template will be resolved for a given name.""" - from .. import _require_specify_project - from . import PresetResolver - - is_command = "." in template_name - valid_name = ( - re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name) - if is_command - else re.fullmatch(r"[a-z0-9-]+", template_name) - ) - if valid_name is None: - typer.echo( - f"Error: invalid template name '{template_name}'; " - "use lowercase letters, digits, and hyphens, with non-empty " - "dot-separated segments for commands", - err=True, - ) - raise typer.Exit(1) - - project_root = _require_specify_project() - resolver = PresetResolver(project_root) - template_type = "command" if is_command else "template" - - layers = resolver.collect_all_layers(template_name, template_type) - safe_template_name = _escape_markup(str(template_name)) - - if layers: - # Use the highest-priority layer for display because the final output - # may be composed and may not map to resolve_with_source()'s single path. - display_layer = layers[0] - console.print( - f" [bold]{safe_template_name}[/bold]: " - f"{_escape_markup(str(display_layer['path']))}" - ) - console.print( - f" [dim](top layer from: " - f"{_escape_markup(str(display_layer['source']))})[/dim]" - ) - - has_composition = ( - layers[0]["strategy"] != "replace" - and any(layer["strategy"] != "replace" for layer in layers) - ) - if has_composition: - # Verify composition is actually possible - try: - composed = resolver.resolve_content(template_name, template_type) - except Exception as exc: - composed = None - console.print( - f" [yellow]Warning: composition error: " - f"{_escape_markup(str(exc))}[/yellow]" - ) - if composed is None: - console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") - else: - console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") - console.print("\n [bold]Composition chain:[/bold]") - # Compute the effective base: first replace layer scanning from - # highest priority (matching resolve_content top-down logic). - # Only show layers from the base upward (lower layers are ignored). - effective_base_idx = None - for idx, lyr in enumerate(layers): - if lyr["strategy"] == "replace": - effective_base_idx = idx - break - # Show only contributing layers (base and above) - if effective_base_idx is not None: - contributing = layers[:effective_base_idx + 1] - else: - contributing = layers - for i, layer in enumerate(reversed(contributing)): - strategy_label = layer["strategy"] - if strategy_label == "replace" and i == 0: - strategy_label = "base" - # Escape the literal bracket (\[) so Rich renders `[]` - # instead of parsing it as a style tag and swallowing the label, - # mirroring `workflow info`'s step-graph line. - console.print( - f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] " - f"{_escape_markup(str(layer['source']))} → " - f"{_escape_markup(str(layer['path']))}" - ) - else: - # No layers found — fall back to resolve_with_source for non-composition cases - result = resolver.resolve_with_source(template_name, template_type) - if result: - console.print( - f" [bold]{safe_template_name}[/bold]: " - f"{_escape_markup(str(result['path']))}" - ) - console.print( - f" [dim](from: {_escape_markup(str(result['source']))})[/dim]" - ) - else: - console.print(f" [yellow]{safe_template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") - - -@preset_app.command("info") -def preset_info( - preset_id: str = typer.Argument(..., help="Preset ID to get info about"), -): - """Show detailed information about a preset.""" - from .. import _require_specify_project - from ..extensions import normalize_priority - from . import PresetCatalog, PresetManager, PresetError - - project_root = _require_specify_project() - safe_preset_id = _escape_markup(str(preset_id)) - # Check if installed locally first - manager = PresetManager(project_root) - local_pack = manager.get_pack(preset_id) - - if local_pack: - console.print( - f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n" - ) - console.print(f" ID: {_escape_markup(str(local_pack.id))}") - console.print(f" Version: {_escape_markup(str(local_pack.version))}") - console.print( - f" Description: {_escape_markup(str(local_pack.description))}" - ) - if local_pack.author: - console.print(f" Author: {_escape_markup(str(local_pack.author))}") - local_tags = local_pack.tags - if isinstance(local_tags, list) and local_tags: - tags_str = _escape_markup(", ".join(str(t) for t in local_tags)) - console.print(f" Tags: {tags_str}") - console.print(f" Templates: {len(local_pack.templates)}") - for tmpl in local_pack.templates: - tmpl_name = _escape_markup(str(tmpl['name'])) - tmpl_type = _escape_markup(str(tmpl['type'])) - tmpl_desc = _escape_markup(str(tmpl.get('description', ''))) - console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}") - repo = local_pack.data.get("preset", {}).get("repository") - if repo: - console.print(f" Repository: {_escape_markup(str(repo))}") - license_val = local_pack.data.get("preset", {}).get("license") - if license_val: - console.print(f" License: {_escape_markup(str(license_val))}") - console.print("\n [green]Status: installed[/green]") - # Get priority from registry - pack_metadata = manager.registry.get(preset_id) - priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) - console.print(f" [dim]Priority:[/dim] {priority}") - console.print() - return - - # Fall back to catalog - catalog = PresetCatalog(project_root) - try: - pack_info = catalog.get_pack_info(preset_id) - except PresetError: - pack_info = None - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") - raise typer.Exit(1) - - name = _escape_markup(str(pack_info.get("name", preset_id))) - console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n") - console.print(f" ID: {_escape_markup(str(pack_info['id']))}") - console.print( - f" Version: {_escape_markup(str(pack_info.get('version', '?')))}" - ) - console.print( - f" Description: {_escape_markup(str(pack_info.get('description', '')))}" - ) - if pack_info.get("author"): - console.print( - f" Author: {_escape_markup(str(pack_info['author']))}" - ) - catalog_tags = pack_info.get("tags", []) - if isinstance(catalog_tags, list) and catalog_tags: - catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags)) - console.print(f" Tags: {catalog_tags_str}") - if pack_info.get("repository"): - console.print( - f" Repository: {_escape_markup(str(pack_info['repository']))}" - ) - if pack_info.get("license"): - console.print( - f" License: {_escape_markup(str(pack_info['license']))}" - ) - console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]") - console.print() - - -@preset_app.command("set-priority") -def preset_set_priority( - preset_id: str = typer.Argument(help="Preset ID"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): - """Set the resolution priority of an installed preset.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - # Validate priority - _validate_priority(priority) - - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - from ..extensions import normalize_priority - raw_priority = metadata.get("priority") - # Only skip if the stored value is already a valid int equal to requested priority - # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) - # A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly — - # mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority - # equals 1/0 here and is never repaired. - if ( - isinstance(raw_priority, int) - and not isinstance(raw_priority, bool) - and raw_priority == priority - ): - console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(preset_id, {"priority": priority}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after changing priority for preset {preset_id}" - ) - - console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") - - -@preset_app.command("enable") -def preset_enable( - preset_id: str = typer.Argument(help="Preset ID to enable"), -): - """Enable a disabled preset.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") - raise typer.Exit(0) - - # Enable the preset - manager.registry.update(preset_id, {"enabled": True}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after enabling preset {preset_id}" - ) - - console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") - console.print("\nTemplates from this preset will now be included in resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") - - -@preset_app.command("disable") -def preset_disable( - preset_id: str = typer.Argument(help="Preset ID to disable"), -): - """Disable a preset without removing it.""" - from .. import _require_specify_project - from . import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") - raise typer.Exit(0) - - # Disable the preset - manager.registry.update(preset_id, {"enabled": False}) - manager.reconcile_constitution( - f"Failed to reconcile constitution after disabling preset {preset_id}" - ) - - console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") - console.print("\nTemplates from this preset will be skipped during resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") - console.print(f"To re-enable: specify preset enable {preset_id}") - - -# ===== Preset Catalog Commands ===== - - -@preset_catalog_app.command("list") -def preset_catalog_list(): - """List all active preset catalogs.""" - from .. import _display_project_path, _require_specify_project - from . import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - active_catalogs = catalog.get_active_catalogs() - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") - for entry in active_catalogs: - install_str = ( - "[green]install allowed[/green]" - if entry.install_allowed - else "[yellow]discovery only[/yellow]" - ) - console.print(f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})") - if entry.description: - console.print(f" {_escape_markup(str(entry.description))}") - console.print(f" URL: {_escape_markup(str(entry.url))}") - console.print(f" Install: {install_str}") - console.print() - - config_path = project_root / ".specify" / "preset-catalogs.yml" - user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" - if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") - else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except PresetValidationError: - proj_loaded = False - if proj_loaded: - console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") - else: - try: - user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None - except PresetValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" - ) - - -@preset_catalog_app.command("add") -def preset_catalog_add( - url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), - name: str = typer.Option(..., "--name", help="Catalog name"), - priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), - install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", - help="Allow presets from this catalog to be installed", - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), -): - """Add a catalog to .specify/preset-catalogs.yml.""" - from .. import _display_project_path, _require_specify_project - from . import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Validate URL - tmp_catalog = PresetCatalog(project_root) - try: - tmp_catalog._validate_catalog_url(url) - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - config_path = specify_dir / "preset-catalogs.yml" - - # Load existing config - if config_path.exists(): - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except Exception as e: - config_label = _display_project_path(project_root, config_path) - console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}") - raise typer.Exit(1) - if config is None: - config = {} - elif not isinstance(config, dict): - console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") - raise typer.Exit(1) - else: - config = {} - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - - # Only rendering is escaped — the raw values are what get persisted and - # compared below, so a name containing markup still round-trips exactly. - safe_name = _escape_markup(str(name)) - safe_url = _escape_markup(str(url)) - - # Check for duplicate name - for existing in catalogs: - if isinstance(existing, dict) and existing.get("name") == name: - console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.") - console.print("Use 'specify preset catalog remove' first, or choose a different name.") - raise typer.Exit(1) - - catalogs.append({ - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - }) - - config["catalogs"] = catalogs - config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - install_label = "install allowed" if install_allowed else "discovery only" - console.print(f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})") - console.print(f" URL: {safe_url}") - console.print(f" Priority: {priority}") - config_label = _escape_markup(str(_display_project_path(project_root, config_path))) - console.print(f"\nConfig saved to {config_label}") - - -@preset_catalog_app.command("remove") -def preset_catalog_remove( - name: str = typer.Argument(help="Catalog name to remove"), -): - """Remove a catalog from .specify/preset-catalogs.yml.""" - from .. import _require_specify_project - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - config_path = specify_dir / "preset-catalogs.yml" - if not config_path.exists(): - console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") - raise typer.Exit(1) - - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except Exception as e: - console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") - raise typer.Exit(1) - if config is None: - config = {} - elif not isinstance(config, dict): - console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") - raise typer.Exit(1) - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - # Rendering only — the raw name drives the comparison below. - safe_name = _escape_markup(str(name)) - - original_count = len(catalogs) - catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] - - if len(catalogs) == original_count: - console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.") - raise typer.Exit(1) - - config["catalogs"] = catalogs - config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - console.print(f"[green]✓[/green] Removed catalog '{safe_name}'") - if not catalogs: - console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + return implementation(*args, **kwargs) def register(app: typer.Typer) -> None: - """Attach the preset command group to the root Typer app.""" + """Register preset commands on the parent application.""" + # Imports are intentionally ordered to preserve command help output. + from . import command_list as _command_list + from . import command_add as _command_add + from . import command_remove as _command_remove + from . import command_update as _command_update + from . import command_search as _command_search + from . import command_resolve as _command_resolve + from . import command_info as _command_info + from . import command_set_priority as _command_set_priority + from . import command_enable as _command_enable + from . import command_disable as _command_disable + from .catalog import register as register_catalog + + _ = ( + _command_list, + _command_add, + _command_remove, + _command_update, + _command_search, + _command_resolve, + _command_info, + _command_set_priority, + _command_enable, + _command_disable, + ) + register_catalog(preset_app) app.add_typer(preset_app, name="preset") diff --git a/src/specify_cli/presets/catalog/__init__.py b/src/specify_cli/presets/catalog/__init__.py new file mode 100644 index 0000000000..e893a79c55 --- /dev/null +++ b/src/specify_cli/presets/catalog/__init__.py @@ -0,0 +1,25 @@ +"""Registration for the nested ``specify preset catalog`` command group. + +Command handlers live in ``command_*.py`` modules. +""" + +from __future__ import annotations + +import typer + +catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the catalog command group to the preset Typer app.""" + # isort: off + 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 + # isort: on + + app.add_typer(catalog_app, name="catalog") diff --git a/src/specify_cli/presets/catalog/command_add.py b/src/specify_cli/presets/catalog/command_add.py new file mode 100644 index 0000000000..eedf01275b --- /dev/null +++ b/src/specify_cli/presets/catalog/command_add.py @@ -0,0 +1,114 @@ +"""Implementation of the ``specify preset catalog add`` command.""" + +from __future__ import annotations + +import typer +import yaml +from rich.markup import escape as _escape_markup + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option( + 10, "--priority", help="Priority (lower = higher priority)" + ), + install_allowed: bool = typer.Option( + False, + "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option( + "", "--description", help="Description of the catalog" + ), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from ... import _display_project_path, _require_specify_project + from .. import PresetCatalog, PresetValidationError + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 - preserve CLI error boundary + config_label = _display_project_path(project_root, config_path) + console.print( + f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}" + ) + raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print( + "[red]Error:[/red] Invalid catalog config: expected a mapping." + ) + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print( + "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." + ) + raise typer.Exit(1) + + # Only rendering is escaped — the raw values are what get persisted and + # compared below, so a name containing markup still round-trips exactly. + safe_name = _escape_markup(str(name)) + safe_url = _escape_markup(str(url)) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + console.print( + f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists." + ) + console.print( + "Use 'specify preset catalog remove' first, or choose a different name." + ) + raise typer.Exit(1) + + catalogs.append( + { + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + } + ) + + config["catalogs"] = catalogs + config_path.write_text( + yaml.safe_dump( + config, default_flow_style=False, sort_keys=False, allow_unicode=True + ), + encoding="utf-8", + ) + + install_label = "install allowed" if install_allowed else "discovery only" + console.print( + f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})" + ) + console.print(f" URL: {safe_url}") + console.print(f" Priority: {priority}") + config_label = _escape_markup(str(_display_project_path(project_root, config_path))) + console.print(f"\nConfig saved to {config_label}") diff --git a/src/specify_cli/presets/catalog/command_list.py b/src/specify_cli/presets/catalog/command_list.py new file mode 100644 index 0000000000..1a5c4764f5 --- /dev/null +++ b/src/specify_cli/presets/catalog/command_list.py @@ -0,0 +1,78 @@ +"""Implementation of the ``specify preset catalog list`` command.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from ... import _display_project_path, _require_specify_project + from .. import PresetCatalog, PresetValidationError + + project_root = _require_specify_project() + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print( + f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})" + ) + if entry.description: + console.print(f" {_escape_markup(str(entry.description))}") + console.print(f" URL: {_escape_markup(str(entry.url))}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print( + "[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]" + ) + else: + try: + proj_loaded = ( + config_path.exists() + and catalog._load_catalog_config(config_path) is not None + ) + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print( + f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]" + ) + else: + try: + user_loaded = ( + user_config_path.exists() + and catalog._load_catalog_config(user_config_path) is not None + ) + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) diff --git a/src/specify_cli/presets/catalog/command_remove.py b/src/specify_cli/presets/catalog/command_remove.py new file mode 100644 index 0000000000..2ee2e85860 --- /dev/null +++ b/src/specify_cli/presets/catalog/command_remove.py @@ -0,0 +1,69 @@ +"""Implementation of the ``specify preset catalog remove`` command.""" + +from __future__ import annotations + +import typer +import yaml +from rich.markup import escape as _escape_markup + +from ..._console import console +from . import catalog_app + + +@catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + from ... import _require_specify_project + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print( + "[red]Error:[/red] No preset catalog config found. Nothing to remove." + ) + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 - preserve CLI error boundary + console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}") + raise typer.Exit(1) + if config is None: + config = {} + elif not isinstance(config, dict): + console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print( + "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." + ) + raise typer.Exit(1) + # Rendering only — the raw name drives the comparison below. + safe_name = _escape_markup(str(name)) + + original_count = len(catalogs) + catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name] + + if len(catalogs) == original_count: + console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text( + yaml.safe_dump( + config, default_flow_style=False, sort_keys=False, allow_unicode=True + ), + encoding="utf-8", + ) + + console.print(f"[green]✓[/green] Removed catalog '{safe_name}'") + if not catalogs: + console.print( + "\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]" + ) diff --git a/src/specify_cli/presets/command_add.py b/src/specify_cli/presets/command_add.py new file mode 100644 index 0000000000..fae04d0558 --- /dev/null +++ b/src/specify_cli/presets/command_add.py @@ -0,0 +1,381 @@ +"""Implementation of the ``specify preset add`` command.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from .._download_security import ( + archive_format_from_name, + archive_suffix, + detect_archive_format, + is_https_or_localhost_http, + is_safe_download_redirect, +) +from . import _commands +from ._commands import preset_app + + +def _warn_unmet_extension_dependencies(manager, manifest) -> None: + """Warn when a preset's declared extension dependencies are unsatisfied. + + A preset whose command overrides call into an extension is inert without + it, but the overrides still fall through to the core workflow, so nothing + breaks -- it just silently does less than the user expects. Naming the + missing extension and the command that installs it turns that silence into + something actionable. See issue #4231. + """ + from ..extensions._commands import _command_safe_id + + unmet = manager.find_unmet_extension_dependencies(manifest) + if not unmet: + return + + console.print() + console.print( + "[yellow]![/yellow] This preset depends on extensions that are not satisfied:" + ) + needs_catalog = False + for dep in unmet: + uses_catalog = False + extension_id = _escape_markup(dep["id"]) + # The displayed id only needs Rich escaping, but a suggested command + # has to survive Typer's parser: `^[a-z0-9-]+$` admits a leading + # hyphen, so an id like `--force` would render as an option rather + # than the positional argument. _command_safe_id substitutes a + # placeholder in that case, the same way extension commands do. + command_id = _command_safe_id(dep["id"]) + reason = dep["reason"] + # The remediation has to match the reason. `extension add` refuses an + # already-installed extension without --force, and `extension update` + # only moves forward to the catalog release. A general PEP 440 + # constraint may require an exact version, an upper bound, or a + # downgrade, so do not promise that update will satisfy it. + if reason == "missing": + console.print(f" [yellow]{extension_id}[/yellow] is not installed") + label, remedy = "Install with", f"specify extension add {command_id}" + uses_catalog = True + elif reason == "corrupt": + console.print( + f" [yellow]{extension_id}[/yellow] has an unreadable registry entry" + ) + # is_installed() still counts the key, so a plain add is refused. + label = "Reinstall with" + remedy = f"specify extension add {command_id} --force" + uses_catalog = True + elif reason == "stale": + console.print( + f" [yellow]{extension_id}[/yellow] is registered but its " + "files are missing" + ) + label = "Reinstall with" + remedy = f"specify extension add {command_id} --force" + uses_catalog = True + elif reason == "disabled": + console.print( + f" [yellow]{extension_id}[/yellow] is installed but disabled" + ) + label, remedy = "Enable with", f"specify extension enable {command_id}" + else: + console.print( + f" [yellow]{extension_id}[/yellow] " + f"{_escape_markup(dep['installed'])} does not satisfy " + f"{_escape_markup(dep['version'])}" + ) + label = "Needs" + remedy = ( + f"a release of {command_id} satisfying {_escape_markup(dep['version'])}" + ) + console.print(f" {label}: {remedy}") + needs_catalog = needs_catalog or uses_catalog + console.print() + # The consequence differs by reason and must not be overstated. An + # unavailable extension contributes nothing, so those features are simply + # inert. A version mismatch is the opposite: the extension is installed and + # enabled, so the preset does invoke it -- the combination is just untested + # against the declared constraint, which is not the same as "safe". + console.print("[dim]The preset is installed.[/dim]") + if any( + dep["reason"] in ("missing", "corrupt", "stale", "disabled") for dep in unmet + ): + console.print( + "[dim]Anything relying on an unavailable extension does nothing " + "until that is resolved.[/dim]" + ) + if any(dep["reason"] == "version" for dep in unmet): + console.print( + "[dim]Where only a version constraint is unmet the extension is " + "still used, so it may not behave as the preset expects.[/dim]" + ) + if needs_catalog: + # `extension add ` resolves through the catalogs, and the default + # community catalog is discovery-only, so installing by id is refused + # for anything listed only there -- true of every extension motivating + # this feature. Knowing which applies would mean a catalog fetch, and + # this runs on an install path that touches no network, so describe + # the outcome instead of asserting the command succeeds. The rejection + # itself prints the exact --from form, so this is a signpost rather + # than a dead end. + console.print( + "[dim]If an extension is listed only in a discovery-only catalog, " + "that command is refused and prints the " + "--from form to use instead.[/dim]" + ) + + +# ===== Preset Commands ===== + + +@preset_app.command("add") +def preset_add( + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option( + None, + "--from", + help="Install from a .zip, .tar.gz, or .tgz URL", + ), + dev: str = typer.Option( + None, "--dev", help="Install from local directory (development mode)" + ), + priority: int = typer.Option( + 10, + "--priority", + help="Resolution priority (lower = higher precedence, default 10)", + ), +): + """Install a preset.""" + from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version + from . import ( + PresetCatalog, + PresetCompatibilityError, + PresetError, + PresetManager, + PresetValidationError, + ) + + project_root = _require_specify_project() + _commands._validate_priority(priority) + + manager = PresetManager(project_root) + speckit_version = get_speckit_version() + + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory( + dev_path, speckit_version, priority + ) + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + + elif from_url: + # Validate URL scheme before downloading + from urllib.parse import urlparse as _urlparse + + try: + _parsed = _urlparse(from_url) + _ = _parsed.port + except ValueError: + console.print( + f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}" + ) + raise typer.Exit(1) + + def _validate_download_redirect(old_url, new_url): + if not is_safe_download_redirect(old_url, new_url): + import urllib.error + + raise urllib.error.URLError( + "redirect target must use HTTPS without entering a local " + "target, or stay within loopback over HTTP" + ) + + if not is_https_or_localhost_http(from_url): + console.print( + "[red]Error:[/red] URL must use HTTPS with a hostname and be " + "a valid URL with a host. HTTP is only allowed for localhost, " + "127.0.0.1, and ::1." + ) + raise typer.Exit(1) + + console.print( + f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]..." + ) + import tempfile + import urllib.error + + with tempfile.TemporaryDirectory() as tmpdir: + archive_path = Path(tmpdir) / "preset.archive" + try: + from specify_cli._github_http import ( + resolve_github_release_asset_api_url, + ) + from specify_cli.authentication.http import github_provider_hosts + from specify_cli.authentication.http import open_url as _open_url + + _preset_extra_headers = None + _resolved_from_url = resolve_github_release_asset_api_url( + from_url, _open_url, github_hosts=github_provider_hosts() + ) + if _resolved_from_url: + from_url = _resolved_from_url + _preset_extra_headers = {"Accept": "application/octet-stream"} + + with _open_url( + from_url, + timeout=60, + extra_headers=_preset_extra_headers, + redirect_validator=_validate_download_redirect, + ) as response: + final_url = ( + response.geturl() + if hasattr(response, "geturl") + else from_url + ) + if not is_https_or_localhost_http(final_url): + console.print( + "[red]Error:[/red] Preset URL redirected to a disallowed URL: " + f"{final_url}. Redirect targets must use HTTPS with a hostname, " + "or HTTP for localhost (127.0.0.1, ::1)." + ) + raise typer.Exit(1) + archive_data = _commands.read_response_limited( + response, + error_type=PresetError, + label=f"preset {from_url}", + ) + content_type = ( + response.getheader("Content-Type") + if hasattr(response, "getheader") + else None + ) + archive_path.write_bytes(archive_data) + format_source = ( + final_url + if archive_format_from_name(final_url) is not None + else from_url + ) + archive_format = detect_archive_format( + archive_path, + source_name=format_source, + content_type=content_type, + error_type=PresetError, + ) + detected_path = archive_path.with_suffix( + archive_suffix(archive_format) + ) + os.replace(archive_path, detected_path) + archive_path = detected_path + except (urllib.error.URLError, PresetError) as e: + console.print( + f"[red]Error:[/red] Failed to download: " + f"{_escape_markup(str(e))}" + ) + raise typer.Exit(1) + + manifest = manager.install_from_zip( + archive_path, + speckit_version, + priority, + ) + + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + + elif preset_id: + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(preset_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority + ) + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(preset_id) + + if not pack_info: + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in catalog" + ) + raise typer.Exit(1) + + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed)." + ) + console.print( + "Add the catalog with --install-allowed or install from the preset's repository directly with --from." + ) + raise typer.Exit(1) + + console.print( + f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]..." + ) + + try: + archive_path = catalog.download_pack(preset_id) + manifest = manager.install_from_zip( + archive_path, + speckit_version, + priority, + catalog_name=pack_info.get("_catalog_name"), + ) + console.print( + f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})" + ) + finally: + if "archive_path" in locals() and archive_path.exists(): + archive_path.unlink(missing_ok=True) + else: + console.print( + "[red]Error:[/red] Specify a preset ID, --from URL, or --dev path" + ) + raise typer.Exit(1) + + # Every install path above binds `manifest` and the no-source branch + # exits, so one call here covers --dev, --from, and catalog installs + # alike. Warns rather than fails: the preset is installed and its + # overrides fall through to the core workflow without the extension. + _commands._warn_unmet_extension_dependencies(manager, manifest) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) diff --git a/src/specify_cli/presets/command_disable.py b/src/specify_cli/presets/command_disable.py new file mode 100644 index 0000000000..85c0231abd --- /dev/null +++ b/src/specify_cli/presets/command_disable.py @@ -0,0 +1,50 @@ +"""Implementation of the ``specify preset disable`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("disable") +def preset_disable( + preset_id: str = typer.Argument(help="Preset ID to disable"), +): + """Disable a preset without removing it.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") + raise typer.Exit(0) + + # Disable the preset + manager.registry.update(preset_id, {"enabled": False}) + manager.reconcile_constitution( + f"Failed to reconcile constitution after disabling preset {preset_id}" + ) + + console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") + console.print("\nTemplates from this preset will be skipped during resolution.") + console.print( + "[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]" + ) + console.print(f"To re-enable: specify preset enable {preset_id}") diff --git a/src/specify_cli/presets/command_enable.py b/src/specify_cli/presets/command_enable.py new file mode 100644 index 0000000000..c3cd3ba5ff --- /dev/null +++ b/src/specify_cli/presets/command_enable.py @@ -0,0 +1,49 @@ +"""Implementation of the ``specify preset enable`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("enable") +def preset_enable( + preset_id: str = typer.Argument(help="Preset ID to enable"), +): + """Enable a disabled preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") + raise typer.Exit(0) + + # Enable the preset + manager.registry.update(preset_id, {"enabled": True}) + manager.reconcile_constitution( + f"Failed to reconcile constitution after enabling preset {preset_id}" + ) + + console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") + console.print("\nTemplates from this preset will now be included in resolution.") + console.print( + "[dim]Note: Previously registered commands/skills remain active.[/dim]" + ) diff --git a/src/specify_cli/presets/command_info.py b/src/specify_cli/presets/command_info.py new file mode 100644 index 0000000000..a3920a6605 --- /dev/null +++ b/src/specify_cli/presets/command_info.py @@ -0,0 +1,96 @@ +"""Implementation of the ``specify preset info`` command.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("info") +def preset_info( + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from .. import _require_specify_project + from ..extensions import normalize_priority + from . import PresetCatalog, PresetError, PresetManager + + project_root = _require_specify_project() + safe_preset_id = _escape_markup(str(preset_id)) + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(preset_id) + + if local_pack: + console.print( + f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n" + ) + console.print(f" ID: {_escape_markup(str(local_pack.id))}") + console.print(f" Version: {_escape_markup(str(local_pack.version))}") + console.print(f" Description: {_escape_markup(str(local_pack.description))}") + if local_pack.author: + console.print(f" Author: {_escape_markup(str(local_pack.author))}") + local_tags = local_pack.tags + if isinstance(local_tags, list) and local_tags: + tags_str = _escape_markup(", ".join(str(t) for t in local_tags)) + console.print(f" Tags: {tags_str}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + tmpl_name = _escape_markup(str(tmpl["name"])) + tmpl_type = _escape_markup(str(tmpl["type"])) + tmpl_desc = _escape_markup(str(tmpl.get("description", ""))) + console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {_escape_markup(str(repo))}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {_escape_markup(str(license_val))}") + console.print("\n [green]Status: installed[/green]") + # Get priority from registry + pack_metadata = manager.registry.get(preset_id) + priority = normalize_priority( + pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None + ) + console.print(f" [dim]Priority:[/dim] {priority}") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(preset_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)" + ) + raise typer.Exit(1) + + name = _escape_markup(str(pack_info.get("name", preset_id))) + console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n") + console.print(f" ID: {_escape_markup(str(pack_info['id']))}") + console.print( + f" Version: {_escape_markup(str(pack_info.get('version', '?')))}" + ) + console.print( + f" Description: {_escape_markup(str(pack_info.get('description', '')))}" + ) + if pack_info.get("author"): + console.print(f" Author: {_escape_markup(str(pack_info['author']))}") + catalog_tags = pack_info.get("tags", []) + if isinstance(catalog_tags, list) and catalog_tags: + catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags)) + console.print(f" Tags: {catalog_tags_str}") + if pack_info.get("repository"): + console.print(f" Repository: {_escape_markup(str(pack_info['repository']))}") + if pack_info.get("license"): + console.print(f" License: {_escape_markup(str(pack_info['license']))}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]") + console.print() diff --git a/src/specify_cli/presets/command_list.py b/src/specify_cli/presets/command_list.py new file mode 100644 index 0000000000..f7fd72efe3 --- /dev/null +++ b/src/specify_cli/presets/command_list.py @@ -0,0 +1,89 @@ +"""Implementation of the ``specify preset list`` command.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from .._installed_list_json import ( + InstalledListJSONCommand, + emit_json, + emit_json_error, + installed_list_item, +) +from .._project import resolve_specify_project_root +from ._commands import preset_app + + +@preset_app.command("list", cls=InstalledListJSONCommand) +def preset_list( + json_output: bool = typer.Option( + False, "--json", help="Output installed presets as JSON" + ), +): + """List installed presets.""" + from .. import _require_specify_project + from . import PresetManager + + if json_output: + try: + project_root = resolve_specify_project_root() + manager = PresetManager(project_root) + installed = manager.list_installed() + installed = sorted( + installed, + key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), + ) + emit_json( + [installed_list_item(pack, include_hooks=False) for pack in installed] + ) + return + except Exception as error: # noqa: BLE001 - emit the JSON error contract + emit_json_error(error) + + project_root = _require_specify_project() + manager = PresetManager(project_root) + installed = manager.list_installed() + + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return + + # Sort by actual resolution precedence: lower priority number wins, ties + # broken by preset id (matching PresetRegistry.list_by_priority()). This + # keeps the printed order aligned with how presets are composed/resolved. + installed = sorted( + installed, + key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), + ) + + console.print( + "\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n" + ) + for pack in installed: + status = ( + "[green]enabled[/green]" + if pack.get("enabled", True) + else "[red]disabled[/red]" + ) + pri = pack.get("priority", 10) + name = _escape_markup(str(pack["name"])) + pack_id = _escape_markup(str(pack["id"])) + version = _escape_markup(str(pack["version"])) + console.print( + f" [bold]{name}[/bold] ({pack_id}) v{version} — {status} — priority {pri}" + ) + console.print(f" {_escape_markup(str(pack['description']))}") + tags = pack.get("tags", []) + if isinstance(tags, list) and tags: + tags_str = _escape_markup(", ".join(str(t) for t in tags)) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() + + console.print( + "[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]" + ) diff --git a/src/specify_cli/presets/command_remove.py b/src/specify_cli/presets/command_remove.py new file mode 100644 index 0000000000..3654f1e04f --- /dev/null +++ b/src/specify_cli/presets/command_remove.py @@ -0,0 +1,30 @@ +"""Implementation of the ``specify preset remove`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("remove") +def preset_remove( + preset_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") + raise typer.Exit(1) diff --git a/src/specify_cli/presets/command_resolve.py b/src/specify_cli/presets/command_resolve.py new file mode 100644 index 0000000000..7256307160 --- /dev/null +++ b/src/specify_cli/presets/command_resolve.py @@ -0,0 +1,121 @@ +"""Implementation of the ``specify preset resolve`` command.""" + +from __future__ import annotations + +import re + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument( + ..., help="Template name to resolve (e.g., spec-template)" + ), +): + """Show which template will be resolved for a given name.""" + from .. import _require_specify_project + from . import PresetResolver + + is_command = "." in template_name + valid_name = ( + re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name) + if is_command + else re.fullmatch(r"[a-z0-9-]+", template_name) + ) + if valid_name is None: + typer.echo( + f"Error: invalid template name '{template_name}'; " + "use lowercase letters, digits, and hyphens, with non-empty " + "dot-separated segments for commands", + err=True, + ) + raise typer.Exit(1) + + project_root = _require_specify_project() + resolver = PresetResolver(project_root) + template_type = "command" if is_command else "template" + + layers = resolver.collect_all_layers(template_name, template_type) + safe_template_name = _escape_markup(str(template_name)) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print( + f" [bold]{safe_template_name}[/bold]: " + f"{_escape_markup(str(display_layer['path']))}" + ) + console.print( + f" [dim](top layer from: " + f"{_escape_markup(str(display_layer['source']))})[/dim]" + ) + + has_composition = layers[0]["strategy"] != "replace" and any( + layer["strategy"] != "replace" for layer in layers + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name, template_type) + except Exception as exc: # noqa: BLE001 - render composition failures + composed = None + console.print( + f" [yellow]Warning: composition error: " + f"{_escape_markup(str(exc))}[/yellow]" + ) + if composed is None: + console.print( + " [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]" + ) + else: + console.print( + " [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]" + ) + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[: effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + # Escape the literal bracket (\[) so Rich renders `[]` + # instead of parsing it as a style tag and swallowing the label, + # mirroring `workflow info`'s step-graph line. + console.print( + f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] " + f"{_escape_markup(str(layer['source']))} → " + f"{_escape_markup(str(layer['path']))}" + ) + else: + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name, template_type) + if result: + console.print( + f" [bold]{safe_template_name}[/bold]: " + f"{_escape_markup(str(result['path']))}" + ) + console.print( + f" [dim](from: {_escape_markup(str(result['source']))})[/dim]" + ) + else: + console.print(f" [yellow]{safe_template_name}[/yellow]: not found") + console.print( + " [dim]No template with this name exists in the resolution stack[/dim]" + ) diff --git a/src/specify_cli/presets/command_search.py b/src/specify_cli/presets/command_search.py new file mode 100644 index 0000000000..85216b1cb6 --- /dev/null +++ b/src/specify_cli/presets/command_search.py @@ -0,0 +1,46 @@ +"""Implementation of the ``specify preset search`` command.""" + +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from ._commands import preset_app + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from .. import _require_specify_project + from . import PresetCatalog, PresetError + + project_root = _require_specify_project() + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + name = _escape_markup(str(pack.get("name", pack["id"]))) + pack_id = _escape_markup(str(pack["id"])) + version = _escape_markup(str(pack.get("version", "?"))) + console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}") + console.print(f" {_escape_markup(str(pack.get('description', '')))}") + tags = pack.get("tags", []) + if isinstance(tags, list) and tags: + tags_str = _escape_markup(", ".join(str(t) for t in tags)) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() diff --git a/src/specify_cli/presets/command_set_priority.py b/src/specify_cli/presets/command_set_priority.py new file mode 100644 index 0000000000..8337c251fa --- /dev/null +++ b/src/specify_cli/presets/command_set_priority.py @@ -0,0 +1,70 @@ +"""Implementation of the ``specify preset set-priority`` command.""" + +from __future__ import annotations + +import typer + +from .._console import console +from . import _commands +from ._commands import preset_app + + +@preset_app.command("set-priority") +def preset_set_priority( + preset_id: str = typer.Argument(help="Preset ID"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + _commands._validate_priority(priority) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + from ..extensions import normalize_priority + + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + # A bool is an int in Python (isinstance(True, int) is True), so exclude it explicitly — + # mirroring normalize_priority's bool guard — otherwise a corrupted True/False priority + # equals 1/0 here and is never repaired. + if ( + isinstance(raw_priority, int) + and not isinstance(raw_priority, bool) + and raw_priority == priority + ): + console.print( + f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]" + ) + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(preset_id, {"priority": priority}) + manager.reconcile_constitution( + f"Failed to reconcile constitution after changing priority for preset {preset_id}" + ) + + console.print( + f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}" + ) + console.print( + "\n[dim]Lower priority = higher precedence in template resolution[/dim]" + ) diff --git a/src/specify_cli/presets/command_update.py b/src/specify_cli/presets/command_update.py new file mode 100644 index 0000000000..cde9b256bb --- /dev/null +++ b/src/specify_cli/presets/command_update.py @@ -0,0 +1,99 @@ +"""Implementation of the ``specify preset update`` command.""" + +from __future__ import annotations + +import os +import shlex + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands +from ._commands import preset_app + + +@preset_app.command("update") +def preset_update( + preset_id: str = typer.Argument(..., help="Installed preset ID to replace"), + from_url: str = typer.Option( + None, + "--from", + help="Install the replacement from a .zip, .tar.gz, or .tgz URL", + ), + dev: str = typer.Option( + None, + "--dev", + help="Install the replacement from a local directory (development mode)", + ), + priority: int = typer.Option( + 10, + "--priority", + help="Resolution priority for the replacement (default 10)", + ), +): + """Replace an installed preset using the normal remove and add flows.""" + from .. import _require_specify_project + from . import PresetManager + + if from_url is not None and dev is not None: + console.print("[red]Error:[/red] --from and --dev are mutually exclusive") + raise typer.Exit(1) + if from_url == "": + console.print("[red]Error:[/red] --from must not be empty") + raise typer.Exit(1) + if dev == "": + console.print("[red]Error:[/red] --dev must not be empty") + raise typer.Exit(1) + + _commands._validate_priority(priority) + + project_root = _require_specify_project() + manager = PresetManager(project_root) + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + _commands.preset_remove(preset_id) + + retry_args = ["specify", "preset", "add"] + retry_options = [] + if from_url is not None: + retry_options.extend(["--from", from_url]) + if dev is not None: + retry_options.extend(["--dev", dev]) + retry_options.extend(["--priority", str(priority)]) + if preset_id.startswith("-"): + retry_args.extend([*retry_options, "--", preset_id]) + else: + retry_args.extend([preset_id, *retry_options]) + + def report_add_failure() -> None: + if os.name == "nt": + retry_label = "Retry in PowerShell: " + rendered_args = _commands._render_powershell_argv(retry_args) + else: + retry_label = "Retry with: " + rendered_args = shlex.join(retry_args) + console.print( + "[red]Error:[/red] Preset update failed; the previous preset was removed." + ) + console.print( + f"{retry_label}[cyan]{_escape_markup(rendered_args)}[/cyan]", + soft_wrap=True, + ) + + try: + _commands.preset_add( + preset_id=preset_id, + from_url=from_url, + dev=dev, + priority=priority, + ) + except typer.Exit as error: + report_add_failure() + raise typer.Exit(error.exit_code or 1) + except Exception as error: + console.print(f"[red]Error:[/red] {_escape_markup(str(error))}") + report_add_failure() + raise typer.Exit(1) diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py deleted file mode 100644 index 4cf8d35150..0000000000 --- a/tests/contract/test_bundle_cli.py +++ /dev/null @@ -1,1231 +0,0 @@ -"""Contract test for the `specify bundle` CLI surface (Typer integration). - -Exercises the wired commands end-to-end via CliRunner against a temp project, -asserting exit codes and the cross-cutting error guarantees from -contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error). -""" -from __future__ import annotations - -import io -import json -from pathlib import Path -from unittest.mock import patch - -import pytest -import yaml -from typer.testing import CliRunner - -from specify_cli import app -from specify_cli.bundler.services.adapters import FIRSTPARTY_CATALOG_URL -from specify_cli.bundler.services.packager import build_bundle -from tests.conftest import strip_ansi -from tests.bundler_helpers import ( - catalog_entry_dict, - valid_manifest_dict, - write_catalog_file, -) - -runner = CliRunner() -REPO_ROOT = Path(__file__).parents[2] - -MARKUP_BUNDLE_ID = "[red]markup-id[/red]" -MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" - - -def _configure_markup_catalog(project: Path, **overrides: object) -> dict: - entry = catalog_entry_dict( - MARKUP_BUNDLE_ID, - name="[green]Markup Name[/green]", - version="[blue]1.0.0[/blue]", - role="[magenta]Markup Role[/magenta]", - description="[yellow]Markup Description[/yellow]", - author="[cyan]Markup Author[/cyan]", - license="[bold]Markup License[/bold]", - download_url="https://example.com/markup-bundle.zip", - requires={"speckit_version": "[italic]>=0.1.0[/italic]"}, - **overrides, - ) - catalog = project / "markup-catalog.json" - write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry}) - config = { - "schema_version": "1.0", - "catalogs": [ - { - "id": MARKUP_SOURCE_ID, - "url": str(catalog), - "priority": 1, - "install_policy": "install-allowed", - } - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), - encoding="utf-8", - ) - return entry - - -@pytest.fixture() -def project(tmp_path: Path, monkeypatch) -> Path: - (tmp_path / ".specify").mkdir() - monkeypatch.chdir(tmp_path) - return tmp_path - - -def test_bundle_help_lists_all_commands(): - result = runner.invoke(app, ["bundle", "--help"]) - assert result.exit_code == 0 - for cmd in ("search", "info", "list", "install", "add", "update", "remove", - "validate", "build", "init", "catalog"): - assert cmd in result.output - - -def test_update_accepts_integration_override(): - # Update must expose --integration so integration-pinned bundles can be - # updated in projects where the active integration can't be auto-detected. - # Rich may insert ANSI escapes between the two leading dashes, so match the - # un-split option word rather than the literal "--integration". - result = runner.invoke(app, ["bundle", "update", "--help"]) - assert result.exit_code == 0 - assert "integration" in result.output - - -def test_add_forwards_refresh_default_without_refreshing(project: Path): - from specify_cli.commands import bundle as bundle_commands - - with patch.object(bundle_commands, "bundle_install") as install: - result = runner.invoke(app, ["bundle", "add", "demo"]) - - assert result.exit_code == 0, result.output - install.assert_called_once_with( - bundle_id="demo", - integration=None, - offline=False, - refresh=False, - ) - - -def test_list_empty_project(project: Path): - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code == 0 - assert "No bundles installed" in result.output - - -def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch): - monkeypatch.chdir(tmp_path) # no .specify/ - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code == 1 - assert "Spec Kit project" in result.output - - -def test_remove_reports_clean_error_when_primitive_raises_raw_exception( - project: Path, -): - """A raw exception from a primitive installer (e.g. an OSError from an - unreadable workflow registry surfacing through _WorkflowKindManager's - fail-closed construction) must not propagate uncaught through - `specify bundle remove` -- the command only catches BundlerError, so - without a conversion at the remove_bundle boundary this would exit - with an unhandled exception and empty/raw output instead of a clean, - actionable message, and no removal side effects should occur either.""" - from specify_cli.bundler.models.manifest import BundleManifest - from specify_cli.bundler.models.records import load_records - from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller - from specify_cli.bundler.services.installer import install_bundle - from specify_cli.bundler.services.resolver import resolve_install_plan - from tests.bundler_helpers import FakeInstaller - - manifest = BundleManifest.from_dict(valid_manifest_dict()) - plan = resolve_install_plan( - manifest, speckit_version="0.11.2", active_integration="copilot" - ) - install_bundle(project, plan, FakeInstaller(), manifest=manifest) - - def boom(self, project_root, component): - raise OSError("workflow registry unreadable") - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom) - result = runner.invoke(app, ["bundle", "remove", "demo-bundle"]) - - assert result.exit_code != 0 - assert result.output.strip() != "" - assert result.exception is None or isinstance(result.exception, SystemExit) - assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"} - - -def test_fail_writes_error_to_stderr_not_stdout(capsys): - """_fail must write to stderr, not stdout: every bundle command routes errors - through it, and under --json the error would otherwise corrupt the JSON payload - that consumers read from stdout.""" - import typer - - from specify_cli.commands.bundle import _fail - - with pytest.raises(typer.Exit): - _fail("something broke") - captured = capsys.readouterr() - assert "something broke" in captured.err - assert "something broke" not in captured.out - - -def test_search_works_without_a_project(tmp_path: Path, monkeypatch): - # Discovery commands fall back to the built-in/user catalog stack and must - # not require a Spec Kit project (matches README/quickstart examples). - monkeypatch.chdir(tmp_path) # no .specify/ - result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) - assert result.exit_code == 0, result.output - assert result.output.strip().startswith("[") - - -def test_search_escapes_catalog_markup(project: Path): - entry = _configure_markup_catalog(project) - - result = runner.invoke(app, ["bundle", "search", "--offline"]) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - entry["id"], - entry["name"], - entry["version"], - entry["role"], - entry["description"], - MARKUP_SOURCE_ID, - ): - assert value in output - - -def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch): - monkeypatch.chdir(tmp_path) # no .specify/ - result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"]) - # Reaches catalog resolution (not the project gate) and reports a clean miss. - assert result.exit_code == 1 - assert "Spec Kit project" not in result.output - - -def test_catalog_list_shows_builtin_defaults(project: Path): - result = runner.invoke(app, ["bundle", "catalog", "list"]) - assert result.exit_code == 0 - assert "default" in result.output - assert "community" in result.output - assert "built-in default stack" in result.output - - -def test_catalog_add_and_remove(project: Path): - catalog = project / "local-catalog.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] - ) - assert added.exit_code == 0, added.output - - listed = runner.invoke(app, ["bundle", "catalog", "list"]) - assert "local" in listed.output - - removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"]) - assert removed.exit_code == 0 - - -def test_catalog_remove_builtin_is_refused(project: Path): - result = runner.invoke(app, ["bundle", "catalog", "remove", "default"]) - assert result.exit_code == 1 - assert "built-in" in result.output - - -# Every ``bundle`` error path funnels through ``_fail(str(exc))``, and the -# BundlerError messages interpolate untrusted data -- including the command's -# own argument. An unbalanced closer used to raise MarkupError instead of the -# error, leaving the user with a traceback and no message at all. -@pytest.mark.parametrize( - "argv, expected", - [ - ( - ["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"], - "ssh://ex[/red]ample.com/c.json", - ), - (["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"), - (["bundle", "update", "no[/red]such"], "no[/red]such"), - (["bundle", "remove", "no[/red]such"], "no[/red]such"), - ], -) -def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str): - result = runner.invoke(app, argv) - - assert result.exit_code == 1 - # A MarkupError would surface here as an exception rather than a clean exit. - assert isinstance(result.exception, SystemExit) - assert expected in strip_ansi(result.output) - - -def test_validate_reports_invalid_manifest(project: Path): - data = valid_manifest_dict() - del data["bundle"]["license"] - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - result = runner.invoke(app, ["bundle", "validate"]) - assert result.exit_code == 1 - assert "license" in result.output - - -def test_validate_accepts_valid_manifest(project: Path): - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - # Offline mode does not fail on references it cannot verify (synthetic ids - # here); they surface as warnings while structure is confirmed valid. - result = runner.invoke(app, ["bundle", "validate", "--offline"]) - assert result.exit_code == 0, result.output - assert "valid" in result.output - - -def test_validate_escapes_manifest_markup_in_errors(project: Path): - data = valid_manifest_dict() - # An invalid constraint is echoed back inside the validation error. - data["requires"] = {"speckit_version": ">=1.0[/bold]"} - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - - result = runner.invoke(app, ["bundle", "validate", "--offline"]) - - assert result.exit_code == 1 - assert isinstance(result.exception, SystemExit) - assert ">=1.0[/bold]" in strip_ansi(result.output) - - -def test_validate_escapes_manifest_markup_in_warnings(project: Path): - data = valid_manifest_dict() - # Step ids are not charset-validated, and the unresolved-reference warning - # echoes them -- so an otherwise *valid* manifest crashed just as readily as - # an invalid one, on the success path. - data["provides"]["steps"] = [{"id": "step[/bold]a"}] - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - - result = runner.invoke(app, ["bundle", "validate", "--offline"]) - - assert result.exit_code == 0, repr(result.exception) - assert "step[/bold]a" in strip_ansi(result.output) - - -def test_validate_rejects_broken_reference(project: Path): - # Synthetic component ids resolve to nothing in any catalog → hard failure. - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "validate"]) - assert result.exit_code == 1 - assert "preset-a" in result.output or "ext-a" in result.output - - -def test_validate_accepts_bundled_reference(project: Path): - data = valid_manifest_dict() - data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]} - (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - result = runner.invoke(app, ["bundle", "validate"]) - assert result.exit_code == 0, result.output - assert "valid" in result.output - - -def test_build_produces_artifact(project: Path): - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - (project / "README.md").write_text("# Demo", encoding="utf-8") - result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) - assert result.exit_code == 0, result.output - artifacts = list((project / "dist").glob("*.zip")) - assert len(artifacts) == 1 - - -def test_build_escapes_markup_in_output_path(project: Path): - """The build success line echoes a caller-supplied ``--output`` path. - - Brackets are legal in a directory name on both POSIX and Windows, so the - artifact is built and *then* misreported: ``[bold]`` is consumed as a style - tag, and the success line names a path that does not exist on disk. - - A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a - path separator on Windows, so this uses the silent-swallow form to keep the - fixture portable. - """ - (project / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - (project / "README.md").write_text("# Demo", encoding="utf-8") - out_dir = project / "dist[bold]out" - - result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)]) - - assert result.exit_code == 0, repr(result.exception) - assert list(out_dir.glob("*.zip")), "the artifact should still be built" - # Join across Rich's wrap points: the success line prints an absolute path, - # so the console folds it mid-token whenever the temp directory is long - # enough, which is a property of the runner's path, not of the escaping. - assert "dist[bold]out" in "".join(strip_ansi(result.output).split()), ( - "the reported path must match the directory actually written" - ) - - -def test_list_escapes_markup_in_records(project: Path): - """``bundle list`` renders record fields that are never charset-validated. - - ``InstalledBundleRecord.from_dict`` accepts any non-empty string for - ``bundle_id``/``version`` and any string for ``installed_at``, so a records - file that *loads cleanly* could still crash the command that displays it. - """ - (project / ".specify" / "bundle-records.json").write_text( - json.dumps( - { - "schema_version": "1.0", - "bundles": [ - { - "bundle_id": "demo[/red]id", - "version": "1.0.0[/bold]", - "installed_at": "2026-01-01T00:00:00Z[/dim]", - "contributed_components": [], - } - ], - } - ), - encoding="utf-8", - ) - - result = runner.invoke(app, ["bundle", "list"]) - - assert result.exit_code == 0, repr(result.exception) - output = strip_ansi(result.output) - assert "demo[/red]id" in output - assert "1.0.0[/bold]" in output - assert "2026-01-01T00:00:00Z[/dim]" in output - - -def _mock_manifest_download(monkeypatch, source_path: Path) -> None: - """Mock the HTTPS manifest fetch to return a locally-authored manifest. - - Catalog ``download_url``s are HTTPS-only, so ``info`` tests can no longer - point one at a local file. Patch ``_download_manifest`` to return the - manifest parsed from *source_path* (a bundle.yml or a .zip artifact), - exercising ``info``'s expansion without a network call. - """ - from specify_cli.commands.bundle import _local_manifest_source - - monkeypatch.setattr( - "specify_cli.commands.bundle._download_manifest", - lambda resolved, *, offline: _local_manifest_source(str(source_path)), - ) - - -def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: - return valid_manifest_dict( - provides={"workflows": [{"id": workflow_id, "version": version}]} - ) - - -@pytest.mark.parametrize( - ("command", "bundle_id", "extension_id"), - [("install", "bugfix", "bug"), ("add", "assess", "assess")], -) -def test_local_firstparty_bundle_installs_bundled_components_offline( - project: Path, command: str, bundle_id: str, extension_id: str -): - bundle_dir = REPO_ROOT / "bundles" / bundle_id - - result = runner.invoke( - app, ["bundle", command, str(bundle_dir), "--offline"] - ) - - assert result.exit_code == 0, result.output - assert ( - project / ".specify" / "extensions" / extension_id / "extension.yml" - ).is_file() - assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() - registry = json.loads( - (project / ".specify" / "workflows" / "workflow-registry.json").read_text( - encoding="utf-8" - ) - ) - assert registry["workflows"][bundle_id]["version"] == "1.0.0" - - -@pytest.mark.parametrize( - ("bundle_id", "extension_id"), - [("bugfix", "bug"), ("assess", "assess")], -) -def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( - tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str -): - """``bundle add `` from an empty directory resolves ``builtin://default``. - - The command fetches the first-party catalog and bundle manifest over the - network (both mocked here), initializes a new Spec Kit project, and installs - the bundled extension and workflow without further network access. - """ - project = tmp_path / "fresh" - project.mkdir() - monkeypatch.chdir(project) - - catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() - manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() - expected_manifest_url = ( - "https://raw.githubusercontent.com/github/spec-kit/main/" - f"bundles/{bundle_id}/bundle.yml" - ) - captured_urls: list[str] = [] - - def fake_open_url( - url: str, - timeout: int | None = None, - extra_headers: dict[str, str] | None = None, - redirect_validator=None, - ): - captured_urls.append(url) - if url == FIRSTPARTY_CATALOG_URL: - return FakeBundleResponse(catalog_bytes, url=url) - if url == expected_manifest_url: - return FakeBundleResponse(manifest_bytes, url=url) - raise AssertionError( - f"Unexpected network request in by-ID bundle test: {url}" - ) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke( - app, ["bundle", "add", bundle_id, "--integration", "copilot"] - ) - - assert result.exit_code == 0, result.output - assert "No Spec Kit project here" in result.output - assert (project / ".specify").is_dir() - assert ( - project / ".specify" / "extensions" / extension_id / "extension.yml" - ).is_file() - assert ( - project / ".specify" / "workflows" / bundle_id / "workflow.yml" - ).is_file() - registry = json.loads( - (project / ".specify" / "workflows" / "workflow-registry.json").read_text( - encoding="utf-8" - ) - ) - assert registry["workflows"][bundle_id]["version"] == "1.0.0" - assert FIRSTPARTY_CATALOG_URL in captured_urls - assert expected_manifest_url in captured_urls - - -def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): - bundle_dir = project / "mismatched-workflow-pin" - (bundle_dir / "bundle.yml").parent.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), encoding="utf-8" - ) - - result = runner.invoke( - app, ["bundle", "install", str(bundle_dir), "--offline"] - ) - - assert result.exit_code == 1 - assert "pinned to version 9.9.9" in result.output - assert not (project / ".specify" / "workflows" / "bugfix").exists() - - -def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): - bundle_dir = project / "unbundled-workflow" - (bundle_dir / "bundle.yml").parent.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" - ) - - result = runner.invoke( - app, ["bundle", "install", str(bundle_dir), "--offline"] - ) - - assert result.exit_code == 1 - assert "network access is disabled" in " ".join(result.output.lower().split()) - - -def test_info_expands_full_component_set(project: Path, monkeypatch): - bundle_dir = project / "src-bundle" - bundle_dir.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - catalog = project / "local-catalog.json" - entry = catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - ) - write_catalog_file(catalog, {"demo-bundle": entry}) - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] - ) - assert added.exit_code == 0, added.output - _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") - - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - components = {(c["kind"], c["id"]): c for c in payload["components"]} - assert ("extensions", "ext-a") in components - preset = components[("presets", "preset-a")] - assert preset["version"] == "2.0.0" - assert preset["priority"] == 10 - assert preset["strategy"] == "append" - assert payload["trust"] == "verified" - - text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) - assert "preset-a v2.0.0" in text.output - assert "Trust" in text.output - - -def test_info_escapes_catalog_markup(project: Path, monkeypatch): - entry = _configure_markup_catalog(project) - bundle_dir = project / "markup-bundle" - bundle_dir.mkdir() - manifest_data = valid_manifest_dict() - manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID - manifest_data["integration"] = { - "id": "[conceal]markup-integration[/conceal]" - } - manifest_path = bundle_dir / "bundle.yml" - manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") - _mock_manifest_download(monkeypatch, manifest_path) - monkeypatch.setattr( - "specify_cli.commands.bundle._manifest_component_view", - lambda manifest: [ - { - "kind": "extensions", - "id": "[reverse]markup-component[/reverse]", - "version": "[strike]2.0.0[/strike]", - } - ], - ) - monkeypatch.setattr( - "specify_cli.commands.bundle._bundle_overlaps", - lambda project_root, manifest, *, offline: [ - "[blink]markup-overlap[/blink]" - ], - ) - - result = runner.invoke( - app, - ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], - ) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - entry["id"], - entry["name"], - entry["version"], - entry["role"], - entry["description"], - entry["author"], - entry["license"], - entry["requires"]["speckit_version"], - MARKUP_SOURCE_ID, - "[conceal]markup-integration[/conceal]", - "[reverse]markup-component[/reverse]", - "[strike]2.0.0[/strike]", - "[blink]markup-overlap[/blink]", - ): - assert value in output - - -def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch): - markup_count = "[bold]markup-count[/bold]" - _configure_markup_catalog( - project, - provides={"extensions": markup_count}, - ) - bundle_dir = project / "markup-bundle" - bundle_dir.mkdir() - manifest_data = valid_manifest_dict(provides={}) - manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID - manifest_path = bundle_dir / "bundle.yml" - manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") - _mock_manifest_download(monkeypatch, manifest_path) - - result = runner.invoke( - app, - ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], - ) - - assert result.exit_code == 0, result.output - assert markup_count in strip_ansi(result.output) - - -def test_info_expands_discovery_only_bundle(project: Path, monkeypatch): - # Discovery-only bundles must still be fully inspectable via `info`; - # only `install` is refused for them. - bundle_dir = project / "disc-bundle" - bundle_dir.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - catalog = project / "disc-catalog.json" - entry = catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - ) - write_catalog_file(catalog, {"demo-bundle": entry}) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "disc", "url": str(catalog), "priority": 1, - "install_policy": "discovery-only"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - components = {(c["kind"], c["id"]) for c in payload["components"]} - assert ("extensions", "ext-a") in components - - -def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch): - # A .zip artifact is extracted to read bundle.yml; info expands it. (The - # download itself is HTTPS-only now and mocked here — see contract note.) - bundle_dir = project / "zip-src" - bundle_dir.mkdir() - (bundle_dir / "bundle.yml").write_text( - yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" - ) - (bundle_dir / "README.md").write_text("# Demo", encoding="utf-8") - artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path - catalog = project / "zip-catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - )}, - ) - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] - ) - assert added.exit_code == 0, added.output - _mock_manifest_download(monkeypatch, artifact) - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"]) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - components = {(c["kind"], c["id"]) for c in payload["components"]} - assert ("extensions", "ext-a") in components - - -def test_install_refuses_discovery_only_source(project: Path, monkeypatch): - # Point a discovery-only catalog at a local payload containing the bundle. - catalog = project / "disc.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "disc", "url": str(catalog), "priority": 1, - "install_policy": "discovery-only"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "install", "demo", "--offline"]) - assert result.exit_code == 1 - assert "discovery-only" in result.output - - -def test_update_refuses_discovery_only_source(project: Path): - # An installed bundle whose only resolvable source is discovery-only must - # not be updatable from there (FR-025), mirroring the install policy gate. - from specify_cli.bundler.models.manifest import ComponentRef - from specify_cli.bundler.models.records import ( - InstalledBundleRecord, - save_records, - ) - - save_records( - project, - [ - InstalledBundleRecord.create( - "demo", - "1.0.0", - [ComponentRef(kind="extensions", id="ext-a", version=None)], - ) - ], - ) - - catalog = project / "disc.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "disc", "url": str(catalog), "priority": 1, - "install_policy": "discovery-only"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - - result = runner.invoke(app, ["bundle", "update", "demo", "--offline"]) - assert result.exit_code == 1 - assert "discovery-only" in result.output - - -def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path): - # `info` must expand the real component set; if the manifest can't be - # resolved (here: --offline against an https download_url), it should error - # and exit non-zero rather than silently degrading to `provides` counts. - catalog = project / "remote-catalog.json" - entry = catalog_entry_dict( - "demo-bundle", download_url="https://example.com/demo-bundle.zip" - ) - write_catalog_file(catalog, {"demo-bundle": entry}) - added = runner.invoke( - app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"] - ) - assert added.exit_code == 0, added.output - - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) - assert result.exit_code == 1 - assert "Network access disabled" in result.output - - -def test_search_json_offline(project: Path): - catalog = project / "c.json" - write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) - config = { - "schema_version": "1.0", - "catalogs": [ - # Priority 0 wins over the built-in first-party catalog so the demo - # entry is resolved from this project catalog, while the offline - # packaged first-party catalog (bugfix / assess) still appears in - # search results alongside it. - {"id": "c", "url": str(catalog), "priority": 0, - "install_policy": "install-allowed"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) - assert result.exit_code == 0 - payload = json.loads(result.output) - by_id = {entry["id"]: entry for entry in payload} - assert "demo" in by_id - # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). - assert by_id["demo"]["verified"] is True - assert by_id["demo"]["trust"] == "verified" - - -def test_search_text_shows_trust(project: Path): - catalog = project / "c.json" - write_catalog_file( - catalog, - { - "verified-one": catalog_entry_dict("verified-one", verified=True), - "community-one": catalog_entry_dict("community-one", verified=False), - }, - ) - config = { - "schema_version": "1.0", - "catalogs": [ - {"id": "c", "url": str(catalog), "priority": 1, - "install_policy": "install-allowed"} - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - result = runner.invoke(app, ["bundle", "search", "--offline"]) - assert result.exit_code == 0, result.output - assert "verified" in result.output - assert "community" in result.output - - -def test_install_integration_override_cannot_bypass_clash_guard(project: Path): - # An initialized project's recorded active integration is authoritative: - # passing --integration must not let a differently-pinned bundle install. - import json - - (project / ".specify" / "integration.json").write_text( - json.dumps({"integration": "copilot"}), encoding="utf-8" - ) - bundle_dir = project / "claude-bundle" - bundle_dir.mkdir() - data = valid_manifest_dict(integration={"id": "claude"}) - (bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") - (bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8") - - result = runner.invoke( - app, - ["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"], - ) - assert result.exit_code == 1 - assert "claude" in result.output and "copilot" in result.output - - -# ===== Private GitHub release asset URL resolution ===== - - -class FakeBundleResponse(io.BytesIO): - """Minimal context-manager response stub for open_url fakes.""" - - def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"): - super().__init__(data) - self._url = url - - def geturl(self) -> str: - return self._url - - -def _make_catalog_config(catalog_path: Path, project: Path) -> None: - """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. - - Uses priority 0 so the test catalog wins over the built-in first-party - ``builtin://default`` catalog and the command under test does not need to - fetch the repository catalog from the network. - """ - config = { - "schema_version": "1.0", - "catalogs": [ - { - "id": "test", - "url": str(catalog_path), - "priority": 0, - "install_policy": "install-allowed", - } - ], - } - (project / ".specify" / "bundle-catalogs.yml").write_text( - yaml.safe_dump(config), encoding="utf-8" - ) - - -def test_bundle_info_resolves_github_browser_release_url(project: Path): - """bundle info resolves a private-repo browser release URL via the GitHub API.""" - browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml" - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" - - captured = [] - manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "releases/tags/" in url: - # GitHub API release-tags lookup — return asset list - return FakeBundleResponse( - json.dumps({ - "assets": [{"name": "bundle.yml", "url": api_asset_url}] - }).encode(), - url=url, - ) - # Actual asset download - return FakeBundleResponse(manifest_yaml, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # The browser release URL must have been resolved via the GitHub tags API - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}" - assert "releases/tags/v1.0" in tag_calls[0] - - # The actual download must use the resolved API asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - -def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path): - """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8. - - ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as - UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell - ``Out-File`` output) is rejected. Feeding the downloaded bytes straight - to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the - UTF-16 BOM and silently *accept* the same manifest instead, diverging - from local/zip sources (the zip branch of this same download path was - already fixed for the identical bug). - """ - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" - manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16") - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 1 - output_flat = " ".join(result.output.split()) - assert "could not be read" in output_flat.lower() - - -def test_bundle_info_passes_through_api_asset_url(project: Path): - """bundle info passes a direct GitHub API asset URL through with octet-stream.""" - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77" - - captured = [] - manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - return FakeBundleResponse(manifest_yaml, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # No tags API call — URL was already a REST asset URL - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 0 - - # Exactly one download call to the asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - -def test_bundle_info_resolves_github_browser_release_url_zip(project: Path): - """bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml.""" - import io - import zipfile - - browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip" - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88" - - # Build a minimal in-memory ZIP containing bundle.yml - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) - zip_bytes = buf.getvalue() - - captured = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "releases/tags/" in url: - return FakeBundleResponse( - json.dumps({ - "assets": [{"name": "bundle.zip", "url": api_asset_url}] - }).encode(), - url=url, - ) - return FakeBundleResponse(zip_bytes, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # tags API lookup must have fired - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1 - assert "releases/tags/v2.0" in tag_calls[0] - - # Asset download uses the resolved API URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - # Manifest was successfully parsed from the ZIP - payload = json.loads(result.output) - assert payload["id"] == "demo-bundle" - - -def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path): - """bundle info correctly handles a direct API asset URL that serves ZIP bytes.""" - import io - import zipfile - - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55" - - # Build a minimal in-memory ZIP containing bundle.yml - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) - zip_bytes = buf.getvalue() - - captured = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - return FakeBundleResponse(zip_bytes, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # No tags API call — URL was already a REST asset URL - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 0 - - # Download used octet-stream header - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - # ZIP bytes were detected by magic and bundle.yml extracted correctly - payload = json.loads(result.output) - assert payload["id"] == "demo-bundle" - - -def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors(project: Path): - """When the GitHub tags API lookup finds no matching asset, fall back to the - original browser URL and surface a meaningful error (not a raw traceback).""" - browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml" - - captured = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "releases/tags/" in url: - # Tags API responds but the asset list doesn't include our file - return FakeBundleResponse( - json.dumps({"assets": []}).encode(), - url=url, - ) - # Fallback download: GitHub serves HTML (SSO redirect) instead of YAML - return FakeBundleResponse(b"SSO login required", url=url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - # Must exit non-zero — the HTML body is not a valid bundle manifest - assert result.exit_code == 1 - - # The tags API lookup must have fired - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1 - - # The fallback download should use the original browser URL (no octet-stream) - fallback_calls = [(url, h) for url, h in captured if url == browser_url] - assert len(fallback_calls) == 1 - assert fallback_calls[0][1] is None # no Accept header on the original URL - - # Error output must be actionable (not a raw traceback) - assert "Error:" in result.output - - -def test_bundle_info_resolves_ghes_browser_release_url(project: Path): - """bundle info resolves a GHES private-repo browser release URL via /api/v3.""" - ghes_host = "ghes.example" - browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml" - api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42" - - captured = [] - manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured.append((url, extra_headers)) - if "/api/v3/repos/" in url and "releases/tags/" in url: - return FakeBundleResponse( - json.dumps({ - "assets": [{"name": "bundle.yml", "url": api_asset_url}] - }).encode(), - url=url, - ) - return FakeBundleResponse(manifest_yaml, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ - patch("specify_cli.authentication.http.github_provider_hosts", return_value=(ghes_host,)): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - assert result.exit_code == 0, result.output - - # The GHES /api/v3 tags lookup must have fired - tag_calls = [url for url, _ in captured if "releases/tags/" in url] - assert len(tag_calls) == 1 - assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0] - - # Asset download must use the resolved GHES API URL with octet-stream - asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] - assert len(asset_calls) == 1 - assert asset_calls[0][0] == api_asset_url - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - payload = json.loads(result.output) - assert payload["id"] == "demo-bundle" - - -def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch): - """Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES.""" - # Monkeypatch to a small limit so the test is fast and low-memory. - monkeypatch.setattr( - "specify_cli.commands.bundle.MAX_DOWNLOAD_BYTES", 100 - ) - - api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - # Return a response that exceeds 100 bytes. - return FakeBundleResponse(b"x" * 200, url=api_asset_url) - - catalog = project / "catalog.json" - write_catalog_file( - catalog, - {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, - ) - _make_catalog_config(catalog, project) - - with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) - - # Must fail with a size-limit error, not an unhandled traceback. - assert result.exit_code == 1 - # Rich may wrap the message across lines; normalise whitespace before checking. - output_flat = " ".join(result.output.split()) - assert "exceeds maximum size of 100 bytes" in output_flat diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index c32b059d53..dbc053ff48 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -11,7 +11,7 @@ import yaml -from specify_cli.bundler.models.catalog import ( +from specify_cli.bundles.catalogs import ( BUILTIN_DEFAULT_STACK, CatalogSource, InstallPolicy, @@ -21,7 +21,7 @@ ) from specify_cli.bundler import BundlerError import pytest -from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project +from tests.specify_cli.bundles.helpers import catalog_entry_dict, catalog_payload, make_project def test_non_integer_source_priority_raises_actionable_error(): @@ -243,7 +243,7 @@ def test_wheel_packages_firstparty_bundle_catalog(): def test_catalog_entry_rejects_string_tags(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["tags"] = "not-a-list" @@ -252,7 +252,7 @@ def test_catalog_entry_rejects_string_tags(): def test_catalog_entry_rejects_non_string_tag_members(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["tags"] = ["valid", 1] @@ -261,7 +261,7 @@ def test_catalog_entry_rejects_non_string_tag_members(): def test_catalog_entry_rejects_non_boolean_verified(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["verified"] = "false" # truthy string must not mark the entry verified @@ -305,7 +305,7 @@ def test_load_payload_rejects_missing_entry_id(): def test_catalog_entry_rejects_non_mapping_requires(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["requires"] = "speckit>=0.1" @@ -314,7 +314,7 @@ def test_catalog_entry_rejects_non_mapping_requires(): def test_catalog_entry_rejects_non_mapping_provides(): - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data["provides"] = "extensions" @@ -345,7 +345,7 @@ def test_catalog_entry_rejects_falsy_non_mapping(field, bad): # `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the # isinstance guard, silently accepting a corrupt entry; only absent/None # means "not present". Mirrors the manifest requires/provides guard. - from specify_cli.bundler.models.catalog import CatalogEntry + from specify_cli.bundles.catalogs import CatalogEntry data = catalog_entry_dict("demo") data[field] = bad diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py index 4784bdf462..edad666ebb 100644 --- a/tests/contract/test_manifest_schema.py +++ b/tests/contract/test_manifest_schema.py @@ -8,8 +8,8 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import BundleManifest -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from tests.specify_cli.bundles.helpers import valid_manifest_dict def test_valid_manifest_has_no_structural_errors(): diff --git a/tests/integration/test_bundler_init_install.py b/tests/integration/test_bundler_init_install.py deleted file mode 100644 index a13def5ff8..0000000000 --- a/tests/integration/test_bundler_init_install.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Install-time initialization and integration precedence (T049, T050). - -``specify bundle install`` into an uninitialized directory must scaffold a Spec -Kit project first (FR-012), choosing the integration by precedence (FR-013): -explicit ``--integration`` override → bundle-declared integration → default. -The end-to-end test runs fully offline against bundled assets. -""" -from __future__ import annotations - -import json -import os -from pathlib import Path - -import yaml -from typer.testing import CliRunner - -from specify_cli import app -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.commands.bundle import _resolve_init_integration -from specify_cli.bundler.services.packager import build_bundle -from tests.bundler_helpers import valid_manifest_dict - -runner = CliRunner() - - -def _manifest(**overrides): - data = valid_manifest_dict(**overrides) - return BundleManifest.from_dict(data) - - -def test_precedence_override_wins(): - manifest = _manifest(integration={"id": "claude"}) - assert _resolve_init_integration("gemini", manifest) == "gemini" - - -def test_precedence_bundle_declared_when_no_override(): - manifest = _manifest(integration={"id": "claude"}) - assert _resolve_init_integration(None, manifest) == "claude" - - -def test_precedence_default_when_unspecified(): - manifest = _manifest() - assert _resolve_init_integration(None, manifest) == "copilot" - assert _resolve_init_integration(None, None) == "copilot" - - -def test_precedence_default_honors_env_var(monkeypatch): - monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini") - # With no override and no bundle-declared integration, the env-var default - # applies instead of the hardcoded "copilot". - assert _resolve_init_integration(None, None) == "gemini" - assert _resolve_init_integration(None, _manifest()) == "gemini" - # Explicit override and bundle-declared integration still take precedence. - assert _resolve_init_integration("claude", None) == "claude" - assert ( - _resolve_init_integration(None, _manifest(integration={"id": "claude"})) - == "claude" - ) - - -def _build_mini(tmp_path: Path) -> Path: - bundle = tmp_path / "mini" - bundle.mkdir() - (bundle / "bundle.yml").write_text( - yaml.safe_dump( - { - "schema_version": "1.0", - "bundle": { - "id": "mini", - "name": "Mini", - "version": "1.0.0", - "role": "developer", - "description": "minimal", - "author": "tests", - "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}, - } - ), - encoding="utf-8", - ) - (bundle / "README.md").write_text("# Mini\n", encoding="utf-8") - return build_bundle(bundle).artifact_path - - -def test_install_initializes_uninitialized_project(tmp_path: Path): - project = tmp_path / "proj" - project.mkdir() - artifact = _build_mini(tmp_path) - - previous = Path.cwd() - os.chdir(project) - try: - result = runner.invoke( - app, ["bundle", "install", str(artifact), "--offline"] - ) - assert result.exit_code == 0, result.output - finally: - os.chdir(previous) - - assert (project / ".specify").is_dir() - marker = project / ".specify" / "integration.json" - assert marker.exists() - data = json.loads(marker.read_text(encoding="utf-8")) - assert "copilot" in json.dumps(data) 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..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/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py index 587bf61add..f69b9cde50 100644 --- a/tests/integrations/test_integration_amp.py +++ b/tests/integrations/test_integration_amp.py @@ -64,6 +64,18 @@ def test_build_exec_args_omits_model_flag(self): assert "-m" not in args assert "gpt-5" not in args + def test_build_exec_args_accepts_project_root(self, tmp_path): + """Workflow dispatch may provide a project root to every integration.""" + integration = get_integration(self.KEY) + + args = integration.build_exec_args( + "check the project", + output_json=False, + project_root=tmp_path, + ) + + assert args == ["amp", "--execute", "check the project"] + def test_build_exec_args_applies_extra_args_before_execute(self, monkeypatch): """Operator-injected flags precede `--execute` so they stay global. 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/artifacts/conftest.py b/tests/specify_cli/artifacts/conftest.py new file mode 100644 index 0000000000..48a493e152 --- /dev/null +++ b/tests/specify_cli/artifacts/conftest.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def spec_kit_project(tmp_path: Path) -> Path: + """Create a minimal but valid Spec Kit project layout.""" + root = tmp_path / "proj" + root.mkdir() + (root / ".specify").mkdir() + (root / ".specify" / "presets").mkdir() + (root / ".specify" / "extensions").mkdir() + (root / ".specify" / "templates").mkdir() + return root + + +@pytest.fixture +def non_project(tmp_path: Path) -> Path: + """Create a directory that intentionally lacks ``.specify/``.""" + root = tmp_path / "not-proj" + root.mkdir() + return root diff --git a/tests/specify_cli/artifacts/helpers.py b/tests/specify_cli/artifacts/helpers.py new file mode 100644 index 0000000000..4f6bc13814 --- /dev/null +++ b/tests/specify_cli/artifacts/helpers.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +from specify_cli.extensions import ExtensionRegistry + +ERROR_REGEX = re.compile( + r"^(unknown artifact |unknown contribution |ambiguous artifact |" + r"artifact resolution failed|not a Spec Kit project)" +) + + +def install_extension_with_hooks( + project_root: Path, + extension_id: str, + hooks: dict, + *, + manifest_id: str | None = None, + priority: int = 10, + enabled: bool = True, +) -> Path: + """Create a registered extension whose manifest declares hook contributions.""" + ext_dir = project_root / ".specify" / "extensions" / extension_id + ext_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "schema_version": "1.0", + "extension": { + "id": manifest_id or extension_id, + "name": manifest_id or extension_id, + "version": "1.0.0", + "description": "Test extension", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": {}, + "hooks": hooks, + } + (ext_dir / "extension.yml").write_text( + yaml.safe_dump(manifest), encoding="utf-8" + ) + ExtensionRegistry(project_root / ".specify" / "extensions").add( + extension_id, + {"version": "1.0.0", "enabled": enabled, "priority": priority}, + ) + return ext_dir + + +def write_hook_binding( + project_root: Path, + event_name: str, + entries: list[dict], +) -> None: + """Write concrete hook bindings in the runtime extension configuration.""" + config_path = project_root / ".specify" / "extensions.yml" + config_path.write_text( + yaml.safe_dump( + { + "installed": [], + "settings": {"auto_execute_hooks": True}, + "hooks": {event_name: entries}, + } + ), + encoding="utf-8", + ) diff --git a/tests/test_artifact_command.py b/tests/specify_cli/artifacts/test_catalog.py similarity index 80% rename from tests/test_artifact_command.py rename to tests/specify_cli/artifacts/test_catalog.py index b574dad7b9..c3fa25eab8 100644 --- a/tests/test_artifact_command.py +++ b/tests/specify_cli/artifacts/test_catalog.py @@ -1,9 +1,4 @@ -"""Unit and contract tests for the `specify artifact` command group. - -Covers the pure-logic layer (:class:`ArtifactCatalog`) plus the CLI wiring -(``specify artifact list``, ``specify artifact info``) exercised through -Typer's ``CliRunner``. -""" +"""Domain and contract tests for artifact inventory and resolution.""" from __future__ import annotations @@ -11,14 +6,11 @@ import os import re import shutil -from datetime import date from pathlib import Path import pytest import yaml -from typer.testing import CliRunner -from specify_cli import app from specify_cli.artifacts import ( AmbiguousArtifactError, Artifact, @@ -34,38 +26,13 @@ from specify_cli.extensions import CORE_COMMAND_NAMES, ExtensionRegistry from specify_cli.presets import PresetRegistry, PresetResolver from tests.conftest import install_preset - -ERROR_REGEX = re.compile( - r"^(unknown artifact |unknown contribution |ambiguous artifact |" - r"artifact resolution failed|not a Spec Kit project)" +from tests.specify_cli.artifacts.helpers import ( + ERROR_REGEX, + install_extension_with_hooks, + write_hook_binding, ) -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def spec_kit_project(tmp_path: Path) -> Path: - """Create a minimal but valid Spec Kit project layout.""" - root = tmp_path / "proj" - root.mkdir() - (root / ".specify").mkdir() - (root / ".specify" / "presets").mkdir() - (root / ".specify" / "extensions").mkdir() - (root / ".specify" / "templates").mkdir() - return root - - -@pytest.fixture -def non_project(tmp_path: Path) -> Path: - """A directory that intentionally lacks ``.specify/``.""" - root = tmp_path / "not-proj" - root.mkdir() - return root - - # --------------------------------------------------------------------------- # Contract tests — matching artifact-list.schema.json # --------------------------------------------------------------------------- @@ -924,80 +891,11 @@ def test_no_skills_in_list(self, spec_kit_project: Path): # --------------------------------------------------------------------------- -# CLI wiring — Typer CliRunner +# Manifest contribution lookup # --------------------------------------------------------------------------- -class TestCLI: - def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list"]) - assert result.exit_code == 2 - assert result.stdout == "" - - def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list", "--json"]) - assert result.exit_code == 0, result.stderr - payload = json.loads(result.stdout) - assert isinstance(payload, list) - assert result.stdout.endswith("\n") - - def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list", "--json"]) - assert result.exit_code == 0, result.stderr - payload = json.loads(result.stdout) - assert payload, "expected at least one artifact" - - row = payload[0] - assert set(row.keys()) == {"id", "name", "kind", "description", "stack"} - assert isinstance(row["stack"], list) - - info_result = runner.invoke(app, ["artifact", "info", row["id"], "--json"]) - assert info_result.exit_code == 0, info_result.stderr - info = json.loads(info_result.stdout) - assert row["stack"] == info["stack"] - - def test_lookup_json_cross_references_manifest_contribution( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.chdir(spec_kit_project) - pack = install_preset( - spec_kit_project, - "lookup-pack", - { - "templates": [ - { - "type": "template", - "name": "lookup-template", - "file": "templates/lookup.md", - "description": "Lookup target", - } - ] - }, - ) - (pack / "templates").mkdir() - (pack / "templates" / "lookup.md").write_text("body", encoding="utf-8") - - lookup_id = ArtifactCatalog(spec_kit_project).get_artifact_info( - "template:lookup-template" - )["stack"][0]["lookupId"] - result = CliRunner().invoke( - app, ["artifact", "lookup", lookup_id, "--json"] - ) - - assert result.exit_code == 0, result.stderr - payload = json.loads(result.stdout) - assert payload["id"] == lookup_id - assert payload["contribution"]["description"] == "Lookup target" - assert payload["sourcePath"] == ( - ".specify/presets/lookup-pack/templates/lookup.md" - ) - +class TestContributionInfo: def test_lookup_returns_normalized_preset_declaration( self, spec_kit_project: Path ): @@ -1159,134 +1057,6 @@ def test_lookup_preserves_source_path_through_symlinked_project_root( ".specify/presets/symlinked-project/templates/source.md" ) - def test_lookup_json_rejects_unknown_contribution( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.chdir(spec_kit_project) - lookup_id = "extension:missing:command:speckit.missing.command" - - result = CliRunner().invoke( - app, ["artifact", "lookup", lookup_id, "--json"] - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == { - "error": f"unknown contribution {lookup_id}" - } - - @pytest.mark.parametrize( - "manifest_value", - [ - date(2026, 1, 1), - float("nan"), - float("inf"), - float("-inf"), - "\ud800", - ], - ids=[ - "date", - "nan", - "positive-infinity", - "negative-infinity", - "unpaired-surrogate", - ], - ) - def test_lookup_json_rejects_non_json_manifest_value( - self, - spec_kit_project: Path, - monkeypatch: pytest.MonkeyPatch, - manifest_value: object, - ): - monkeypatch.chdir(spec_kit_project) - install_preset( - spec_kit_project, - "non-json-contribution", - { - "templates": [ - { - "type": "template", - "name": "non-json-contribution", - "extra": manifest_value, - } - ] - }, - ) - - result = CliRunner().invoke( - app, - [ - "artifact", - "lookup", - "preset:non-json-contribution:template:non-json-contribution", - "--json", - ], - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == { - "error": "artifact resolution failed" - } - - @pytest.mark.parametrize( - "lookup_id", - [ - "invalid:source:command:name", - "extension:source:invalid:name", - "extension:source:hook:%FF:command", - "extension:source:hook:event:%ZZ", - ], - ) - def test_lookup_json_rejects_malformed_lookup_id( - self, - spec_kit_project: Path, - monkeypatch: pytest.MonkeyPatch, - lookup_id: str, - ): - monkeypatch.chdir(spec_kit_project) - - result = CliRunner().invoke( - app, ["artifact", "lookup", lookup_id, "--json"] - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == { - "error": f"unknown contribution {lookup_id}" - } - - def test_lookup_requires_json_flag( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.chdir(spec_kit_project) - result = CliRunner().invoke( - app, - [ - "artifact", - "lookup", - "extension:missing:command:speckit.missing.command", - ], - ) - - assert result.exit_code == 2 - assert result.stdout == "" - - def test_lookup_validates_project_before_lookup_id( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.chdir(tmp_path) - result = CliRunner().invoke( - app, - ["artifact", "lookup", "project:_:command:local", "--json"], - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == { - "error": "not a Spec Kit project: no .specify/ directory found" - } - def test_hidden_command_layer_source_path_is_own_pack_file( self, spec_kit_project: Path ): @@ -1367,211 +1137,7 @@ def test_hidden_command_layer_source_path_is_own_pack_file( ".specify/presets/aaa-low-priority-preset/commands/speckit.compliance.plan.md" ) - def test_list_json_stack_source_path_contract( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - preset_pack = install_preset( - spec_kit_project, - "compliance", - { - "commands": [ - { - "name": "speckit.compliance.plan", - "file": "commands/speckit.compliance.plan.md", - "description": "Compliance plan", - } - ] - }, - ) - (preset_pack / "commands").mkdir() - (preset_pack / "commands" / "speckit.compliance.plan.md").write_text( - "---\ndescription: Compliance plan\n---\nbody\n", encoding="utf-8" - ) - PresetRegistry(spec_kit_project / ".specify" / "presets").update( - "compliance", - { - "registered_skills": { - "copilot": ["speckit-compliance-plan"], - } - }, - ) - skill_file = ( - spec_kit_project - / ".github" - / "skills" - / "speckit-compliance-plan" - / "SKILL.md" - ) - skill_file.parent.mkdir(parents=True) - skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") - - extension_dir = spec_kit_project / ".specify" / "extensions" / "quality" - (extension_dir / "templates").mkdir(parents=True) - (extension_dir / "templates" / "checklist.md").write_text( - "---\ndescription: Extension checklist\n---\n", encoding="utf-8" - ) - (extension_dir / "extension.yml").write_text( - yaml.safe_dump( - { - "schema_version": "1.0", - "extension": { - "id": "quality", - "name": "Quality", - "version": "1.0.0", - "description": "test", - "author": "test", - "repository": "https://example.com", - "license": "MIT", - }, - "requires": {"speckit_version": ">=0.2.0"}, - "provides": { - "templates": [ - { - "name": "checklist", - "file": "templates/checklist.md", - "description": "Extension checklist", - } - ] - }, - } - ), - encoding="utf-8", - ) - ExtensionRegistry(spec_kit_project / ".specify" / "extensions").add( - "quality", {"version": "1.0.0", "enabled": True} - ) - - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list", "--json"]) - assert result.exit_code == 0, result.stderr - payload = json.loads(result.stdout) - - non_null_source_paths: set[str] = set() - for row in payload: - for layer in row["stack"]: - assert "sourcePath" in layer - source_path = layer["sourcePath"] - if source_path is None: - continue - assert isinstance(source_path, str) - assert (spec_kit_project / source_path).is_file() - non_null_source_paths.add(source_path) - - assert ".github/skills/speckit-compliance-plan/SKILL.md" in non_null_source_paths - assert ".specify/extensions/quality/templates/checklist.md" in non_null_source_paths - - def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list", "--json"]) - assert ' "id"' in result.stdout # 2-space indent visible - - def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) - assert result.exit_code == 0, result.stderr - payload = json.loads(result.stdout) - assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} - - def test_info_accepts_id_form_on_cli( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - by_bare = runner.invoke(app, ["artifact", "info", "speckit.plan", "--json"]) - by_id = runner.invoke(app, ["artifact", "info", "command:speckit.plan", "--json"]) - assert by_bare.exit_code == 0, by_bare.stderr - assert by_id.exit_code == 0, by_id.stderr - assert json.loads(by_id.stdout) == json.loads(by_bare.stdout) - - def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) - assert result.exit_code == 1 - assert result.stdout == "" - err = json.loads(result.stderr) - assert set(err.keys()) == {"error"} - assert ERROR_REGEX.match(err["error"]) - - def test_info_corrupt_extension_registry_uses_json_error_envelope( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - extensions_dir = spec_kit_project / ".specify" / "extensions" - (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") - monkeypatch.chdir(spec_kit_project) - result = CliRunner().invoke( - app, ["artifact", "info", "speckit.constitution", "--json"] - ) - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == {"error": "artifact resolution failed"} - - def test_list_corrupt_extension_registry_uses_json_error_envelope( - self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch - ): - extensions_dir = spec_kit_project / ".specify" / "extensions" - (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") - monkeypatch.chdir(spec_kit_project) - result = CliRunner().invoke(app, ["artifact", "list", "--json"]) - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == {"error": "artifact resolution failed"} - - def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(non_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list", "--json"]) - assert result.exit_code == 1 - assert result.stdout == "" - err = json.loads(result.stderr) - assert err["error"] == "not a Spec Kit project: no .specify/ directory found" - - def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(non_project) - runner = CliRunner() - for argv in ( - ["artifact", "list", "--json"], - ["artifact", "info", "x", "--json"], - ): - result = runner.invoke(app, argv) - assert result.stdout == "", f"stdout leak for {argv}: {result.stdout!r}" - @pytest.mark.parametrize( - "override", - ("missing-project", "."), - ) - def test_invalid_init_dir_override_uses_json_error_envelope( - self, - non_project: Path, - monkeypatch: pytest.MonkeyPatch, - override: str, - ): - monkeypatch.chdir(non_project) - monkeypatch.setenv("SPECIFY_INIT_DIR", override) - runner = CliRunner() - for argv in ( - ["artifact", "list", "--json"], - ["artifact", "info", "x", "--json"], - ): - result = runner.invoke(app, argv) - assert result.exit_code == 1 - assert result.stdout == "" - assert json.loads(result.stderr) == { - "error": "not a Spec Kit project: no .specify/ directory found" - } - - -class TestUTF8NoBOM: - def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - result = runner.invoke(app, ["artifact", "list", "--json"]) - assert result.exit_code == 0 - # No BOM at start - assert not result.stdout.startswith("\ufeff") # --------------------------------------------------------------------------- @@ -2053,67 +1619,15 @@ def test_falls_back_to_pack_id_without_manifest_file(self, tmp_path: Path): # --------------------------------------------------------------------------- -def _install_extension_with_hooks( - project_root: Path, - extension_id: str, - hooks: dict, - *, - manifest_id: str | None = None, - priority: int = 10, - enabled: bool = True, -) -> Path: - """Create a registered extension whose manifest declares hook contributions.""" - ext_dir = project_root / ".specify" / "extensions" / extension_id - ext_dir.mkdir(parents=True, exist_ok=True) - manifest = { - "schema_version": "1.0", - "extension": { - "id": manifest_id or extension_id, - "name": manifest_id or extension_id, - "version": "1.0.0", - "description": "Test extension", - "author": "test", - "repository": "https://example.com", - "license": "MIT", - }, - "requires": {"speckit_version": ">=0.2.0"}, - "provides": {}, - "hooks": hooks, - } - (ext_dir / "extension.yml").write_text( - yaml.safe_dump(manifest), encoding="utf-8" - ) - ExtensionRegistry(project_root / ".specify" / "extensions").add( - extension_id, - {"version": "1.0.0", "enabled": enabled, "priority": priority}, - ) - return ext_dir - - -def _write_hook_binding( - project_root: Path, - event_name: str, - entries: list[dict], -) -> None: - """Write concrete hook bindings in the runtime extension configuration.""" - config_path = project_root / ".specify" / "extensions.yml" - config_path.write_text( - yaml.safe_dump( - { - "installed": [], - "settings": {"auto_execute_hooks": True}, - "hooks": {event_name: entries}, - } - ), - encoding="utf-8", - ) + + class TestHookInventory: def test_flat_and_stack_listings_include_the_same_hook( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "compliance", hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, @@ -2151,7 +1665,7 @@ def test_flat_and_stack_listings_include_the_same_hook( def test_invalid_unicode_hook_is_omitted_without_hiding_healthy_hooks( self, spec_kit_project: Path, hooks: dict ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "unicode-hooks", hooks=hooks, @@ -2173,7 +1687,7 @@ def test_declared_hook_has_artifact_and_stack_shape( ): from specify_cli.artifacts._identifiers import derive_hook_lookup_id - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "compliance", hooks={ @@ -2239,13 +1753,13 @@ def test_declared_hook_has_artifact_and_stack_shape( def test_hook_lookup_targets_installed_provider_with_shared_manifest_id( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "a-copy", manifest_id="shared-hooks", hooks={"after_plan": [{"command": "unrelated.cmd"}]}, ) - second = _install_extension_with_hooks( + second = install_extension_with_hooks( spec_kit_project, "b-copy", manifest_id="shared-hooks", @@ -2282,7 +1796,7 @@ def test_same_manifest_id_hook_layers_have_distinct_lookup_ids( ("a-copy", "First hook"), ("b-copy", "Second hook"), ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, installed_id, manifest_id="shared-hooks", @@ -2327,17 +1841,17 @@ def test_same_manifest_id_hook_layers_have_distinct_lookup_ids( def test_duplicate_declarations_are_additive_and_priority_sorted( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext-a", hooks={"before_specify": [{"command": "shared.cmd", "priority": 10}]}, ) - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext-b", hooks={"before_specify": [{"command": "shared.cmd", "priority": 3}]}, ) - _write_hook_binding( + write_hook_binding( spec_kit_project, "before_specify", [ @@ -2357,13 +1871,13 @@ def test_duplicate_declarations_are_additive_and_priority_sorted( def test_equal_priorities_preserve_deterministic_resolver_order( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext-a", hooks={"before_specify": [{"command": "shared.cmd", "priority": 5}]}, priority=5, ) - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext-b", hooks={"before_specify": [{"command": "shared.cmd", "priority": 5}]}, @@ -2384,7 +1898,7 @@ def test_equal_priorities_preserve_deterministic_resolver_order( def test_duplicate_declarations_within_extension_use_last_value( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext", hooks={ @@ -2416,7 +1930,7 @@ def test_duplicate_declarations_within_extension_use_last_value( def test_disabled_extension_contributions_are_excluded( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "disabled", hooks={"before_specify": [{"command": "disabled.cmd"}]}, @@ -2430,7 +1944,7 @@ def test_disabled_extension_contributions_are_excluded( def test_malformed_manifest_is_omitted_without_hiding_healthy_hooks( self, spec_kit_project: Path, registered: bool ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "healthy", hooks={"before_specify": [{"command": "speckit.healthy.cmd"}]}, @@ -2459,7 +1973,7 @@ def test_malformed_manifest_is_omitted_without_hiding_healthy_hooks( assert hook_rows[0]["stack"][0]["sourceId"] == "healthy" def test_hooks_never_use_builtin_layer(self, spec_kit_project: Path): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext", hooks={"before_specify": [{"command": "cmd.x"}]}, @@ -2508,12 +2022,12 @@ def test_registration_matches_runtime_binding( binding: dict, expected: bool, ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "compliance", hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, ) - _write_hook_binding(spec_kit_project, "before_specify", [binding]) + write_hook_binding(spec_kit_project, "before_specify", [binding]) rows = ArtifactCatalog(spec_kit_project).list_artifacts_with_stack() row = next(item for item in rows if item["kind"] == "hook") @@ -2524,7 +2038,7 @@ def test_registration_matches_runtime_binding( def test_binding_only_activates_matching_command( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "compliance", hooks={ @@ -2534,7 +2048,7 @@ def test_binding_only_activates_matching_command( ] }, ) - _write_hook_binding( + write_hook_binding( spec_kit_project, "before_specify", [ @@ -2558,12 +2072,12 @@ def test_duplicate_contributors_activate_independently( self, spec_kit_project: Path ): for extension_id in ("ext-a", "ext-b"): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, extension_id, hooks={"before_specify": [{"command": "shared.cmd"}]}, ) - _write_hook_binding( + write_hook_binding( spec_kit_project, "before_specify", [ @@ -2587,7 +2101,7 @@ def test_duplicate_contributors_activate_independently( def test_renamed_installation_uses_manifest_id_for_runtime_activation( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "renamed-installation", manifest_id="runtime-id", @@ -2597,7 +2111,7 @@ def test_renamed_installation_uses_manifest_id_for_runtime_activation( ] }, ) - _write_hook_binding( + write_hook_binding( spec_kit_project, "before_specify", [ @@ -2624,7 +2138,7 @@ def test_renamed_installation_uses_manifest_id_for_runtime_activation( def test_invalid_runtime_config_degrades_to_unregistered( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "compliance", hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, @@ -2640,7 +2154,7 @@ def test_invalid_runtime_config_degrades_to_unregistered( class TestHookInfo: def test_hook_shorthand_round_trips(self, spec_kit_project: Path): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "compliance", hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, @@ -2657,7 +2171,7 @@ def test_hook_shorthand_round_trips(self, spec_kit_project: Path): def test_colon_containing_values_round_trip_through_encoded_id( self, spec_kit_project: Path ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext", hooks={ @@ -2699,7 +2213,7 @@ def test_colon_containing_values_round_trip_through_encoded_id( ) def test_kind_hint_resolves_hook_name(self, spec_kit_project: Path): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext", hooks={"after_plan": [{"command": "cmd.x"}]}, @@ -2716,7 +2230,7 @@ def test_kind_hint_resolves_hook_name(self, spec_kit_project: Path): def test_kind_hint_disambiguates_reserved_event_names( self, spec_kit_project: Path, event_name: str ): - _install_extension_with_hooks( + install_extension_with_hooks( spec_kit_project, "ext", hooks={event_name: [{"command": "cmd.x"}]}, @@ -2742,65 +2256,6 @@ def test_unknown_hook_uses_unknown_artifact_error( ) -class TestHookCli: - def test_list_and_info_json(self, spec_kit_project: Path, monkeypatch): - _install_extension_with_hooks( - spec_kit_project, - "compliance", - hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, - ) - monkeypatch.chdir(spec_kit_project) - runner = CliRunner() - - list_result = runner.invoke(app, ["artifact", "list", "--json"]) - info_result = runner.invoke( - app, - [ - "artifact", - "info", - "hook:before_specify:speckit.compliance.pre-check", - "--json", - ], - ) - - assert list_result.exit_code == 0, list_result.output - assert info_result.exit_code == 0, info_result.output - assert any(row["kind"] == "hook" for row in json.loads(list_result.stdout)) - assert json.loads(info_result.stdout)["kind"] == "hook" - - def test_unknown_hook_json_error_envelope( - self, spec_kit_project: Path, monkeypatch - ): - monkeypatch.chdir(spec_kit_project) - - result = CliRunner().invoke( - app, - ["artifact", "info", "hook:nope:missing.cmd", "--json"], - catch_exceptions=False, - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert ERROR_REGEX.match(json.loads(result.stderr)["error"]) - - @pytest.mark.parametrize( - "identifier", - ["hook:event:bad%escape", "hook:event:%FF"], - ) - def test_malformed_hook_id_json_error_envelope( - self, spec_kit_project: Path, monkeypatch, identifier: str - ): - monkeypatch.chdir(spec_kit_project) - - result = CliRunner().invoke( - app, - ["artifact", "info", identifier, "--json"], - catch_exceptions=False, - ) - - assert result.exit_code == 1 - assert result.stdout == "" - assert ERROR_REGEX.match(json.loads(result.stderr)["error"]) def test_existing_artifact_shapes_do_not_gain_hook_fields( diff --git a/tests/test_artifact_command_parity.py b/tests/specify_cli/artifacts/test_catalog_parity.py similarity index 98% rename from tests/test_artifact_command_parity.py rename to tests/specify_cli/artifacts/test_catalog_parity.py index cdd550eef9..15cdbe008c 100644 --- a/tests/test_artifact_command_parity.py +++ b/tests/specify_cli/artifacts/test_catalog_parity.py @@ -1,4 +1,4 @@ -"""Resolver-parity tests for the `specify artifact` command group. +"""Resolver-parity tests for the artifact catalog domain. Verifies that the artifact output stays consistent with the underlying :class:`~specify_cli.presets.PresetResolver`, including for contributions diff --git a/tests/specify_cli/artifacts/test_command_info.py b/tests/specify_cli/artifacts/test_command_info.py new file mode 100644 index 0000000000..4fb3ddce63 --- /dev/null +++ b/tests/specify_cli/artifacts/test_command_info.py @@ -0,0 +1,152 @@ +"""Tests for ``specify artifact info``.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from tests.specify_cli.artifacts.helpers import ( + ERROR_REGEX, + install_extension_with_hooks, +) + + +class TestCommandInfo: + def test_info_json_shape(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "speckit.constitution", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert set(payload.keys()) == {"id", "name", "kind", "description", "stack"} + + def test_info_accepts_id_form_on_cli( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + by_bare = runner.invoke(app, ["artifact", "info", "speckit.plan", "--json"]) + by_id = runner.invoke(app, ["artifact", "info", "command:speckit.plan", "--json"]) + assert by_bare.exit_code == 0, by_bare.stderr + assert by_id.exit_code == 0, by_id.stderr + assert json.loads(by_id.stdout) == json.loads(by_bare.stdout) + + def test_info_unknown_error_envelope(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "info", "no.such.thing", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert set(err.keys()) == {"error"} + assert ERROR_REGEX.match(err["error"]) + + def test_info_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke( + app, ["artifact", "info", "speckit.constitution", "--json"] + ) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + + def test_stdout_empty_on_error(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(non_project) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.stdout == "", f"stdout leak for {argv}: {result.stdout!r}" + + @pytest.mark.parametrize( + "override", + ("missing-project", "."), + ) + def test_invalid_init_dir_override_uses_json_error_envelope( + self, + non_project: Path, + monkeypatch: pytest.MonkeyPatch, + override: str, + ): + monkeypatch.chdir(non_project) + monkeypatch.setenv("SPECIFY_INIT_DIR", override) + runner = CliRunner() + for argv in ( + ["artifact", "list", "--json"], + ["artifact", "info", "x", "--json"], + ): + result = runner.invoke(app, argv) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "not a Spec Kit project: no .specify/ directory found" + } + + def test_list_and_info_json(self, spec_kit_project: Path, monkeypatch): + install_extension_with_hooks( + spec_kit_project, + "compliance", + hooks={"before_specify": [{"command": "speckit.compliance.pre-check"}]}, + ) + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + + list_result = runner.invoke(app, ["artifact", "list", "--json"]) + info_result = runner.invoke( + app, + [ + "artifact", + "info", + "hook:before_specify:speckit.compliance.pre-check", + "--json", + ], + ) + + assert list_result.exit_code == 0, list_result.output + assert info_result.exit_code == 0, info_result.output + assert any(row["kind"] == "hook" for row in json.loads(list_result.stdout)) + assert json.loads(info_result.stdout)["kind"] == "hook" + + def test_unknown_hook_json_error_envelope( + self, spec_kit_project: Path, monkeypatch + ): + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, + ["artifact", "info", "hook:nope:missing.cmd", "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert ERROR_REGEX.match(json.loads(result.stderr)["error"]) + + @pytest.mark.parametrize( + "identifier", + ["hook:event:bad%escape", "hook:event:%FF"], + ) + def test_malformed_hook_id_json_error_envelope( + self, spec_kit_project: Path, monkeypatch, identifier: str + ): + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, + ["artifact", "info", identifier, "--json"], + catch_exceptions=False, + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert ERROR_REGEX.match(json.loads(result.stderr)["error"]) diff --git a/tests/specify_cli/artifacts/test_command_list.py b/tests/specify_cli/artifacts/test_command_list.py new file mode 100644 index 0000000000..dc77531c5b --- /dev/null +++ b/tests/specify_cli/artifacts/test_command_list.py @@ -0,0 +1,178 @@ +"""Tests for ``specify artifact list``.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ExtensionRegistry +from specify_cli.presets import PresetRegistry +from tests.conftest import install_preset + + +class TestCommandList: + def test_list_requires_json_flag(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list"]) + assert result.exit_code == 2 + assert result.stdout == "" + + def test_list_json_emits_array(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert isinstance(payload, list) + assert result.stdout.endswith("\n") + + def test_list_json_rows_include_stack(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload, "expected at least one artifact" + + row = payload[0] + assert set(row.keys()) == {"id", "name", "kind", "description", "stack"} + assert isinstance(row["stack"], list) + + info_result = runner.invoke(app, ["artifact", "info", row["id"], "--json"]) + assert info_result.exit_code == 0, info_result.stderr + info = json.loads(info_result.stdout) + assert row["stack"] == info["stack"] + + def test_list_json_stack_source_path_contract( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + preset_pack = install_preset( + spec_kit_project, + "compliance", + { + "commands": [ + { + "name": "speckit.compliance.plan", + "file": "commands/speckit.compliance.plan.md", + "description": "Compliance plan", + } + ] + }, + ) + (preset_pack / "commands").mkdir() + (preset_pack / "commands" / "speckit.compliance.plan.md").write_text( + "---\ndescription: Compliance plan\n---\nbody\n", encoding="utf-8" + ) + PresetRegistry(spec_kit_project / ".specify" / "presets").update( + "compliance", + { + "registered_skills": { + "copilot": ["speckit-compliance-plan"], + } + }, + ) + skill_file = ( + spec_kit_project + / ".github" + / "skills" + / "speckit-compliance-plan" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + skill_file.write_text("---\nname: speckit-compliance-plan\n---\n", encoding="utf-8") + + extension_dir = spec_kit_project / ".specify" / "extensions" / "quality" + (extension_dir / "templates").mkdir(parents=True) + (extension_dir / "templates" / "checklist.md").write_text( + "---\ndescription: Extension checklist\n---\n", encoding="utf-8" + ) + (extension_dir / "extension.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "quality", + "name": "Quality", + "version": "1.0.0", + "description": "test", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.2.0"}, + "provides": { + "templates": [ + { + "name": "checklist", + "file": "templates/checklist.md", + "description": "Extension checklist", + } + ] + }, + } + ), + encoding="utf-8", + ) + ExtensionRegistry(spec_kit_project / ".specify" / "extensions").add( + "quality", {"version": "1.0.0", "enabled": True} + ) + + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + + non_null_source_paths: set[str] = set() + for row in payload: + for layer in row["stack"]: + assert "sourcePath" in layer + source_path = layer["sourcePath"] + if source_path is None: + continue + assert isinstance(source_path, str) + assert (spec_kit_project / source_path).is_file() + non_null_source_paths.add(source_path) + + assert ".github/skills/speckit-compliance-plan/SKILL.md" in non_null_source_paths + assert ".specify/extensions/quality/templates/checklist.md" in non_null_source_paths + + def test_list_json_is_pretty_printed(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert ' "id"' in result.stdout # 2-space indent visible + + def test_list_corrupt_extension_registry_uses_json_error_envelope( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + extensions_dir = spec_kit_project / ".specify" / "extensions" + (extensions_dir / ".registry").write_text("{invalid", encoding="utf-8") + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == {"error": "artifact resolution failed"} + + def test_not_a_project_error_envelope(self, non_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(non_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 1 + assert result.stdout == "" + err = json.loads(result.stderr) + assert err["error"] == "not a Spec Kit project: no .specify/ directory found" + + def test_output_is_utf8_without_bom(self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(spec_kit_project) + runner = CliRunner() + result = runner.invoke(app, ["artifact", "list", "--json"]) + assert result.exit_code == 0 + # No BOM at start + assert not result.stdout.startswith("\ufeff") diff --git a/tests/specify_cli/artifacts/test_command_lookup.py b/tests/specify_cli/artifacts/test_command_lookup.py new file mode 100644 index 0000000000..9e995915af --- /dev/null +++ b/tests/specify_cli/artifacts/test_command_lookup.py @@ -0,0 +1,180 @@ +"""Tests for ``specify artifact lookup``.""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.artifacts import ArtifactCatalog +from tests.conftest import install_preset + + +class TestCommandLookup: + def test_lookup_json_cross_references_manifest_contribution( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + pack = install_preset( + spec_kit_project, + "lookup-pack", + { + "templates": [ + { + "type": "template", + "name": "lookup-template", + "file": "templates/lookup.md", + "description": "Lookup target", + } + ] + }, + ) + (pack / "templates").mkdir() + (pack / "templates" / "lookup.md").write_text("body", encoding="utf-8") + + lookup_id = ArtifactCatalog(spec_kit_project).get_artifact_info( + "template:lookup-template" + )["stack"][0]["lookupId"] + result = CliRunner().invoke( + app, ["artifact", "lookup", lookup_id, "--json"] + ) + + assert result.exit_code == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["id"] == lookup_id + assert payload["contribution"]["description"] == "Lookup target" + assert payload["sourcePath"] == ( + ".specify/presets/lookup-pack/templates/lookup.md" + ) + + def test_lookup_json_rejects_unknown_contribution( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + lookup_id = "extension:missing:command:speckit.missing.command" + + result = CliRunner().invoke( + app, ["artifact", "lookup", lookup_id, "--json"] + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": f"unknown contribution {lookup_id}" + } + + @pytest.mark.parametrize( + "manifest_value", + [ + date(2026, 1, 1), + float("nan"), + float("inf"), + float("-inf"), + "\ud800", + ], + ids=[ + "date", + "nan", + "positive-infinity", + "negative-infinity", + "unpaired-surrogate", + ], + ) + def test_lookup_json_rejects_non_json_manifest_value( + self, + spec_kit_project: Path, + monkeypatch: pytest.MonkeyPatch, + manifest_value: object, + ): + monkeypatch.chdir(spec_kit_project) + install_preset( + spec_kit_project, + "non-json-contribution", + { + "templates": [ + { + "type": "template", + "name": "non-json-contribution", + "extra": manifest_value, + } + ] + }, + ) + + result = CliRunner().invoke( + app, + [ + "artifact", + "lookup", + "preset:non-json-contribution:template:non-json-contribution", + "--json", + ], + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "artifact resolution failed" + } + + @pytest.mark.parametrize( + "lookup_id", + [ + "invalid:source:command:name", + "extension:source:invalid:name", + "extension:source:hook:%FF:command", + "extension:source:hook:event:%ZZ", + ], + ) + def test_lookup_json_rejects_malformed_lookup_id( + self, + spec_kit_project: Path, + monkeypatch: pytest.MonkeyPatch, + lookup_id: str, + ): + monkeypatch.chdir(spec_kit_project) + + result = CliRunner().invoke( + app, ["artifact", "lookup", lookup_id, "--json"] + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": f"unknown contribution {lookup_id}" + } + + def test_lookup_requires_json_flag( + self, spec_kit_project: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(spec_kit_project) + result = CliRunner().invoke( + app, + [ + "artifact", + "lookup", + "extension:missing:command:speckit.missing.command", + ], + ) + + assert result.exit_code == 2 + assert result.stdout == "" + + def test_lookup_validates_project_before_lookup_id( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke( + app, + ["artifact", "lookup", "project:_:command:local", "--json"], + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert json.loads(result.stderr) == { + "error": "not a Spec Kit project: no .specify/ directory found" + } diff --git a/tests/specify_cli/artifacts/test_registration.py b/tests/specify_cli/artifacts/test_registration.py new file mode 100644 index 0000000000..e82ffcd555 --- /dev/null +++ b/tests/specify_cli/artifacts/test_registration.py @@ -0,0 +1,11 @@ +"""Tests for ``specify artifact`` command-group registration.""" + +from specify_cli.artifacts import _commands + + +def test_artifact_commands_registered_once_in_stable_order(): + assert [command.name for command in _commands.artifact_app.registered_commands] == [ + "list", + "info", + "lookup", + ] diff --git a/tests/specify_cli/bundles/__init__.py b/tests/specify_cli/bundles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/specify_cli/bundles/_command_helpers.py b/tests/specify_cli/bundles/_command_helpers.py new file mode 100644 index 0000000000..81149d81d1 --- /dev/null +++ b/tests/specify_cli/bundles/_command_helpers.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.bundles.sources import _local_manifest_source +from tests.specify_cli.bundles.helpers import catalog_entry_dict, write_catalog_file + +MARKUP_BUNDLE_ID = "[red]markup-id[/red]" +MARKUP_SOURCE_ID = "[underline]markup-source[/underline]" + + +def configure_markup_catalog(project: Path, **overrides: object) -> dict: + entry = catalog_entry_dict( + MARKUP_BUNDLE_ID, + name="[green]Markup Name[/green]", + version="[blue]1.0.0[/blue]", + role="[magenta]Markup Role[/magenta]", + description="[yellow]Markup Description[/yellow]", + author="[cyan]Markup Author[/cyan]", + license="[bold]Markup License[/bold]", + download_url="https://example.com/markup-bundle.zip", + requires={"speckit_version": "[italic]>=0.1.0[/italic]"}, + **overrides, + ) + catalog = project / "markup-catalog.json" + write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": MARKUP_SOURCE_ID, + "url": str(catalog), + "priority": 1, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + return entry + + +def mock_manifest_download(monkeypatch, source_path: Path) -> None: + monkeypatch.setattr( + "specify_cli.bundles.command_info._download_manifest", + lambda resolved, *, offline: _local_manifest_source(str(source_path)), + ) diff --git a/tests/specify_cli/bundles/catalog/__init__.py b/tests/specify_cli/bundles/catalog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/specify_cli/bundles/catalog/test_command_add.py b/tests/specify_cli/bundles/catalog/test_command_add.py new file mode 100644 index 0000000000..5a6cc9c0e4 --- /dev/null +++ b/tests/specify_cli/bundles/catalog/test_command_add.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + write_catalog_file, +) + +runner = CliRunner() + + +def test_catalog_add_and_remove(project: Path): + catalog = project / "local-catalog.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + + listed = runner.invoke(app, ["bundle", "catalog", "list"]) + assert "local" in listed.output + + removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"]) + assert removed.exit_code == 0 diff --git a/tests/specify_cli/bundles/catalog/test_command_list.py b/tests/specify_cli/bundles/catalog/test_command_list.py new file mode 100644 index 0000000000..f3445c1e13 --- /dev/null +++ b/tests/specify_cli/bundles/catalog/test_command_list.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def test_catalog_list_shows_builtin_defaults(project: Path): + result = runner.invoke(app, ["bundle", "catalog", "list"]) + assert result.exit_code == 0 + assert "default" in result.output + assert "community" in result.output + assert "built-in default stack" in result.output diff --git a/tests/specify_cli/bundles/catalog/test_command_remove.py b/tests/specify_cli/bundles/catalog/test_command_remove.py new file mode 100644 index 0000000000..335b2d28be --- /dev/null +++ b/tests/specify_cli/bundles/catalog/test_command_remove.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def test_catalog_remove_builtin_is_refused(project: Path): + result = runner.invoke(app, ["bundle", "catalog", "remove", "default"]) + assert result.exit_code == 1 + assert "built-in" in result.output diff --git a/tests/specify_cli/bundles/conftest.py b/tests/specify_cli/bundles/conftest.py new file mode 100644 index 0000000000..f7fa8213cd --- /dev/null +++ b/tests/specify_cli/bundles/conftest.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture() +def project(tmp_path: Path, monkeypatch) -> Path: + (tmp_path / ".specify").mkdir() + monkeypatch.chdir(tmp_path) + return tmp_path diff --git a/tests/bundler_helpers.py b/tests/specify_cli/bundles/helpers.py similarity index 96% rename from tests/bundler_helpers.py rename to tests/specify_cli/bundles/helpers.py index 0ebaf2f1c7..eb46fc3baf 100644 --- a/tests/bundler_helpers.py +++ b/tests/specify_cli/bundles/helpers.py @@ -3,7 +3,7 @@ Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched. Import what you need explicitly, e.g.:: - from tests.bundler_helpers import FakeInstaller, write_manifest + from tests.specify_cli.bundles.helpers import FakeInstaller, write_manifest """ from __future__ import annotations @@ -12,7 +12,7 @@ import yaml -from specify_cli.bundler.models.manifest import ComponentRef +from specify_cli.bundles.manifest import ComponentRef def valid_manifest_dict(**overrides) -> dict: diff --git a/tests/unit/test_bundler_adapters.py b/tests/specify_cli/bundles/test_adapters.py similarity index 99% rename from tests/unit/test_bundler_adapters.py rename to tests/specify_cli/bundles/test_adapters.py index d435d29c4d..6ce9a3232f 100644 --- a/tests/unit/test_bundler_adapters.py +++ b/tests/specify_cli/bundles/test_adapters.py @@ -13,8 +13,8 @@ from specify_cli.authentication.http import _StripAuthOnRedirect from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy -from specify_cli.bundler.services import adapters +from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy +from specify_cli.bundles import adapters def _source(url: str) -> CatalogSource: diff --git a/tests/specify_cli/bundles/test_bundles.py b/tests/specify_cli/bundles/test_bundles.py new file mode 100644 index 0000000000..098cca20f8 --- /dev/null +++ b/tests/specify_cli/bundles/test_bundles.py @@ -0,0 +1,8 @@ +"""Tests for the public bundle domain package.""" + + +def test_legacy_bundler_error_import_remains_compatible(): + from specify_cli.bundler import BundlerError as LegacyBundlerError + from specify_cli.bundles import BundlerError + + assert LegacyBundlerError is BundlerError diff --git a/tests/unit/test_bundler_catalog_config.py b/tests/specify_cli/bundles/test_catalog_config.py similarity index 99% rename from tests/unit/test_bundler_catalog_config.py rename to tests/specify_cli/bundles/test_catalog_config.py index 46c333700a..5a8f62a2e5 100644 --- a/tests/unit/test_bundler_catalog_config.py +++ b/tests/specify_cli/bundles/test_catalog_config.py @@ -6,7 +6,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.commands_impl import catalog_config as cc +from specify_cli.bundles import catalog_config as cc def test_derive_id_incorporates_path_stem_for_same_host(): diff --git a/tests/integration/test_bundler_catalog_stack.py b/tests/specify_cli/bundles/test_catalog_stack.py similarity index 95% rename from tests/integration/test_bundler_catalog_stack.py rename to tests/specify_cli/bundles/test_catalog_stack.py index 87d31581b1..0dd3d0c9f4 100644 --- a/tests/integration/test_bundler_catalog_stack.py +++ b/tests/specify_cli/bundles/test_catalog_stack.py @@ -5,9 +5,9 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope -from specify_cli.bundler.services.catalog_stack import CatalogStack -from tests.bundler_helpers import catalog_entry_dict, catalog_payload +from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy, Scope +from specify_cli.bundles.catalog_stack import CatalogStack +from tests.specify_cli.bundles.helpers import catalog_entry_dict, catalog_payload def _source(source_id, priority, policy, url="builtin://x"): diff --git a/tests/specify_cli/bundles/test_command_add.py b/tests/specify_cli/bundles/test_command_add.py new file mode 100644 index 0000000000..203cc9bed6 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_add.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import io +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.adapters import FIRSTPARTY_CATALOG_URL + +runner = CliRunner() +REPO_ROOT = Path(__file__).parents[3] + + +class FakeBundleResponse(io.BytesIO): + def __init__(self, data: bytes, url: str): + super().__init__(data) + self._url = url + + def geturl(self) -> str: + return self._url + + +def test_add_forwards_refresh_default_without_refreshing(project: Path): + with patch("specify_cli.bundles.command_add.bundle_install") as install: + result = runner.invoke(app, ["bundle", "add", "demo"]) + + assert result.exit_code == 0, result.output + install.assert_called_once_with( + bundle_id="demo", + integration=None, + offline=False, + refresh=False, + ) + + +@pytest.mark.parametrize( + ("bundle_id", "extension_id"), + [("bugfix", "bug"), ("assess", "assess")], +) +def test_bundle_add_by_id_initializes_empty_project_from_firstparty_catalog( + tmp_path: Path, monkeypatch, bundle_id: str, extension_id: str +): + project = tmp_path / "fresh" + project.mkdir() + monkeypatch.chdir(project) + + catalog_bytes = (REPO_ROOT / "bundles" / "catalog.json").read_bytes() + manifest_bytes = (REPO_ROOT / "bundles" / bundle_id / "bundle.yml").read_bytes() + expected_manifest_url = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + f"bundles/{bundle_id}/bundle.yml" + ) + captured_urls: list[str] = [] + + def fake_open_url( + url: str, + timeout: int | None = None, + extra_headers: dict[str, str] | None = None, + redirect_validator=None, + ): + captured_urls.append(url) + if url == FIRSTPARTY_CATALOG_URL: + return FakeBundleResponse(catalog_bytes, url=url) + if url == expected_manifest_url: + return FakeBundleResponse(manifest_bytes, url=url) + raise AssertionError(f"Unexpected network request in by-ID bundle test: {url}") + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke( + app, ["bundle", "add", bundle_id, "--integration", "copilot"] + ) + + assert result.exit_code == 0, result.output + assert "No Spec Kit project here" in result.output + assert (project / ".specify").is_dir() + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" + assert FIRSTPARTY_CATALOG_URL in captured_urls + assert expected_manifest_url in captured_urls diff --git a/tests/specify_cli/bundles/test_command_build.py b/tests/specify_cli/bundles/test_command_build.py new file mode 100644 index 0000000000..0b721d9593 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_build.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + valid_manifest_dict, +) + +runner = CliRunner() + + +def test_build_produces_artifact(project: Path): + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (project / "README.md").write_text("# Demo", encoding="utf-8") + result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")]) + assert result.exit_code == 0, result.output + artifacts = list((project / "dist").glob("*.zip")) + assert len(artifacts) == 1 + + +def test_build_escapes_markup_in_output_path(project: Path): + """The build success line echoes a caller-supplied ``--output`` path. + + Brackets are legal in a directory name on both POSIX and Windows, so the + artifact is built and *then* misreported: ``[bold]`` is consumed as a style + tag, and the success line names a path that does not exist on disk. + + A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a + path separator on Windows, so this uses the silent-swallow form to keep the + fixture portable. + """ + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (project / "README.md").write_text("# Demo", encoding="utf-8") + out_dir = project / "dist[bold]out" + + result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)]) + + assert result.exit_code == 0, repr(result.exception) + assert list(out_dir.glob("*.zip")), "the artifact should still be built" + # Join across Rich's wrap points: the success line prints an absolute path, + # so the console folds it mid-token whenever the temp directory is long + # enough, which is a property of the runner's path, not of the escaping. + assert "dist[bold]out" in "".join(strip_ansi(result.output).split()), ( + "the reported path must match the directory actually written" + ) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py new file mode 100644 index 0000000000..6d9f6905a9 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_info.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles._command_helpers import ( + MARKUP_BUNDLE_ID, + MARKUP_SOURCE_ID, + configure_markup_catalog as _configure_markup_catalog, + mock_manifest_download as _mock_manifest_download, +) +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + valid_manifest_dict, + write_catalog_file, +) + +runner = CliRunner() + + +class FakeBundleResponse(io.BytesIO): + def __init__( + self, + data: bytes, + url: str = "https://api.github.com/repos/org/repo/releases/assets/99", + ): + super().__init__(data) + self._url = url + + def geturl(self) -> str: + return self._url + + +def _make_catalog_config(catalog_path: Path, project: Path) -> None: + """Write a bundle-catalogs.yml pointing at *catalog_path* in *project*. + + Priority 0 keeps this test catalog ahead of the packaged first-party + catalog, avoiding unrelated network resolution in info tests. + """ + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "test", + "url": str(catalog_path), + "priority": 0, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + +def test_info_unknown_bundle_without_project_reports_not_found( + tmp_path: Path, monkeypatch +): + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"]) + # Reaches catalog resolution (not the project gate) and reports a clean miss. + assert result.exit_code == 1 + assert "Spec Kit project" not in result.output + + +def test_info_expands_full_component_set(project: Path, monkeypatch): + bundle_dir = project / "src-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "local-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") + + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]): c for c in payload["components"]} + assert ("extensions", "ext-a") in components + preset = components[("presets", "preset-a")] + assert preset["version"] == "2.0.0" + assert preset["priority"] == 10 + assert preset["strategy"] == "append" + assert payload["trust"] == "verified" + + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert "preset-a v2.0.0" in text.output + assert "Trust" in text.output + + +def test_info_escapes_catalog_markup(project: Path, monkeypatch): + entry = _configure_markup_catalog(project) + bundle_dir = project / "markup-bundle" + bundle_dir.mkdir() + manifest_data = valid_manifest_dict() + manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID + manifest_data["integration"] = {"id": "[conceal]markup-integration[/conceal]"} + manifest_path = bundle_dir / "bundle.yml" + manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") + _mock_manifest_download(monkeypatch, manifest_path) + monkeypatch.setattr( + "specify_cli.bundles.command_info._manifest_component_view", + lambda manifest: [ + { + "kind": "extensions", + "id": "[reverse]markup-component[/reverse]", + "version": "[strike]2.0.0[/strike]", + } + ], + ) + monkeypatch.setattr( + "specify_cli.bundles.command_info._bundle_overlaps", + lambda project_root, manifest, *, offline: ["[blink]markup-overlap[/blink]"], + ) + + result = runner.invoke( + app, + ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], + ) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + entry["id"], + entry["name"], + entry["version"], + entry["role"], + entry["description"], + entry["author"], + entry["license"], + entry["requires"]["speckit_version"], + MARKUP_SOURCE_ID, + "[conceal]markup-integration[/conceal]", + "[reverse]markup-component[/reverse]", + "[strike]2.0.0[/strike]", + "[blink]markup-overlap[/blink]", + ): + assert value in output + + +def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch): + markup_count = "[bold]markup-count[/bold]" + _configure_markup_catalog( + project, + provides={"extensions": markup_count}, + ) + bundle_dir = project / "markup-bundle" + bundle_dir.mkdir() + manifest_data = valid_manifest_dict(provides={}) + manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID + manifest_path = bundle_dir / "bundle.yml" + manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8") + _mock_manifest_download(monkeypatch, manifest_path) + + result = runner.invoke( + app, + ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"], + ) + + assert result.exit_code == 0, result.output + assert markup_count in strip_ansi(result.output) + + +def test_info_expands_discovery_only_bundle(project: Path, monkeypatch): + # Discovery-only bundles must still be fully inspectable via `info`; + # only `install` is refused for them. + bundle_dir = project / "disc-bundle" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + catalog = project / "disc-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + _mock_manifest_download(monkeypatch, bundle_dir / "bundle.yml") + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + + +def test_info_expands_zip_sourced_bundle(project: Path, monkeypatch): + # A .zip artifact is extracted to read bundle.yml; info expands it. (The + # download itself is HTTPS-only now and mocked here — see contract note.) + bundle_dir = project / "zip-src" + bundle_dir.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + (bundle_dir / "README.md").write_text("# Demo", encoding="utf-8") + artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path + catalog = project / "zip-catalog.json" + write_catalog_file( + catalog, + { + "demo-bundle": catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + }, + ) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert added.exit_code == 0, added.output + _mock_manifest_download(monkeypatch, artifact) + result = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + components = {(c["kind"], c["id"]) for c in payload["components"]} + assert ("extensions", "ext-a") in components + + +def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path): + # `info` must expand the real component set; if the manifest can't be + # resolved (here: --offline against an https download_url), it should error + # and exit non-zero rather than silently degrading to `provides` counts. + catalog = project / "remote-catalog.json" + entry = catalog_entry_dict( + "demo-bundle", download_url="https://example.com/demo-bundle.zip" + ) + write_catalog_file(catalog, {"demo-bundle": entry}) + added = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"] + ) + assert added.exit_code == 0, added.output + + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert result.exit_code == 1 + assert "Network access disabled" in result.output + + +def test_bundle_info_resolves_github_browser_release_url(project: Path): + """bundle info resolves a private-repo browser release URL via the GitHub API.""" + browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml" + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + + captured = [] + manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "releases/tags/" in url: + # GitHub API release-tags lookup — return asset list + return FakeBundleResponse( + json.dumps( + {"assets": [{"name": "bundle.yml", "url": api_asset_url}]} + ).encode(), + url=url, + ) + # Actual asset download + return FakeBundleResponse(manifest_yaml, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # The browser release URL must have been resolved via the GitHub tags API + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}" + assert "releases/tags/v1.0" in tag_calls[0] + + # The actual download must use the resolved API asset URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + +def test_bundle_info_rejects_utf16_remote_manifest_like_local_sources(project: Path): + """A downloaded (non-zip) bundle.yml must be decoded strictly as UTF-8. + + ``yamlio.load_yaml`` decodes local ``bundle.yml`` sources strictly as + UTF-8, so a well-formed UTF-16 manifest (a realistic PowerShell + ``Out-File`` output) is rejected. Feeding the downloaded bytes straight + to ``yaml.safe_load(io.BytesIO(raw))`` let PyYAML's Reader honour the + UTF-16 BOM and silently *accept* the same manifest instead, diverging + from local/zip sources (the zip branch of this same download path was + already fixed for the identical bug). + """ + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + manifest_yaml_utf16 = yaml.safe_dump(valid_manifest_dict()).encode("utf-16") + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + return FakeBundleResponse(manifest_yaml_utf16, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 1 + output_flat = " ".join(result.output.split()) + assert "could not be read" in output_flat.lower() + + +def test_bundle_info_passes_through_api_asset_url(project: Path): + """bundle info passes a direct GitHub API asset URL through with octet-stream.""" + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77" + + captured = [] + manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + return FakeBundleResponse(manifest_yaml, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # No tags API call — URL was already a REST asset URL + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 0 + + # Exactly one download call to the asset URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + +def test_bundle_info_resolves_github_browser_release_url_zip(project: Path): + """bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml.""" + import io + import zipfile + + browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip" + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88" + + # Build a minimal in-memory ZIP containing bundle.yml + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) + zip_bytes = buf.getvalue() + + captured = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeBundleResponse( + json.dumps( + {"assets": [{"name": "bundle.zip", "url": api_asset_url}]} + ).encode(), + url=url, + ) + return FakeBundleResponse(zip_bytes, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # tags API lookup must have fired + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert "releases/tags/v2.0" in tag_calls[0] + + # Asset download uses the resolved API URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + # Manifest was successfully parsed from the ZIP + payload = json.loads(result.output) + assert payload["id"] == "demo-bundle" + + +def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path): + """bundle info correctly handles a direct API asset URL that serves ZIP bytes.""" + import io + import zipfile + + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55" + + # Build a minimal in-memory ZIP containing bundle.yml + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) + zip_bytes = buf.getvalue() + + captured = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + return FakeBundleResponse(zip_bytes, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # No tags API call — URL was already a REST asset URL + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 0 + + # Download used octet-stream header + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + # ZIP bytes were detected by magic and bundle.yml extracted correctly + payload = json.loads(result.output) + assert payload["id"] == "demo-bundle" + + +def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors( + project: Path, +): + """When the GitHub tags API lookup finds no matching asset, fall back to the + original browser URL and surface a meaningful error (not a raw traceback).""" + browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml" + + captured = [] + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "releases/tags/" in url: + # Tags API responds but the asset list doesn't include our file + return FakeBundleResponse( + json.dumps({"assets": []}).encode(), + url=url, + ) + # Fallback download: GitHub serves HTML (SSO redirect) instead of YAML + return FakeBundleResponse(b"SSO login required", url=url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + # Must exit non-zero — the HTML body is not a valid bundle manifest + assert result.exit_code == 1 + + # The tags API lookup must have fired + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1 + + # The fallback download should use the original browser URL (no octet-stream) + fallback_calls = [(url, h) for url, h in captured if url == browser_url] + assert len(fallback_calls) == 1 + assert fallback_calls[0][1] is None # no Accept header on the original URL + + # Error output must be actionable (not a raw traceback) + assert "Error:" in result.output + + +def test_bundle_info_resolves_ghes_browser_release_url(project: Path): + """bundle info resolves a GHES private-repo browser release URL via /api/v3.""" + ghes_host = "ghes.example" + browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml" + api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42" + + captured = [] + manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode() + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured.append((url, extra_headers)) + if "/api/v3/repos/" in url and "releases/tags/" in url: + return FakeBundleResponse( + json.dumps( + {"assets": [{"name": "bundle.yml", "url": api_asset_url}]} + ).encode(), + url=url, + ) + return FakeBundleResponse(manifest_yaml, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)}, + ) + _make_catalog_config(catalog, project) + + with ( + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), + patch( + "specify_cli.authentication.http.github_provider_hosts", + return_value=(ghes_host,), + ), + ): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + assert result.exit_code == 0, result.output + + # The GHES /api/v3 tags lookup must have fired + tag_calls = [url for url, _ in captured if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0] + + # Asset download must use the resolved GHES API URL with octet-stream + asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url] + assert len(asset_calls) == 1 + assert asset_calls[0][0] == api_asset_url + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + payload = json.loads(result.output) + assert payload["id"] == "demo-bundle" + + +def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch): + """Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES.""" + # Monkeypatch to a small limit so the test is fast and low-memory. + monkeypatch.setattr("specify_cli.bundles.sources.MAX_DOWNLOAD_BYTES", 100) + + api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99" + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + # Return a response that exceeds 100 bytes. + return FakeBundleResponse(b"x" * 200, url=api_asset_url) + + catalog = project / "catalog.json" + write_catalog_file( + catalog, + {"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)}, + ) + _make_catalog_config(catalog, project) + + with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"]) + + # Must fail with a size-limit error, not an unhandled traceback. + assert result.exit_code == 1 + # Rich may wrap the message across lines; normalise whitespace before checking. + output_flat = " ".join(result.output.split()) + assert "exceeds maximum size of 100 bytes" in output_flat diff --git a/tests/specify_cli/bundles/test_command_init.py b/tests/specify_cli/bundles/test_command_init.py new file mode 100644 index 0000000000..adf60e0035 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_init.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from specify_cli import app + +runner = CliRunner() + + +def _make_project(tmp_path: Path, name: str) -> Path: + project = tmp_path / name + (project / ".specify").mkdir(parents=True) + return project + + +def test_override_symlinked_specify_errors_bundle_init_no_fallback( + tmp_path, monkeypatch +): + """A symlinked override .specify must not make bundle init fall back to cwd.""" + web = tmp_path / "web" + web.mkdir() + real = tmp_path / "real-specify" + real.mkdir() + try: + (web / ".specify").symlink_to(real, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Symlinks are not available in this environment") + + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "init", "--offline"]) + assert result.exit_code != 0 + assert "symlinked .specify" in result.output + assert not (elsewhere / ".specify").exists() diff --git a/tests/integration/test_bundler_local_install.py b/tests/specify_cli/bundles/test_command_install.py similarity index 54% rename from tests/integration/test_bundler_local_install.py rename to tests/specify_cli/bundles/test_command_install.py index 7655543ebb..399162efa1 100644 --- a/tests/integration/test_bundler_local_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -1,11 +1,6 @@ -"""Tests for installing a bundle from a local artifact/path (T045). - -The resolution-level tests are pure; the end-to-end test installs the bundled -``agent-context`` extension fully offline from a built ``.zip`` artifact, -proving the real in-process primitive dispatch (T044) works without a network. -""" from __future__ import annotations +import json import os import zipfile from pathlib import Path @@ -16,83 +11,123 @@ from typer.testing import CliRunner from specify_cli import app -from specify_cli.bundler import BundlerError -from specify_cli.commands.bundle import _local_manifest_source -from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict, write_manifest - - -def test_local_source_none_for_non_path(): - assert _local_manifest_source("some-catalog-bundle-id") is None +from specify_cli.bundles._commands import _resolve_init_integration +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.packager import build_bundle +from tests.specify_cli.bundles.helpers import ( + FakeInstaller, + catalog_entry_dict, + make_project, + valid_manifest_dict, + write_catalog_file, + write_manifest, +) + +runner = CliRunner() +REPO_ROOT = Path(__file__).parents[3] + + +def _bundled_workflow_manifest(workflow_id: str, version: str = "1.0.0") -> dict: + return valid_manifest_dict( + provides={"workflows": [{"id": workflow_id, "version": version}]} + ) -def test_local_source_from_directory(tmp_path: Path): - write_manifest(tmp_path, valid_manifest_dict()) - manifest = _local_manifest_source(str(tmp_path)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" +@pytest.mark.parametrize( + ("command", "bundle_id", "extension_id"), + [("install", "bugfix", "bug"), ("add", "assess", "assess")], +) +def test_local_firstparty_bundle_installs_bundled_components_offline( + project: Path, command: str, bundle_id: str, extension_id: str +): + bundle_dir = REPO_ROOT / "bundles" / bundle_id + result = runner.invoke(app, ["bundle", command, str(bundle_dir), "--offline"]) -def test_local_source_from_bundle_yml(tmp_path: Path): - path = write_manifest(tmp_path, valid_manifest_dict()) - manifest = _local_manifest_source(str(path)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" + assert result.exit_code == 0, result.output + assert ( + project / ".specify" / "extensions" / extension_id / "extension.yml" + ).is_file() + assert (project / ".specify" / "workflows" / bundle_id / "workflow.yml").is_file() + registry = json.loads( + (project / ".specify" / "workflows" / "workflow-registry.json").read_text( + encoding="utf-8" + ) + ) + assert registry["workflows"][bundle_id]["version"] == "1.0.0" -def test_local_source_from_zip_artifact(tmp_path: Path): - bundle_dir = tmp_path / "bundle" - bundle_dir.mkdir() - write_manifest(bundle_dir, valid_manifest_dict()) - (bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8") +def test_local_bundle_rejects_mismatched_bundled_workflow_pin_offline(project: Path): + bundle_dir = project / "mismatched-workflow-pin" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("bugfix", "9.9.9")), + encoding="utf-8", + ) - runner = CliRunner() - result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)]) - assert result.exit_code == 0, result.output - artifact = next(bundle_dir.glob("*.zip")) + result = runner.invoke(app, ["bundle", "install", str(bundle_dir), "--offline"]) - manifest = _local_manifest_source(str(artifact)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" + assert result.exit_code == 1 + assert "pinned to version 9.9.9" in result.output + assert not (project / ".specify" / "workflows" / "bugfix").exists() -def test_local_source_rejects_unknown_file(tmp_path: Path): - weird = tmp_path / "thing.txt" - weird.write_text("nope", encoding="utf-8") - with pytest.raises(BundlerError, match="not a recognised bundle source"): - _local_manifest_source(str(weird)) +def test_local_bundle_refuses_unbundled_workflow_offline(project: Path): + bundle_dir = project / "unbundled-workflow" + (bundle_dir / "bundle.yml").parent.mkdir() + (bundle_dir / "bundle.yml").write_text( + yaml.safe_dump(_bundled_workflow_manifest("not-bundled")), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "install", str(bundle_dir), "--offline"]) -def test_local_source_zip_non_utf8_manifest_raises_bundler_error(tmp_path: Path): - """Undecodable bundle.yml bytes inside a .zip must raise BundlerError. + assert result.exit_code == 1 + assert "network access is disabled" in " ".join(result.output.lower().split()) - The manifest bytes are decoded as UTF-8 explicitly, matching - ``yamlio.load_yaml``'s "Could not read ..." contract, instead of - escaping as a raw ``UnicodeDecodeError``/``ReaderError`` traceback. - """ - artifact = tmp_path / "demo.zip" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", b"\xff\xfe bundle \xc3\x28\n") - with pytest.raises(BundlerError, match="Could not read"): - _local_manifest_source(str(artifact)) +def test_install_refuses_discovery_only_source(project: Path, monkeypatch): + # Point a discovery-only catalog at a local payload containing the bundle. + catalog = project / "disc.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "install", "demo", "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output -def test_local_source_zip_utf16_manifest_rejected_like_directory(tmp_path: Path): - """A well-formed UTF-16 manifest must fail the same way in a .zip. +def test_install_integration_override_cannot_bypass_clash_guard(project: Path): + # An initialized project's recorded active integration is authoritative: + # passing --integration must not let a differently-pinned bundle install. + import json - ``yamlio.load_yaml`` decodes strictly as UTF-8, so a UTF-16 bundle.yml - (the realistic PowerShell ``Out-File`` output) is rejected when read - from a directory. Feeding the zip bytes straight to PyYAML would let - its Reader honour the UTF-16 BOM and *accept* the same manifest, - making zip and directory sources diverge. - """ - artifact = tmp_path / "demo.zip" - manifest_text = "bundle:\n id: demo-bundle\n version: 1.0.0\n" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", manifest_text.encode("utf-16")) + (project / ".specify" / "integration.json").write_text( + json.dumps({"integration": "copilot"}), encoding="utf-8" + ) + bundle_dir = project / "claude-bundle" + bundle_dir.mkdir() + data = valid_manifest_dict(integration={"id": "claude"}) + (bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + (bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8") - with pytest.raises(BundlerError, match="Could not read"): - _local_manifest_source(str(artifact)) + result = runner.invoke( + app, + ["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"], + ) + assert result.exit_code == 1 + assert "claude" in result.output and "copilot" in result.output def test_install_bundled_extension_from_zip_offline(tmp_path: Path): @@ -149,91 +184,6 @@ def test_install_bundled_extension_from_zip_offline(tmp_path: Path): os.chdir(previous) -def test_download_manifest_rejects_file_url(tmp_path: Path): - """A catalog ``file://`` download_url is rejected — catalog URLs are - HTTPS-only, matching extensions/presets/workflows. Disk installs go through - the positional path (see the local-source tests above), not download_url. - """ - from types import SimpleNamespace - - from specify_cli.commands.bundle import _download_manifest - - manifest_path = write_manifest(tmp_path / "my bundles") - resolved = SimpleNamespace( - entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri()) - ) - - with pytest.raises(BundlerError, match="bundle install"): - _download_manifest(resolved, offline=True) - - -def test_download_manifest_rejects_bare_path(tmp_path: Path): - """A bare filesystem path download_url is likewise rejected.""" - from types import SimpleNamespace - - from specify_cli.commands.bundle import _download_manifest - - manifest_path = write_manifest(tmp_path / "plain") - resolved = SimpleNamespace( - entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path)) - ) - - with pytest.raises(BundlerError, match="bundle install"): - _download_manifest(resolved, offline=True) - - -def test_local_install_still_resolves_via_positional_path(tmp_path: Path): - """The supported local route — a positional path, not a download_url — - still resolves the manifest via _local_manifest_source.""" - manifest_path = write_manifest(tmp_path / "my bundles") - manifest = _local_manifest_source(str(manifest_path)) - assert manifest is not None - assert manifest.bundle.id == "demo-bundle" - - -def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path): - """A non-HTTPS download_url must report the HTTPS problem, not a misleading - 'Network access disabled', even under --offline (scheme is validated before - the offline gate).""" - from types import SimpleNamespace - - from specify_cli.commands.bundle import _download_manifest - - resolved = SimpleNamespace( - entry=SimpleNamespace( - id="demo-bundle", download_url="http://example.com/bundle.zip" - ) - ) - with pytest.raises(BundlerError, match="HTTPS"): - _download_manifest(resolved, offline=True) - - -def test_local_zip_uses_bounded_archive_open(tmp_path: Path): - artifact = tmp_path / "too-many-entries.zip" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) - for index in range(512): - archive.writestr(f"assets/{index}.txt", "") - - with pytest.raises(BundlerError, match="too many entries"): - _local_manifest_source(str(artifact)) - - -def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path): - """A malformed bundle.yml inside a .zip must raise BundlerError. - - The zip branch parses YAML inline rather than through load_yaml(), so the - raw yaml.YAMLError used to escape. It is neither a ValueError nor an - OSError, so nothing upstream caught it. - """ - artifact = tmp_path / "bad-manifest.zip" - with zipfile.ZipFile(artifact, "w") as archive: - archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n") - - with pytest.raises(BundlerError, match="Invalid YAML"): - _local_manifest_source(str(artifact)) - - def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path): """`bundle install` reports malformed YAML the same way for all 3 sources. @@ -257,9 +207,9 @@ def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Pa for source in (directory, manifest_file, artifact): result = runner.invoke(app, ["bundle", "install", str(source)]) assert result.exit_code == 1, f"{source.name}: {result.output}" - assert result.exception is None or isinstance( - result.exception, SystemExit - ), f"{source.name} leaked {type(result.exception).__name__}" + assert result.exception is None or isinstance(result.exception, SystemExit), ( + f"{source.name} leaked {type(result.exception).__name__}" + ) assert "Invalid YAML" in result.output, f"{source.name}: {result.output}" @@ -276,7 +226,7 @@ def test_invalid_local_manifest_is_rejected_before_project_init( monkeypatch.chdir(empty_cwd) runner = CliRunner() - with patch("specify_cli.commands.bundle._run_init") as run_init: + with patch("specify_cli.bundles.command_install._run_init") as run_init: result = runner.invoke( app, ["bundle", "install", str(bundle_dir), "--offline"], @@ -300,7 +250,7 @@ def test_incompatible_local_manifest_is_rejected_before_project_init( monkeypatch.chdir(empty_cwd) runner = CliRunner() - with patch("specify_cli.commands.bundle._run_init") as run_init: + with patch("specify_cli.bundles.command_install._run_init") as run_init: result = runner.invoke( app, ["bundle", "install", str(bundle_dir), "--offline"], @@ -314,10 +264,13 @@ def test_incompatible_local_manifest_is_rejected_before_project_init( @pytest.mark.parametrize("source_kind", ["manifest", "directory", "zip"]) @pytest.mark.parametrize("bundle_version", ["1.2.0", "2.0.0"]) def test_local_install_refresh_updates_owned_components( - tmp_path: Path, monkeypatch, source_kind: str, bundle_version: str, + tmp_path: Path, + monkeypatch, + source_kind: str, + bundle_version: str, ): """Local upgrades refresh owned pins before advancing the bundle record.""" - from specify_cli.bundler.models.records import load_records, records_path + from specify_cli.bundles.records import load_records, records_path project = make_project(tmp_path / "proj") monkeypatch.chdir(project) @@ -335,7 +288,7 @@ def refresh(self, root, component): installer = VersionedInstaller() monkeypatch.setattr( - "specify_cli.bundler.services.adapters.DefaultPrimitiveInstaller", + "specify_cli.bundles.adapters.DefaultPrimitiveInstaller", lambda **kwargs: installer, ) data = valid_manifest_dict() @@ -367,7 +320,8 @@ def refresh(self, root, component): assert installer.refresh_calls == [] refreshed = runner.invoke( - app, ["bundle", "install", str(source), "--offline", "--refresh"], + app, + ["bundle", "install", str(source), "--offline", "--refresh"], ) assert refreshed.exit_code == 0, refreshed.output assert "--refresh" in rejected.output @@ -382,22 +336,31 @@ def refresh(self, root, component): assert set(installer.refresh_calls) == set(expected) record = load_records(project)[0] assert record.version == bundle_version - assert {(c.kind, c.id): c.version for c in record.contributed_components} == expected + assert { + (c.kind, c.id): c.version for c in record.contributed_components + } == expected @pytest.mark.parametrize("source_kind", ["manifest", "directory", "zip"]) @pytest.mark.parametrize("bundle_version", ["1.2.0", "2.0.0"]) def test_local_refresh_catalog_extension_requires_network( - tmp_path: Path, monkeypatch, source_kind: str, bundle_version: str, + tmp_path: Path, + monkeypatch, + source_kind: str, + bundle_version: str, ): """Use the real installer; replace only catalog I/O with local artifacts.""" - from specify_cli.bundler.models.records import load_records, records_path + from specify_cli.bundles.records import load_records, records_path from specify_cli.extensions import ExtensionCatalog project = make_project(tmp_path / "project") monkeypatch.chdir(project) - monkeypatch.setattr("specify_cli.commands.bundle._bundle_overlaps", lambda *a, **kw: []) - monkeypatch.setattr("specify_cli._assets._locate_bundled_extension", lambda cid: None) + monkeypatch.setattr( + "specify_cli.bundles.command_install._bundle_overlaps", lambda *a, **kw: [] + ) + monkeypatch.setattr( + "specify_cli._assets._locate_bundled_extension", lambda cid: None + ) version = "1.0.0" downloads = [] @@ -407,25 +370,34 @@ def download_extension(self, extension_id): extension = { "schema_version": "1.0", "extension": { - "id": extension_id, "name": "Catalog extension", - "version": version, "description": "Refresh regression", + "id": extension_id, + "name": "Catalog extension", + "version": version, + "description": "Refresh regression", }, "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"commands": [ - {"name": "speckit.catalog-ext.hello", "file": "commands/hello.md"}, - ]}, + "provides": { + "commands": [ + {"name": "speckit.catalog-ext.hello", "file": "commands/hello.md"}, + ] + }, } with zipfile.ZipFile(artifact, "w") as archive: archive.writestr("extension.yml", yaml.safe_dump(extension)) - archive.writestr("commands/hello.md", f"---\ndescription: Test\n---\n{version}\n") + archive.writestr( + "commands/hello.md", f"---\ndescription: Test\n---\n{version}\n" + ) return artifact monkeypatch.setattr( - ExtensionCatalog, "get_extension_info", + ExtensionCatalog, + "get_extension_info", lambda self, cid: {"id": cid, "version": version, "_install_allowed": True}, ) monkeypatch.setattr(ExtensionCatalog, "download_extension", download_extension) - data = valid_manifest_dict(provides={"extensions": [{"id": "catalog-ext", "version": version}]}) + data = valid_manifest_dict( + provides={"extensions": [{"id": "catalog-ext", "version": version}]} + ) manifest_path = write_manifest(tmp_path / "local bundle", data) runner = CliRunner() first = runner.invoke(app, ["bundle", "install", str(manifest_path)]) @@ -457,7 +429,9 @@ def download_extension(self, extension_id): assert payload.read_bytes() == original_payload assert (installed_dir / "extension.yml").read_bytes() == original_manifest - offline = runner.invoke(app, ["bundle", "install", str(source), "--refresh", "--offline"]) + offline = runner.invoke( + app, ["bundle", "install", str(source), "--refresh", "--offline"] + ) assert offline.exit_code == 1, offline.output output = " ".join(offline.output.split()) assert "catalog-ext" in output @@ -474,7 +448,95 @@ def download_extension(self, extension_id): assert "1 refreshed" in refreshed.output assert downloads == [("catalog-ext", "1.0.0"), ("catalog-ext", "2.0.0")] assert payload.read_text(encoding="utf-8").endswith("2.0.0\n") - assert yaml.safe_load((installed_dir / "extension.yml").read_text(encoding="utf-8"))["extension"]["version"] == version + assert ( + yaml.safe_load((installed_dir / "extension.yml").read_text(encoding="utf-8"))[ + "extension" + ]["version"] + == version + ) record = load_records(project)[0] assert record.version == bundle_version assert record.contributed_components[0].version == version + + +def _manifest(**overrides): + data = valid_manifest_dict(**overrides) + return BundleManifest.from_dict(data) + + +def _build_mini(tmp_path: Path) -> Path: + bundle = tmp_path / "mini" + bundle.mkdir() + (bundle / "bundle.yml").write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "bundle": { + "id": "mini", + "name": "Mini", + "version": "1.0.0", + "role": "developer", + "description": "minimal", + "author": "tests", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "extensions": [{"id": "agent-context", "version": "1.0.0"}] + }, + } + ), + encoding="utf-8", + ) + (bundle / "README.md").write_text("# Mini\n", encoding="utf-8") + return build_bundle(bundle).artifact_path + + +def test_precedence_override_wins(): + manifest = _manifest(integration={"id": "claude"}) + assert _resolve_init_integration("gemini", manifest) == "gemini" + + +def test_precedence_bundle_declared_when_no_override(): + manifest = _manifest(integration={"id": "claude"}) + assert _resolve_init_integration(None, manifest) == "claude" + + +def test_precedence_default_when_unspecified(): + manifest = _manifest() + assert _resolve_init_integration(None, manifest) == "copilot" + assert _resolve_init_integration(None, None) == "copilot" + + +def test_precedence_default_honors_env_var(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "gemini") + # With no override and no bundle-declared integration, the env-var default + # applies instead of the hardcoded "copilot". + assert _resolve_init_integration(None, None) == "gemini" + assert _resolve_init_integration(None, _manifest()) == "gemini" + # Explicit override and bundle-declared integration still take precedence. + assert _resolve_init_integration("claude", None) == "claude" + assert ( + _resolve_init_integration(None, _manifest(integration={"id": "claude"})) + == "claude" + ) + + +def test_install_initializes_uninitialized_project(tmp_path: Path): + project = tmp_path / "proj" + project.mkdir() + artifact = _build_mini(tmp_path) + + previous = Path.cwd() + os.chdir(project) + try: + result = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"]) + assert result.exit_code == 0, result.output + finally: + os.chdir(previous) + + assert (project / ".specify").is_dir() + marker = project / ".specify" / "integration.json" + assert marker.exists() + data = json.loads(marker.read_text(encoding="utf-8")) + assert "copilot" in json.dumps(data) diff --git a/tests/specify_cli/bundles/test_command_list.py b/tests/specify_cli/bundles/test_command_list.py new file mode 100644 index 0000000000..8e2fe90a9f --- /dev/null +++ b/tests/specify_cli/bundles/test_command_list.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def _make_project(tmp_path: Path, name: str) -> Path: + project = tmp_path / name + (project / ".specify").mkdir(parents=True) + return project + + +def test_list_empty_project(project: Path): + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0 + assert "No bundles installed" in result.output + + +def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 1 + assert "Spec Kit project" in result.output + + +def test_list_escapes_markup_in_records(project: Path): + """``bundle list`` renders record fields that are never charset-validated. + + ``InstalledBundleRecord.from_dict`` accepts any non-empty string for + ``bundle_id``/``version`` and any string for ``installed_at``, so a records + file that *loads cleanly* could still crash the command that displays it. + """ + (project / ".specify" / "bundle-records.json").write_text( + json.dumps( + { + "schema_version": "1.0", + "bundles": [ + { + "bundle_id": "demo[/red]id", + "version": "1.0.0[/bold]", + "installed_at": "2026-01-01T00:00:00Z[/dim]", + "contributed_components": [], + } + ], + } + ), + encoding="utf-8", + ) + + result = runner.invoke(app, ["bundle", "list"]) + + assert result.exit_code == 0, repr(result.exception) + output = strip_ansi(result.output) + assert "demo[/red]id" in output + assert "1.0.0[/bold]" in output + assert "2026-01-01T00:00:00Z[/dim]" in output + + +def test_override_redirects_bundle_commands(tmp_path, monkeypatch): + web = _make_project(tmp_path, "web") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0, result.output + assert "No bundles installed" in result.output + + +def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch): + """Bundle commands also honor the strict override contract.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code != 0 + assert "does not point to an existing directory" in result.output + assert "No bundles installed" not in result.output + + +def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch): + """Invalid override errors must not contaminate JSON stdout.""" + cwd_proj = _make_project(tmp_path, "cwd") + monkeypatch.chdir(cwd_proj) + monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) + + result = runner.invoke(app, ["bundle", "list", "--json"]) + assert result.exit_code != 0 + assert result.stdout == "" + assert "does not point to an existing directory" in result.stderr diff --git a/tests/specify_cli/bundles/test_command_remove.py b/tests/specify_cli/bundles/test_command_remove.py new file mode 100644 index 0000000000..262d57ade8 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_remove.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import pytest +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + valid_manifest_dict, +) + +runner = CliRunner() + + +def test_remove_reports_clean_error_when_primitive_raises_raw_exception( + project: Path, +): + """A raw exception from a primitive installer (e.g. an OSError from an + unreadable workflow registry surfacing through _WorkflowKindManager's + fail-closed construction) must not propagate uncaught through + `specify bundle remove` -- the command only catches BundlerError, so + without a conversion at the remove_bundle boundary this would exit + with an unhandled exception and empty/raw output instead of a clean, + actionable message, and no removal side effects should occur either.""" + from specify_cli.bundles.manifest import BundleManifest + from specify_cli.bundles.records import load_records + from specify_cli.bundles.adapters import DefaultPrimitiveInstaller + from specify_cli.bundles.installer import install_bundle + from specify_cli.bundles.resolver import resolve_install_plan + from tests.specify_cli.bundles.helpers import FakeInstaller + + manifest = BundleManifest.from_dict(valid_manifest_dict()) + plan = resolve_install_plan( + manifest, speckit_version="0.11.2", active_integration="copilot" + ) + install_bundle(project, plan, FakeInstaller(), manifest=manifest) + + def boom(self, project_root, component): + raise OSError("workflow registry unreadable") + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(DefaultPrimitiveInstaller, "is_installed", boom) + result = runner.invoke(app, ["bundle", "remove", "demo-bundle"]) + + assert result.exit_code != 0 + assert result.output.strip() != "" + assert result.exception is None or isinstance(result.exception, SystemExit) + assert {r.bundle_id for r in load_records(project)} == {"demo-bundle"} diff --git a/tests/specify_cli/bundles/test_command_search.py b/tests/specify_cli/bundles/test_command_search.py new file mode 100644 index 0000000000..433d2158d6 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_search.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles._command_helpers import ( + MARKUP_SOURCE_ID, + configure_markup_catalog as _configure_markup_catalog, +) +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + write_catalog_file, +) + +runner = CliRunner() + + +def test_search_works_without_a_project(tmp_path: Path, monkeypatch): + # Discovery commands fall back to the built-in/user catalog stack and must + # not require a Spec Kit project (matches README/quickstart examples). + monkeypatch.chdir(tmp_path) # no .specify/ + result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) + assert result.exit_code == 0, result.output + assert result.output.strip().startswith("[") + + +def test_search_escapes_catalog_markup(project: Path): + entry = _configure_markup_catalog(project) + + result = runner.invoke(app, ["bundle", "search", "--offline"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + entry["id"], + entry["name"], + entry["version"], + entry["role"], + entry["description"], + MARKUP_SOURCE_ID, + ): + assert value in output + + +def test_search_json_offline(project: Path): + catalog = project / "c.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "c", + "url": str(catalog), + "priority": 0, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "search", "--offline", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + by_id = {entry["id"]: entry for entry in payload} + assert "demo" in by_id + # Trust indicator is exposed on the discovery surface (FR-010 / FR-027). + assert by_id["demo"]["verified"] is True + assert by_id["demo"]["trust"] == "verified" + + +def test_search_text_shows_trust(project: Path): + catalog = project / "c.json" + write_catalog_file( + catalog, + { + "verified-one": catalog_entry_dict("verified-one", verified=True), + "community-one": catalog_entry_dict("community-one", verified=False), + }, + ) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "c", + "url": str(catalog), + "priority": 1, + "install_policy": "install-allowed", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "search", "--offline"]) + assert result.exit_code == 0, result.output + assert "verified" in result.output + assert "community" in result.output diff --git a/tests/specify_cli/bundles/test_command_update.py b/tests/specify_cli/bundles/test_command_update.py new file mode 100644 index 0000000000..6452f5c73c --- /dev/null +++ b/tests/specify_cli/bundles/test_command_update.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + write_catalog_file, +) + +runner = CliRunner() + + +def test_update_accepts_integration_override(): + # Update must expose --integration so integration-pinned bundles can be + # updated in projects where the active integration can't be auto-detected. + # Rich may insert ANSI escapes between the two leading dashes, so match the + # un-split option word rather than the literal "--integration". + result = runner.invoke(app, ["bundle", "update", "--help"]) + assert result.exit_code == 0 + assert "integration" in result.output + + +def test_update_refuses_discovery_only_source(project: Path): + # An installed bundle whose only resolvable source is discovery-only must + # not be updatable from there (FR-025), mirroring the install policy gate. + from specify_cli.bundles.manifest import ComponentRef + from specify_cli.bundles.records import ( + InstalledBundleRecord, + save_records, + ) + + save_records( + project, + [ + InstalledBundleRecord.create( + "demo", + "1.0.0", + [ComponentRef(kind="extensions", id="ext-a", version=None)], + ) + ], + ) + + catalog = project / "disc.json" + write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")}) + config = { + "schema_version": "1.0", + "catalogs": [ + { + "id": "disc", + "url": str(catalog), + "priority": 1, + "install_policy": "discovery-only", + } + ], + } + (project / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + result = runner.invoke(app, ["bundle", "update", "demo", "--offline"]) + assert result.exit_code == 1 + assert "discovery-only" in result.output diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py new file mode 100644 index 0000000000..f0b22f7963 --- /dev/null +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 +from tests.specify_cli.bundles.helpers import ( + valid_manifest_dict, +) + +runner = CliRunner() + + +def test_validate_reports_invalid_manifest(project: Path): + data = valid_manifest_dict() + del data["bundle"]["license"] + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 1 + assert "license" in result.output + + +def test_validate_accepts_valid_manifest(project: Path): + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + # Offline mode does not fail on references it cannot verify (synthetic ids + # here); they surface as warnings while structure is confirmed valid. + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + assert result.exit_code == 0, result.output + assert "valid" in result.output + + +def test_validate_escapes_manifest_markup_in_errors(project: Path): + data = valid_manifest_dict() + # An invalid constraint is echoed back inside the validation error. + data["requires"] = {"speckit_version": ">=1.0[/bold]"} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + + assert result.exit_code == 1 + assert isinstance(result.exception, SystemExit) + assert ">=1.0[/bold]" in strip_ansi(result.output) + + +def test_validate_escapes_manifest_markup_in_warnings(project: Path): + data = valid_manifest_dict() + # Step ids are not charset-validated, and the unresolved-reference warning + # echoes them -- so an otherwise *valid* manifest crashed just as readily as + # an invalid one, on the success path. + data["provides"]["steps"] = [{"id": "step[/bold]a"}] + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + + result = runner.invoke(app, ["bundle", "validate", "--offline"]) + + assert result.exit_code == 0, repr(result.exception) + assert "step[/bold]a" in strip_ansi(result.output) + + +def test_validate_rejects_broken_reference(project: Path): + # Synthetic component ids resolve to nothing in any catalog → hard failure. + (project / "bundle.yml").write_text( + yaml.safe_dump(valid_manifest_dict()), encoding="utf-8" + ) + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 1 + assert "preset-a" in result.output or "ext-a" in result.output + + +def test_validate_accepts_bundled_reference(project: Path): + data = valid_manifest_dict() + data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]} + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + result = runner.invoke(app, ["bundle", "validate"]) + assert result.exit_code == 0, result.output + assert "valid" in result.output diff --git a/tests/specify_cli/bundles/test_commands.py b/tests/specify_cli/bundles/test_commands.py new file mode 100644 index 0000000000..e0d054d219 --- /dev/null +++ b/tests/specify_cli/bundles/test_commands.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import io # noqa: F401 +import json # noqa: F401 +from pathlib import Path +from unittest.mock import patch # noqa: F401 + +import pytest +import yaml # noqa: F401 +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles.packager import build_bundle # noqa: F401 +from tests.conftest import strip_ansi # noqa: F401 + +runner = CliRunner() + + +def test_bundle_help_lists_all_commands(): + result = runner.invoke(app, ["bundle", "--help"]) + assert result.exit_code == 0 + for cmd in ( + "search", + "info", + "list", + "install", + "add", + "update", + "remove", + "validate", + "build", + "init", + "catalog", + ): + assert cmd in result.output + + +def test_fail_writes_error_to_stderr_not_stdout(capsys): + """_fail must write to stderr, not stdout: every bundle command routes errors + through it, and under --json the error would otherwise corrupt the JSON payload + that consumers read from stdout.""" + import typer + + from specify_cli.bundles._commands import _fail + + with pytest.raises(typer.Exit): + _fail("something broke") + captured = capsys.readouterr() + assert "something broke" in captured.err + assert "something broke" not in captured.out + + +@pytest.mark.parametrize( + "argv, expected", + [ + ( + ["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"], + "ssh://ex[/red]ample.com/c.json", + ), + (["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"), + (["bundle", "update", "no[/red]such"], "no[/red]such"), + (["bundle", "remove", "no[/red]such"], "no[/red]such"), + ], +) +def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str): + result = runner.invoke(app, argv) + + assert result.exit_code == 1 + # A MarkupError would surface here as an exception rather than a clean exit. + assert isinstance(result.exception, SystemExit) + assert expected in strip_ansi(result.output) diff --git a/tests/unit/test_bundler_conflict.py b/tests/specify_cli/bundles/test_conflict.py similarity index 87% rename from tests/unit/test_bundler_conflict.py rename to tests/specify_cli/bundles/test_conflict.py index 5dbcb3dba1..ca92f0e825 100644 --- a/tests/unit/test_bundler_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -1,10 +1,10 @@ """Unit tests for conflict detection (T034): integration clash and overlap precedence.""" from __future__ import annotations -from specify_cli.bundler.models.manifest import BundleManifest, ComponentRef -from specify_cli.bundler.models.records import InstalledBundleRecord -from specify_cli.bundler.services.conflict import detect_conflicts -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest, ComponentRef +from specify_cli.bundles.records import InstalledBundleRecord +from specify_cli.bundles.conflict import detect_conflicts +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _manifest(**overrides) -> BundleManifest: diff --git a/tests/integration/test_bundler_install_flow.py b/tests/specify_cli/bundles/test_installer.py similarity index 97% rename from tests/integration/test_bundler_install_flow.py rename to tests/specify_cli/bundles/test_installer.py index 8b149c9f49..f004b2a0c2 100644 --- a/tests/integration/test_bundler_install_flow.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -10,11 +10,11 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.bundler.models.records import load_records, records_path -from specify_cli.bundler.services.installer import install_bundle, remove_bundle -from specify_cli.bundler.services.resolver import resolve_install_plan -from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.records import load_records, records_path +from specify_cli.bundles.installer import install_bundle, remove_bundle +from specify_cli.bundles.resolver import resolve_install_plan +from tests.specify_cli.bundles.helpers import FakeInstaller, make_project, valid_manifest_dict def _plan(manifest): @@ -363,7 +363,7 @@ def fail_dump(_data, handle, *_args, **_kwargs): with pytest.MonkeyPatch.context() as mp: mp.setattr( - "specify_cli.bundler.lib.yamlio.json.dump", + "specify_cli.bundles.yamlio.json.dump", fail_dump, ) with pytest.raises(BundlerError) as exc_info: @@ -391,7 +391,7 @@ def fail_save(*_args, **_kwargs): with pytest.MonkeyPatch.context() as mp: mp.setattr( - "specify_cli.bundler.services.installer.save_records", + "specify_cli.bundles.installer.save_records", fail_save, ) with pytest.raises(BundlerError) as exc_info: @@ -597,8 +597,8 @@ def test_install_result_changed_reports_uninstalled(): # A `bundle update` that only DROPS components (new manifest reduces # provides) populates uninstalled with nothing installed/refreshed; that is # still a mutating change, so `changed` must be True — not a false no-op. - from specify_cli.bundler.services.installer import InstallResult - from specify_cli.bundler.models.manifest import ComponentRef + from specify_cli.bundles.installer import InstallResult + from specify_cli.bundles.manifest import ComponentRef result = InstallResult(bundle_id="x") assert result.changed is False # empty == no change diff --git a/tests/integration/test_bundler_offline.py b/tests/specify_cli/bundles/test_offline.py similarity index 91% rename from tests/integration/test_bundler_offline.py rename to tests/specify_cli/bundles/test_offline.py index f85e58e2cf..8acb513fc5 100644 --- a/tests/integration/test_bundler_offline.py +++ b/tests/specify_cli/bundles/test_offline.py @@ -11,10 +11,10 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope -from specify_cli.bundler.services.adapters import make_catalog_fetcher -from specify_cli.bundler.services.catalog_stack import CatalogStack -from tests.bundler_helpers import catalog_entry_dict, write_catalog_file +from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy, Scope +from specify_cli.bundles.adapters import make_catalog_fetcher +from specify_cli.bundles.catalog_stack import CatalogStack +from tests.specify_cli.bundles.helpers import catalog_entry_dict, write_catalog_file def _src(source_id, url, priority=1, policy="install-allowed"): @@ -51,16 +51,16 @@ def test_builtin_default_catalog_resolves_first_party_bundles_offline(): def test_builtin_catalog_failure_does_not_block_lower_priority_source( monkeypatch, source_id, builtin_id, builtin_priority, project_priority ): - from specify_cli.bundler.services import adapters + from specify_cli.bundles import adapters def fail_http_get_json(source_id, url): raise adapters._CatalogUnavailable("repository unavailable") monkeypatch.setattr( - "specify_cli.bundler.services.adapters._http_get_json", fail_http_get_json + "specify_cli.bundles.adapters._http_get_json", fail_http_get_json ) monkeypatch.setattr( - "specify_cli.bundler.services.adapters._load_packaged_catalog", + "specify_cli.bundles.adapters._load_packaged_catalog", lambda filename: {"schema_version": "1.0", "bundles": {}}, ) diff --git a/tests/unit/test_bundler_packager.py b/tests/specify_cli/bundles/test_packager.py similarity index 98% rename from tests/unit/test_bundler_packager.py rename to tests/specify_cli/bundles/test_packager.py index d203f7ffb0..0044e37830 100644 --- a/tests/unit/test_bundler_packager.py +++ b/tests/specify_cli/bundles/test_packager.py @@ -9,8 +9,8 @@ import yaml from specify_cli.bundler import BundlerError -from specify_cli.bundler.services.packager import build_bundle -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.packager import build_bundle +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path: diff --git a/tests/unit/test_bundler_primitives.py b/tests/specify_cli/bundles/test_primitives.py similarity index 97% rename from tests/unit/test_bundler_primitives.py rename to tests/specify_cli/bundles/test_primitives.py index 21666cf1a7..a3b4d83f45 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -12,16 +12,16 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import ComponentRef -from specify_cli.bundler.services.adapters import DefaultPrimitiveInstaller -from specify_cli.bundler.services.primitives import ( +from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.adapters import DefaultPrimitiveInstaller +from specify_cli.bundles.primitives import ( _ExtensionKindManager, _PresetKindManager, _StepKindManager, _WorkflowKindManager, primitive_manager, ) -from tests.bundler_helpers import valid_manifest_dict +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _component(kind: str, cid: str = "x") -> ComponentRef: @@ -109,7 +109,7 @@ def test_offline_workflow_allows_bundled(tmp_path: Path, monkeypatch): def test_assert_pinned_version_matches_passes(): - from specify_cli.bundler.services.primitives import _assert_pinned_version + from specify_cli.bundles.primitives import _assert_pinned_version # Equal (including v-prefix/normalization) is accepted; no version pins are no-ops. _assert_pinned_version("Preset", "p", "2.0.0", "2.0.0") @@ -119,7 +119,7 @@ def test_assert_pinned_version_matches_passes(): def test_assert_pinned_version_mismatch_raises(): - from specify_cli.bundler.services.primitives import _assert_pinned_version + from specify_cli.bundles.primitives import _assert_pinned_version with pytest.raises(BundlerError, match="pinned to version 2.0.0"): _assert_pinned_version("Preset", "preset-a", "2.0.0", "3.1.0") @@ -483,8 +483,8 @@ def _fake_install(self, *a, **k): def test_refresh_succeeds_and_passes_force_true(tmp_path: Path, monkeypatch): """Regression: bundle update (refresh=True) of an already-installed extension must succeed and pass force=True to install_from_directory.""" - from specify_cli.bundler.services.installer import install_bundle - from specify_cli.bundler.models.manifest import BundleManifest + from specify_cli.bundles.installer import install_bundle + from specify_cli.bundles.manifest import BundleManifest import specify_cli._assets as assets from specify_cli.extensions import ExtensionManager @@ -529,8 +529,8 @@ def _fake_install_from_directory(self, *a, **k): def _plan(manifest): - from specify_cli.bundler.services.installer import InstallPlan - from specify_cli.bundler.models.manifest import ComponentRef as CR + from specify_cli.bundles.installer import InstallPlan + from specify_cli.bundles.manifest import ComponentRef as CR components = [CR(kind=c.kind, id=c.id) for c in manifest.components] return InstallPlan( diff --git a/tests/unit/test_bundler_records.py b/tests/specify_cli/bundles/test_records.py similarity index 98% rename from tests/unit/test_bundler_records.py rename to tests/specify_cli/bundles/test_records.py index dc1da118a1..458b0d01f0 100644 --- a/tests/unit/test_bundler_records.py +++ b/tests/specify_cli/bundles/test_records.py @@ -7,8 +7,8 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import ComponentRef -from specify_cli.bundler.models.records import ( +from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.records import ( InstalledBundleRecord, components_still_needed, load_records, diff --git a/tests/unit/test_bundler_references.py b/tests/specify_cli/bundles/test_references.py similarity index 95% rename from tests/unit/test_bundler_references.py rename to tests/specify_cli/bundles/test_references.py index b910a93e99..a020d64a9d 100644 --- a/tests/unit/test_bundler_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -7,9 +7,9 @@ from pathlib import Path -from specify_cli.bundler.models.manifest import ComponentRef -from specify_cli.bundler.services.references import make_reference_checker -from tests.bundler_helpers import make_project +from specify_cli.bundles.manifest import ComponentRef +from specify_cli.bundles.references import make_reference_checker +from tests.specify_cli.bundles.helpers import make_project def _ref(kind: str, id_: str) -> ComponentRef: diff --git a/tests/unit/test_bundler_resolver.py b/tests/specify_cli/bundles/test_resolver.py similarity index 95% rename from tests/unit/test_bundler_resolver.py rename to tests/specify_cli/bundles/test_resolver.py index 4045cc07a3..a8c88c4179 100644 --- a/tests/unit/test_bundler_resolver.py +++ b/tests/specify_cli/bundles/test_resolver.py @@ -4,9 +4,9 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.bundler.services.resolver import resolve_install_plan -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles.resolver import resolve_install_plan +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _manifest(**overrides) -> BundleManifest: diff --git a/tests/integration/test_bundler_security_paths.py b/tests/specify_cli/bundles/test_security_paths.py similarity index 90% rename from tests/integration/test_bundler_security_paths.py rename to tests/specify_cli/bundles/test_security_paths.py index e575dccb88..807650a5ce 100644 --- a/tests/integration/test_bundler_security_paths.py +++ b/tests/specify_cli/bundles/test_security_paths.py @@ -11,7 +11,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath +from specify_cli.bundles.yamlio import ensure_within, is_safe_relpath def test_ensure_within_allows_child(tmp_path: Path): @@ -67,8 +67,8 @@ def test_build_skips_symlinks(tmp_path: Path): """Packager must not follow symlinks out of the bundle dir.""" import yaml - from specify_cli.bundler.services.packager import build_bundle - from tests.bundler_helpers import valid_manifest_dict + from specify_cli.bundles.packager import build_bundle + from tests.specify_cli.bundles.helpers import valid_manifest_dict bundle = tmp_path / "bundle" bundle.mkdir() @@ -94,7 +94,7 @@ def test_build_skips_symlinks(tmp_path: Path): def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path): # Reading bundle-records.json must honour the same confinement as writes: # a symlinked .specify pointing outside project_root is refused. - from specify_cli.bundler.models.records import load_records + from specify_cli.bundles.records import load_records project = tmp_path / "proj" project.mkdir() @@ -112,7 +112,7 @@ def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path): def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path): # Reading the integration marker must not follow a .specify symlink that # resolves outside project_root; an escape is treated as "not determinable". - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = tmp_path / "proj" project.mkdir() @@ -145,7 +145,7 @@ def test_active_integration_reads_default_integration(tmp_path: Path): reader (``integration_state`` line 199: ``state.get("default_integration") or state.get("integration")``). """ - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = _write_marker(tmp_path, '{"default_integration": "copilot"}') assert active_integration(project) == "copilot" @@ -154,7 +154,7 @@ def test_active_integration_reads_default_integration(tmp_path: Path): def test_active_integration_prefers_default_over_legacy_alias(tmp_path: Path): """When both are present the authoritative field wins, matching ``integration_state``'s own ordering.""" - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = _write_marker( tmp_path, '{"integration": "stale", "default_integration": "copilot"}' @@ -164,14 +164,14 @@ def test_active_integration_prefers_default_over_legacy_alias(tmp_path: Path): def test_active_integration_still_reads_legacy_alias(tmp_path: Path): """Projects initialised by older versions carry only ``integration``.""" - from specify_cli.bundler.lib.project import active_integration + from specify_cli.bundles.project import active_integration project = _write_marker(tmp_path, '{"integration": "copilot"}') assert active_integration(project) == "copilot" def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path): - from specify_cli.bundler.commands_impl import catalog_config as cc + from specify_cli.bundles import catalog_config as cc project = tmp_path / "proj" project.mkdir() @@ -187,7 +187,7 @@ def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path): def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path): - from specify_cli.bundler.models.catalog import load_source_stack + from specify_cli.bundles.catalogs import load_source_stack project = tmp_path / "project" project.mkdir() @@ -203,7 +203,7 @@ def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path): def test_find_project_root_ignores_symlinked_specify(tmp_path: Path): - from specify_cli.bundler.lib.project import find_project_root + from specify_cli.bundles.project import find_project_root real = tmp_path / "real-specify" real.mkdir() @@ -221,7 +221,7 @@ def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, """The SPECIFY_INIT_DIR override path refuses a symlinked .specify too, matching the cwd loop path (regression: the override returned early and skipped the symlink guard).""" - from specify_cli.bundler.lib.project import find_project_root + from specify_cli.bundles.project import find_project_root real = tmp_path / "real-specify" real.mkdir() diff --git a/tests/specify_cli/bundles/test_sources.py b/tests/specify_cli/bundles/test_sources.py new file mode 100644 index 0000000000..10b10aa76c --- /dev/null +++ b/tests/specify_cli/bundles/test_sources.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import hashlib +import io +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.bundles import BundlerError +from specify_cli.bundles import sources as bundle_sources +from specify_cli.bundles.catalogs import CatalogEntry +from specify_cli.bundles.sources import ( + _download_manifest, + _local_manifest_source, + _require_https, +) +from tests.specify_cli.bundles.helpers import ( + catalog_entry_dict, + valid_manifest_dict, + write_manifest, +) + +runner = CliRunner() + +_MALFORMED_URLS = [ + "https://[::1", # unclosed IPv6 bracket + "https://[not-an-ip]/bundle.yml", + "https://example.com:notaport/bundle.yml", + "https://example.com:70000/bundle.yml", +] + + +class _Response(io.BytesIO): + def __init__(self, body: bytes, url: str) -> None: + super().__init__(body) + self._url = url + + def geturl(self) -> str: + return self._url + + +def _resolved_entry(**overrides) -> SimpleNamespace: + entry = CatalogEntry.from_dict( + catalog_entry_dict( + "demo-bundle", + download_url="https://example.com/demo-bundle.yml", + **overrides, + ) + ) + return SimpleNamespace(entry=entry) + + +def _patch_download(monkeypatch, body: bytes) -> None: + def fake_open_url( + url, + timeout=10, + extra_headers=None, + redirect_validator=None, + ): + return _Response(body, url) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + +def test_local_source_none_for_non_path(): + assert _local_manifest_source("some-catalog-bundle-id") is None + + +def test_local_source_from_directory(tmp_path: Path): + write_manifest(tmp_path, valid_manifest_dict()) + manifest = _local_manifest_source(str(tmp_path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_from_bundle_yml(tmp_path: Path): + path = write_manifest(tmp_path, valid_manifest_dict()) + manifest = _local_manifest_source(str(path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_from_zip_artifact(tmp_path: Path): + bundle_dir = tmp_path / "bundle" + bundle_dir.mkdir() + write_manifest(bundle_dir, valid_manifest_dict()) + (bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)]) + assert result.exit_code == 0, result.output + artifact = next(bundle_dir.glob("*.zip")) + + manifest = _local_manifest_source(str(artifact)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_local_source_rejects_unknown_file(tmp_path: Path): + weird = tmp_path / "thing.txt" + weird.write_text("nope", encoding="utf-8") + with pytest.raises(BundlerError, match="not a recognised bundle source"): + _local_manifest_source(str(weird)) + + +def test_local_source_zip_non_utf8_manifest_raises_bundler_error(tmp_path: Path): + """Undecodable bundle.yml bytes inside a .zip must raise BundlerError. + + The manifest bytes are decoded as UTF-8 explicitly, matching + ``yamlio.load_yaml``'s "Could not read ..." contract, instead of + escaping as a raw ``UnicodeDecodeError``/``ReaderError`` traceback. + """ + artifact = tmp_path / "demo.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", b"\xff\xfe bundle \xc3\x28\n") + + with pytest.raises(BundlerError, match="Could not read"): + _local_manifest_source(str(artifact)) + + +def test_local_source_zip_utf16_manifest_rejected_like_directory(tmp_path: Path): + """A well-formed UTF-16 manifest must fail the same way in a .zip. + + ``yamlio.load_yaml`` decodes strictly as UTF-8, so a UTF-16 bundle.yml + (the realistic PowerShell ``Out-File`` output) is rejected when read + from a directory. Feeding the zip bytes straight to PyYAML would let + its Reader honour the UTF-16 BOM and *accept* the same manifest, + making zip and directory sources diverge. + """ + artifact = tmp_path / "demo.zip" + manifest_text = "bundle:\n id: demo-bundle\n version: 1.0.0\n" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", manifest_text.encode("utf-16")) + + with pytest.raises(BundlerError, match="Could not read"): + _local_manifest_source(str(artifact)) + + +def test_download_manifest_rejects_file_url(tmp_path: Path): + """A catalog ``file://`` download_url is rejected — catalog URLs are + HTTPS-only, matching extensions/presets/workflows. Disk installs go through + the positional path (see the local-source tests above), not download_url. + """ + from types import SimpleNamespace + + from specify_cli.bundles.sources import _download_manifest + + manifest_path = write_manifest(tmp_path / "my bundles") + resolved = SimpleNamespace( + entry=SimpleNamespace(id="demo-bundle", download_url=manifest_path.as_uri()) + ) + + with pytest.raises(BundlerError, match="bundle install"): + _download_manifest(resolved, offline=True) + + +def test_download_manifest_rejects_bare_path(tmp_path: Path): + """A bare filesystem path download_url is likewise rejected.""" + from types import SimpleNamespace + + from specify_cli.bundles.sources import _download_manifest + + manifest_path = write_manifest(tmp_path / "plain") + resolved = SimpleNamespace( + entry=SimpleNamespace(id="demo-bundle", download_url=str(manifest_path)) + ) + + with pytest.raises(BundlerError, match="bundle install"): + _download_manifest(resolved, offline=True) + + +def test_local_install_still_resolves_via_positional_path(tmp_path: Path): + """The supported local route — a positional path, not a download_url — + still resolves the manifest via _local_manifest_source.""" + manifest_path = write_manifest(tmp_path / "my bundles") + manifest = _local_manifest_source(str(manifest_path)) + assert manifest is not None + assert manifest.bundle.id == "demo-bundle" + + +def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path): + """A non-HTTPS download_url must report the HTTPS problem, not a misleading + 'Network access disabled', even under --offline (scheme is validated before + the offline gate).""" + from types import SimpleNamespace + + from specify_cli.bundles.sources import _download_manifest + + resolved = SimpleNamespace( + entry=SimpleNamespace( + id="demo-bundle", download_url="http://example.com/bundle.zip" + ) + ) + with pytest.raises(BundlerError, match="HTTPS"): + _download_manifest(resolved, offline=True) + + +def test_local_zip_uses_bounded_archive_open(tmp_path: Path): + artifact = tmp_path / "too-many-entries.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict())) + for index in range(512): + archive.writestr(f"assets/{index}.txt", "") + + with pytest.raises(BundlerError, match="too many entries"): + _local_manifest_source(str(artifact)) + + +def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path): + """A malformed bundle.yml inside a .zip must raise BundlerError. + + The zip branch parses YAML inline rather than through load_yaml(), so the + raw yaml.YAMLError used to escape. It is neither a ValueError nor an + OSError, so nothing upstream caught it. + """ + artifact = tmp_path / "bad-manifest.zip" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n") + + with pytest.raises(BundlerError, match="Invalid YAML"): + _local_manifest_source(str(artifact)) + + +@pytest.mark.parametrize("url", _MALFORMED_URLS) +def test_download_manifest_rejects_malformed_url_cleanly(url): + """A malformed download_url must raise BundlerError, not a raw ValueError. + + ``urlparse`` raises ``ValueError`` on a malformed authority (e.g. an + unclosed IPv6 bracket). The bundle CLI commands only catch BundlerError, so + a raw ValueError would escape as an uncaught traceback. Sibling of the + guarded ``_validate_remote_url`` (adapters) and the merged #3576 fix. + """ + resolved = SimpleNamespace(entry=SimpleNamespace(id="mybundle", download_url=url)) + with pytest.raises(BundlerError): + _download_manifest(resolved, offline=True) + + +@pytest.mark.parametrize("url", _MALFORMED_URLS) +def test_require_https_rejects_malformed_url_cleanly(url): + """``_require_https`` must also surface BundlerError on a malformed authority. + + On older Python versions the ValueError is raised at ``.hostname`` access + rather than at ``urlparse``, so guarding both keeps the contract across the + CI Python matrix. + """ + with pytest.raises(BundlerError): + _require_https("bundle 'x'", url) + + +def test_download_manifest_bounds_remote_artifact(monkeypatch): + body = yaml.safe_dump(valid_manifest_dict()).encode() + _patch_download(monkeypatch, body) + monkeypatch.setattr(bundle_sources, "MAX_DOWNLOAD_BYTES", len(body) - 1) + + with pytest.raises(BundlerError, match="exceeds maximum size"): + _download_manifest(_resolved_entry(), offline=False) + + +def test_download_manifest_accepts_matching_sha256(monkeypatch): + body = yaml.safe_dump(valid_manifest_dict()).encode() + digest = hashlib.sha256(body).hexdigest() + _patch_download(monkeypatch, body) + + manifest = _download_manifest( + _resolved_entry(sha256=f"sha256:{digest}"), + offline=False, + ) + + assert manifest.bundle.id == "demo-bundle" + + +def test_download_manifest_accepts_legacy_entry_without_sha256(monkeypatch): + body = yaml.safe_dump(valid_manifest_dict()).encode() + _patch_download(monkeypatch, body) + resolved = SimpleNamespace( + entry=SimpleNamespace( + id="demo-bundle", + version="1.2.0", + download_url="https://example.com/demo-bundle.yml", + ) + ) + + manifest = _download_manifest(resolved, offline=False) + + assert manifest.bundle.version == "1.2.0" + + +@pytest.mark.parametrize("declared", ["0" * 64, "not-a-sha256"]) +def test_download_manifest_rejects_bad_sha256(monkeypatch, declared): + body = yaml.safe_dump(valid_manifest_dict()).encode() + _patch_download(monkeypatch, body) + + with pytest.raises(BundlerError, match="sha256|Integrity check"): + _download_manifest( + _resolved_entry(sha256=declared), + offline=False, + ) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("id", "other-bundle", "id mismatch"), + ("version", "9.9.9", "version mismatch"), + ], +) +def test_download_manifest_rejects_catalog_identity_mismatch( + monkeypatch, + field, + value, + message, +): + data = valid_manifest_dict() + data["bundle"][field] = value + _patch_download(monkeypatch, yaml.safe_dump(data).encode()) + + with pytest.raises(BundlerError, match=message): + _download_manifest(_resolved_entry(), offline=False) + + +def test_download_manifest_rejects_invalid_structure(monkeypatch): + data = valid_manifest_dict() + data["bundle"]["author"] = "" + _patch_download(monkeypatch, yaml.safe_dump(data).encode()) + + with pytest.raises(BundlerError, match="invalid bundle manifest"): + _download_manifest(_resolved_entry(), offline=False) diff --git a/tests/unit/test_bundler_validator.py b/tests/specify_cli/bundles/test_validator.py similarity index 79% rename from tests/unit/test_bundler_validator.py rename to tests/specify_cli/bundles/test_validator.py index d69c6535e5..855fb80cc1 100644 --- a/tests/unit/test_bundler_validator.py +++ b/tests/specify_cli/bundles/test_validator.py @@ -3,10 +3,10 @@ import pytest -from specify_cli.bundler.models.manifest import BundleManifest -from specify_cli.bundler.services import validator as validator_mod -from specify_cli.bundler.services.validator import validate_manifest -from tests.bundler_helpers import valid_manifest_dict +from specify_cli.bundles.manifest import BundleManifest +from specify_cli.bundles import validator as validator_mod +from specify_cli.bundles.validator import validate_manifest +from tests.specify_cli.bundles.helpers import valid_manifest_dict def _manifest(**overrides) -> BundleManifest: diff --git a/tests/unit/test_bundler_versioning.py b/tests/specify_cli/bundles/test_versioning.py similarity index 93% rename from tests/unit/test_bundler_versioning.py rename to tests/specify_cli/bundles/test_versioning.py index 15c42ea673..aa6dfc1494 100644 --- a/tests/unit/test_bundler_versioning.py +++ b/tests/specify_cli/bundles/test_versioning.py @@ -4,7 +4,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.lib.versioning import is_semver, satisfies +from specify_cli.bundles.versioning import is_semver, satisfies @pytest.mark.parametrize("value,expected", [ @@ -63,6 +63,6 @@ def test_satisfies_prerelease_in_constraint(installed, constraint, ok): def test_parse_constraint_empty_is_permissive(): - from specify_cli.bundler.lib.versioning import parse_constraint + from specify_cli.bundles.versioning import parse_constraint assert str(parse_constraint("")) == "" diff --git a/tests/unit/test_bundler_yamlio.py b/tests/specify_cli/bundles/test_yamlio.py similarity index 96% rename from tests/unit/test_bundler_yamlio.py rename to tests/specify_cli/bundles/test_yamlio.py index b3e8e592e4..23f78546c9 100644 --- a/tests/unit/test_bundler_yamlio.py +++ b/tests/specify_cli/bundles/test_yamlio.py @@ -6,7 +6,7 @@ import pytest from specify_cli.bundler import BundlerError -from specify_cli.bundler.lib.yamlio import dump_yaml, load_json, load_yaml +from specify_cli.bundles.yamlio import dump_yaml, load_json, load_yaml def test_dump_yaml_preserves_unicode(tmp_path: Path): 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..d2fc2ea646 --- /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 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/_scaffold_helpers.py b/tests/specify_cli/integrations/_scaffold_helpers.py new file mode 100644 index 0000000000..2d4f28f49b --- /dev/null +++ b/tests/specify_cli/integrations/_scaffold_helpers.py @@ -0,0 +1,16 @@ +"""Shared setup for integration scaffold 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/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..68cd432882 --- /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 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/integrations/test_integration_catalog.py b/tests/specify_cli/integrations/test_catalog.py similarity index 86% rename from tests/integrations/test_integration_catalog.py rename to tests/specify_cli/integrations/test_catalog.py index c414c3d8ea..3d0c4a5e23 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/specify_cli/integrations/test_catalog.py @@ -1,14 +1,13 @@ -"""Tests for the integration catalog system (catalog.py).""" +"""Tests for the integration package's catalog domain API.""" import json -import os import pytest import yaml 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, @@ -335,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)) @@ -380,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", @@ -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/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..4a129856bf --- /dev/null +++ b/tests/specify_cli/integrations/test_command_scaffold.py @@ -0,0 +1,92 @@ +"""Tests for the ``specify integration scaffold`` command.""" + +from typer.testing import CliRunner + +from specify_cli import app +from tests.conftest import strip_ansi +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) + + 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.integrations._command_scaffold_generation 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/integrations/test_integration_scaffold.py b/tests/specify_cli/integrations/test_command_scaffold_generation.py similarity index 54% rename from tests/integrations/test_integration_scaffold.py rename to tests/specify_cli/integrations/test_command_scaffold_generation.py index f38ea824d5..1385756f66 100644 --- a/tests/integrations/test_integration_scaffold.py +++ b/tests/specify_cli/integrations/test_command_scaffold_generation.py @@ -1,62 +1,11 @@ -"""Tests for integration scaffolding commands.""" +"""Tests for the integration scaffold generation phase.""" 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 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"), @@ -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/specify_cli/integrations/test_command_search.py b/tests/specify_cli/integrations/test_command_search.py new file mode 100644 index 0000000000..d478a95108 --- /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 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 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 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..6ef4256f74 --- /dev/null +++ b/tests/specify_cli/integrations/test_registration.py @@ -0,0 +1,186 @@ +"""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_catalog_package_preserves_domain_import_compatibility(): + from specify_cli.integrations import IntegrationCatalog + from specify_cli.integrations.catalog import ( + IntegrationCatalog as CompatibilityIntegrationCatalog, + ) + + assert CompatibilityIntegrationCatalog is IntegrationCatalog + + +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" + + +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/specify_cli/presets/__init__.py b/tests/specify_cli/presets/__init__.py new file mode 100644 index 0000000000..13f104b1c1 --- /dev/null +++ b/tests/specify_cli/presets/__init__.py @@ -0,0 +1 @@ +"""Tests for the preset CLI command hierarchy.""" diff --git a/tests/specify_cli/presets/_fixtures.py b/tests/specify_cli/presets/_fixtures.py new file mode 100644 index 0000000000..b75c518baf --- /dev/null +++ b/tests/specify_cli/presets/_fixtures.py @@ -0,0 +1,82 @@ +"""Shared pytest fixtures for preset domain and command suites.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +import pytest +import yaml + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def valid_pack_data(): + """Return valid preset manifest data.""" + return { + "schema_version": "1.0", + "preset": { + "id": "test-pack", + "name": "Test Preset", + "version": "1.0.0", + "description": "A test preset", + "author": "Test Author", + "repository": "https://github.com/test/test-pack", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "description": "Custom spec template", + "replaces": "spec-template", + } + ] + }, + "tags": ["testing", "example"], + } + + +@pytest.fixture +def pack_dir(temp_dir, valid_pack_data): + """Create a complete preset directory structure.""" + preset_dir = temp_dir / "test-pack" + preset_dir.mkdir() + (preset_dir / "preset.yml").write_text( + yaml.safe_dump(valid_pack_data), encoding="utf-8" + ) + templates_dir = preset_dir / "templates" + templates_dir.mkdir() + (templates_dir / "spec-template.md").write_text( + "# Custom Spec Template\n\nThis is a custom template.\n", + encoding="utf-8", + ) + return preset_dir + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + project = temp_dir / "project" + project.mkdir() + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + (templates_dir / "spec-template.md").write_text( + "# Core Spec Template\n", encoding="utf-8" + ) + (templates_dir / "plan-template.md").write_text( + "# Core Plan Template\n", encoding="utf-8" + ) + (templates_dir / "commands").mkdir() + return project diff --git a/tests/specify_cli/presets/_helpers.py b/tests/specify_cli/presets/_helpers.py new file mode 100644 index 0000000000..dc3661c055 --- /dev/null +++ b/tests/specify_cli/presets/_helpers.py @@ -0,0 +1,128 @@ +"""Shared helpers for preset domain and command tests.""" + +from __future__ import annotations + +import json +import warnings +from datetime import UTC, datetime +from pathlib import Path + +import yaml + +from specify_cli.presets import ( + PresetCatalog, + PresetCatalogEntry, + PresetManager, + PresetManifest, +) + +REPO_ROOT = Path(__file__).parents[3] +SELF_TEST_PRESET_DIR = REPO_ROOT / "presets" / "self-test" +CONSTITUTION_SYNC_PRESET_DIR = REPO_ROOT / "presets" / "constitution-sync" +SELF_TEST_WRAP_WARNING = ( + r"Cannot compose command 'speckit\.wrap-test': no base layer\. " + r"Stale command files may remain\." +) + +CORE_TEMPLATE_NAMES = [ + "spec-template", + "plan-template", + "tasks-template", + "checklist-template", + "constitution-template", +] + + +def seed_catalog( + project_dir: Path, + tags: object, + extra: dict[str, object] | None = None, +) -> PresetCatalog: + """Seed cached catalog metadata used by search and info command tests.""" + catalog = PresetCatalog(project_dir) + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + pack = { + "name": "Numeric Tags", + "description": "Preset with non-string tags", + "version": "1.0.0", + "tags": tags, + } + if extra: + pack.update(extra) + catalog.cache_file.write_text( + json.dumps( + { + "schema_version": "1.0", + "presets": {"numeric-tags": pack}, + } + ) + ) + catalog.cache_metadata_file.write_text( + json.dumps({"cached_at": datetime.now(UTC).isoformat()}) + ) + return catalog + + +def default_catalog_entries(catalog: PresetCatalog) -> list[PresetCatalogEntry]: + """Return the default catalog as the only active catalog.""" + return [ + PresetCatalogEntry( + url=catalog.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + ) + ] + + +def install_self_test_preset( + manager: PresetManager, speckit_version: str = "0.1.5" +) -> PresetManifest: + """Install self-test while filtering its intentionally missing wrap base.""" + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=SELF_TEST_WRAP_WARNING, + category=UserWarning, + module=r"specify_cli\.presets", + ) + return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) + + +def install_constitution_sync_preset(manager: PresetManager) -> PresetManifest: + """Enable guarded install-time constitution materialization.""" + return manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") + + +def make_convention_constitution_preset(temp_dir: Path) -> Path: + """Create a preset whose constitution is found by convention.""" + preset_dir = temp_dir / "convention-constitution" + (preset_dir / "templates").mkdir(parents=True) + (preset_dir / "templates" / "constitution-template.md").write_text( + "# Convention Constitution\n" + ) + (preset_dir / "templates" / "spec-template.md").write_text("# Spec\n") + (preset_dir / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "convention-constitution", + "name": "Convention Constitution", + "version": "1.0.0", + "description": "Convention-based constitution for testing", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + ] + }, + } + ) + ) + return preset_dir diff --git a/tests/specify_cli/presets/catalog/__init__.py b/tests/specify_cli/presets/catalog/__init__.py new file mode 100644 index 0000000000..4774695ccf --- /dev/null +++ b/tests/specify_cli/presets/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests for preset catalog commands.""" diff --git a/tests/specify_cli/presets/catalog/test_command_add.py b/tests/specify_cli/presets/catalog/test_command_add.py new file mode 100644 index 0000000000..1b6fae541a --- /dev/null +++ b/tests/specify_cli/presets/catalog/test_command_add.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + + +class TestPresetCatalogAdd: + """Test multi-catalog support in PresetCatalog.""" + + def test_catalog_add_escapes_rich_markup(self, project_dir): + """`preset catalog add` must not parse the name/url as Rich markup. + + An unbalanced closing tag raised MarkupError *after* the entry was + already written to preset-catalogs.yml, so the user saw a traceback + and no confirmation for a catalog that had in fact been added. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + name = "[/red]my-catalog" + url = "https://example.com/[bold]c.json" + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["preset", "catalog", "add", url, "--name", name] + ) + assert result.exit_code == 0, result.output + # Rendered verbatim, not swallowed as markup. + assert name in result.output + assert url in result.output + # Only rendering is escaped: the raw values still round-trip to disk. + config = yaml.safe_load( + (project_dir / ".specify" / "preset-catalogs.yml").read_text( + encoding="utf-8" + ) + ) + assert config["catalogs"][0]["name"] == name + assert config["catalogs"][0]["url"] == url + + @pytest.mark.parametrize( + "args", + [ + [ + "preset", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "example", + ], + ["preset", "catalog", "remove", "example"], + ], + ) + def test_catalog_mutation_rejects_non_mapping_config_root(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + config_path = project_dir / ".specify" / "preset-catalogs.yml" + original = "[]\n" + config_path.write_text(original, encoding="utf-8") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, args) + + assert result.exit_code == 1 + assert "expected a mapping" in result.output + assert config_path.read_text(encoding="utf-8") == original diff --git a/tests/specify_cli/presets/catalog/test_command_list.py b/tests/specify_cli/presets/catalog/test_command_list.py new file mode 100644 index 0000000000..21c2aec71f --- /dev/null +++ b/tests/specify_cli/presets/catalog/test_command_list.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetCatalog, + PresetCatalogEntry, +) + + +class TestPresetCatalogList: + """Test multi-catalog support in PresetCatalog.""" + + def test_catalog_list_escapes_rich_markup(self, project_dir): + """User-editable catalog name/url/description must not be parsed as Rich markup.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + entry = PresetCatalogEntry( + url="https://example.com/[cat].json", + name="Bracket [Catalog]", + priority=1, + install_allowed=True, + description="desc [with] brackets", + ) + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]), + ): + result = runner.invoke(app, ["preset", "catalog", "list"]) + 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/presets/catalog/test_command_remove.py b/tests/specify_cli/presets/catalog/test_command_remove.py new file mode 100644 index 0000000000..4466232d2a --- /dev/null +++ b/tests/specify_cli/presets/catalog/test_command_remove.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + + +class TestPresetCatalogRemove: + """Test multi-catalog support in PresetCatalog.""" + + def test_catalog_remove_escapes_rich_markup(self, project_dir): + """`preset catalog remove` must not parse the name as Rich markup.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + name = "[/red]my-catalog" + (project_dir / ".specify" / "preset-catalogs.yml").write_text( + yaml.dump( + { + "catalogs": [ + { + "name": name, + "url": "https://example.com/c.json", + "priority": 1, + "install_allowed": False, + } + ] + } + ), + encoding="utf-8", + ) + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "catalog", "remove", name]) + assert result.exit_code == 0, result.output + assert name in result.output + + def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir): + """The not-found error path renders the name too.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + (project_dir / ".specify" / "preset-catalogs.yml").write_text( + yaml.dump({"catalogs": []}), encoding="utf-8" + ) + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "catalog", "remove", "[/red]absent"]) + assert result.exit_code == 1 + assert "[/red]absent" in result.output diff --git a/tests/specify_cli/presets/conftest.py b/tests/specify_cli/presets/conftest.py new file mode 100644 index 0000000000..e8cdb4f34b --- /dev/null +++ b/tests/specify_cli/presets/conftest.py @@ -0,0 +1,10 @@ +"""Load shared fixtures for mirrored preset command tests.""" + +from __future__ import annotations + +from . import _fixtures + +temp_dir = _fixtures.temp_dir +valid_pack_data = _fixtures.valid_pack_data +pack_dir = _fixtures.pack_dir +project_dir = _fixtures.project_dir diff --git a/tests/specify_cli/presets/test_command_add.py b/tests/specify_cli/presets/test_command_add.py new file mode 100644 index 0000000000..ae46f2bb9d --- /dev/null +++ b/tests/specify_cli/presets/test_command_add.py @@ -0,0 +1,991 @@ +from __future__ import annotations + +import io +import json +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import ANY, MagicMock + +import pytest +import yaml + +from specify_cli._console import console +from specify_cli.presets import ( + PresetCatalog, + PresetCompatibilityError, + PresetError, + PresetManager, + PresetValidationError, +) +from specify_cli.presets._commands import _warn_unmet_extension_dependencies +from tests.conftest import strip_ansi + + +class TestPresetAddDependencyWarnings: + """Test find_unmet_extension_dependencies (issue #4231).""" + + def test_version_warning_does_not_promise_update_satisfies_constraint(self): + """Version remediation must handle constraints update cannot guarantee.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "3.0.0", + "version": "<2", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "Needs: a release of speckit-inventory satisfying <2" in output + assert "specify extension update" not in output + + def test_missing_and_stale_warnings_mention_discovery_only_catalogs(self): + """`extension add ` is rejected for discovery-only entries, so say so.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "missing", + "installed": None, + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "discovery-only catalog" in output + assert "--from " in output + assert "Install with: specify extension add speckit-inventory" in output + + @pytest.mark.parametrize( + "reason, extra", + [ + ("missing", {"installed": None, "version": None}), + ("stale", {"installed": "0.1.0", "version": None}), + ("disabled", {"installed": "0.1.0", "version": None}), + ("version", {"installed": "0.1.0", "version": ">=9.0.0"}), + ], + ) + def test_leading_hyphen_id_is_not_emitted_into_a_command(self, reason, extra): + """A leading-hyphen id satisfies `^[a-z0-9-]+$` but breaks the command. + + Typer would read it as an option rather than the positional extension + argument, so the advertised fix would fail. Every remedy substitutes + the placeholder `_command_safe_id` returns. The id here is deliberately + not a real flag, so a match cannot be confused with `--force` appearing + legitimately in the stale remedy. + """ + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + {"id": "--not-a-real-flag", "reason": reason, **extra} + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + # Isolate the remedy: the description line legitimately shows the raw + # id, escaped for display; only the copyable command must not carry it. + label = next( + lbl + for lbl in ("Install with:", "Reinstall with:", "Enable with:", "Needs:") + if lbl in output + ) + remedy = output.split(label, 1)[1].split("The preset is installed.")[0] + assert "--not-a-real-flag" not in remedy + assert "" in remedy + + def test_version_only_warning_omits_the_discovery_only_note(self): + """The note is about installing by id, which a version mismatch does not do.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "0.1.0", + "version": ">=9.0.0", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + assert "discovery-only" not in strip_ansi(capture.get()) + + def test_corrupt_warning_suggests_forced_reinstall(self): + """The corrupt remedy must use --force, since the id is still registered.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "corrupt", + "installed": None, + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "unreadable registry entry" in output + assert ( + "Reinstall with: specify extension add speckit-inventory --force" in output + ) + + def test_version_only_footer_does_not_claim_the_feature_is_inert(self): + """A version mismatch still invokes the extension, so wording differs.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "version", + "installed": "0.1.0", + "version": ">=9.0.0", + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "may not behave as the preset expects" in output + assert "does nothing" not in output + assert "safe to use" not in output + + def test_unavailable_footer_states_the_feature_is_inert(self): + """An unavailable extension genuinely contributes nothing.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "missing", + "installed": None, + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = " ".join(strip_ansi(capture.get()).split()) + assert "does nothing" in output + assert "may not behave as the preset expects" not in output + + def test_stale_warning_suggests_a_forced_reinstall(self): + """The stale remedy must restore the files, not re-add a registered id.""" + manager = MagicMock() + manager.find_unmet_extension_dependencies.return_value = [ + { + "id": "speckit-inventory", + "reason": "stale", + "installed": "0.1.0", + "version": None, + } + ] + + with console.capture() as capture: + _warn_unmet_extension_dependencies(manager, MagicMock()) + + output = strip_ansi(capture.get()) + assert "its files are missing" in output + assert "specify extension add speckit-inventory --force" in output + + +class TestPresetAdd: + """Tests for _locate_bundled_preset discovery function.""" + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="0.6.0"), + ): + result = runner.invoke(app, ["preset", "add", "lean"]) + + assert result.exit_code == 0, result.output + assert "Lean Workflow" in result.output + assert "installed" in result.output.lower() + + def test_preset_add_catalog_forwards_catalog_name(self, project_dir, monkeypatch): + """Catalog installs pass resolved provenance into the manager boundary.""" + from specify_cli.presets._commands import preset_add + + captured = {} + + def fake_install_from_zip( + self, _archive, _version, priority=10, *, catalog_name=None + ): + captured.update(priority=priority, catalog_name=catalog_name) + return SimpleNamespace(name="Catalog Preset", version="1.0.0") + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "1.0.0") + monkeypatch.setattr( + PresetCatalog, + "get_pack_info", + lambda _self, _id: { + "name": "Catalog Preset", + "_install_allowed": True, + "_catalog_name": "preset-catalog", + }, + ) + archive = project_dir / "preset.zip" + archive.write_bytes(b"archive") + monkeypatch.setattr(PresetCatalog, "download_pack", lambda _self, _id: archive) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + preset_add(preset_id="catalog-preset", from_url=None, dev=None, priority=7) + + assert captured == {"priority": 7, "catalog_name": "preset-catalog"} + + def test_preset_add_uses_legacy_dependency_warning_seam( + self, project_dir, pack_dir, monkeypatch + ): + """The extracted handler must honor patches at the legacy helper path.""" + from specify_cli.presets import _commands as preset_commands + + manifest = SimpleNamespace(name="Test Preset", version="1.0.0") + warning = MagicMock() + monkeypatch.setattr( + "specify_cli._require_specify_project", lambda: project_dir + ) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "1.0.0") + monkeypatch.setattr( + PresetManager, + "install_from_directory", + lambda _self, _path, _version, _priority: manifest, + ) + monkeypatch.setattr( + preset_commands, + "_warn_unmet_extension_dependencies", + warning, + ) + + preset_commands.preset_add( + preset_id=None, + from_url=None, + dev=str(pack_dir), + priority=10, + ) + + warning.assert_called_once_with(ANY, manifest) + + def test_preset_add_from_url_rejects_insecure_redirect( + self, project_dir, monkeypatch + ): + """URL installs reject redirects from HTTPS to non-loopback HTTP.""" + import typer + + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "http://example.com/preset.zip" + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + assert redirect_validator is not None + redirect_validator(url, "http://example.com/preset.zip") + return FakeResponse(b"zip") + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + installed = False + + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + nonlocal installed + installed = True + + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + with pytest.raises(typer.Exit) as exc_info: + preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + assert installed is False + + def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir): + """URL installs reject HTTPS URLs without a hostname before downloading.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, ["preset", "add", "--from", "https:///preset.zip"] + ) + + assert result.exit_code == 1 + output = strip_ansi(result.output) + assert "URL must use HTTPS with a hostname" in output + assert "got https://" not in output + open_url.assert_not_called() + + def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir): + """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, + ["preset", "add", "--from", "https://[::1/preset.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Invalid URL" in output + open_url.assert_not_called() + + def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir): + """A bracketed-but-invalid IPv6 host in --from must exit cleanly. + + "https://[not-an-ip]/preset.zip" is a malformed authority that raises + ValueError during URL validation; the try/except guard around parsing + and the .hostname read must turn that into a clean "Invalid URL" message. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, + ["preset", "add", "--from", "https://[not-an-ip]/preset.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Invalid URL" in output + open_url.assert_not_called() + + def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir): + """An out-of-range port raises ValueError lazily on .port access. + + The up-front guard reads ``_parsed.port`` (urllib validates the port + range/syntax there) inside its try/except, so "https://example.com:99999/ + preset.zip" must produce a clean "Invalid URL" message rather than + leaking a raw ValueError traceback past the CLI. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.authentication.http.open_url") as open_url, + ): + result = runner.invoke( + app, + ["preset", "add", "--from", "https://example.com:99999/preset.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + assert "Invalid URL" in strip_ansi(result.output) + open_url.assert_not_called() + + def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir): + """A catalog download_url with a bracketed non-IP host must render cleanly. + + ``download_pack`` raises ``PresetError`` whose message embeds the raw URL + (e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape + that message before printing so Rich does not interpret ``[not-an-ip]`` + as a markup tag and crash while rendering the error. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + bad_url = "https://[not-an-ip]/x" + catalog_data = { + "test-pack": { + "name": "Test Pack", + "version": "1.0.0", + "download_url": bad_url, + } + } + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data), + ): + result = runner.invoke( + app, + ["preset", "add", "test-pack"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + output = strip_ansi(result.output) + assert "Error:" in output + # The malformed URL surfaces verbatim rather than crashing the renderer. + assert bad_url in output + + @pytest.mark.parametrize( + ("exc_type", "label"), + [ + (PresetCompatibilityError, "Compatibility Error"), + (PresetValidationError, "Validation Error"), + (PresetError, "Error"), + ], + ) + def test_preset_add_exception_handlers_escape_markup( + self, project_dir, exc_type, label + ): + """Preset install exceptions can include catalog-controlled values. + + The message must be escaped so Rich does not treat bracketed content as + markup and raise while rendering the error. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + dev_dir = project_dir / "dev-pack" + dev_dir.mkdir() + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetManager, + "install_from_directory", + side_effect=exc_type("bad [red]preset[/red]"), + ), + ): + result = runner.invoke( + app, + ["preset", "add", "--dev", str(dev_dir)], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + assert f"{label}:" in result.output + assert "bad [red]preset[/red]" in result.output + + def test_preset_add_from_url_redirect_error_describes_disallowed_url( + self, project_dir, monkeypatch, capsys + ): + """Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs.""" + import typer + + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https:///preset.zip" + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: ( + FakeResponse(b"zip") + ), + ) + monkeypatch.setattr( + PresetManager, "install_from_zip", lambda *args, **kwargs: None + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + assert "redirected to a disallowed URL" in output + assert "must use HTTPS with a hostname" in output + + def test_preset_add_from_url_reads_in_bounded_chunks( + self, project_dir, monkeypatch + ): + """URL installs read the response in bounded chunks.""" + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __init__(self, data): + super().__init__(data) + self.read_sizes = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https://example.com/preset.zip" + + def read(self, size=-1): + assert size not in (-1, None) + self.read_sizes.append(size) + return super().read(size) + + response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18) + installed = {} + + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + installed["zip_bytes"] = Path(zip_path).read_bytes() + installed["speckit_version"] = speckit_version + installed["priority"] = priority + return SimpleNamespace(name="Test Preset", version="1.0.0") + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda url, timeout=None, extra_headers=None, redirect_validator=None: ( + response + ), + ) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=7, + ) + + assert response.read_sizes + assert installed == { + "zip_bytes": b"PK\x05\x06" + b"\x00" * 18, + "speckit_version": "0.6.0", + "priority": 7, + } + + def test_preset_add_from_url_rejects_oversized_download( + self, project_dir, monkeypatch, capsys + ): + """An oversized direct download fails before preset installation.""" + import typer + + from specify_cli._download_security import ( + read_response_limited as real_read_response_limited, + ) + from specify_cli.presets import _commands as preset_commands + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https://example.com/preset.zip" + + def read_with_tiny_limit(response, **kwargs): + kwargs.pop("max_bytes", None) + return real_read_response_limited(response, max_bytes=4, **kwargs) + + installed = False + + def fake_install_from_zip(*_args, **_kwargs): + nonlocal installed + installed = True + + monkeypatch.setattr( + preset_commands, + "read_response_limited", + read_with_tiny_limit, + ) + monkeypatch.setattr( + "specify_cli._require_specify_project", + lambda: project_dir, + ) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr( + "specify_cli.authentication.http.open_url", + lambda *_args, **_kwargs: FakeResponse(b"12345"), + ) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + with pytest.raises(typer.Exit) as exc_info: + preset_commands.preset_add( + preset_id=None, + from_url="https://example.com/preset.zip", + dev=None, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + output = " ".join(strip_ansi(capsys.readouterr().out).split()) + assert "exceeds maximum size of 4 bytes" in output + assert installed is False + + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli._locate_bundled_preset", return_value=None), + patch("specify_cli.presets.PresetCatalog") as MockCatalog, + ): + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + result = runner.invoke(app, ["preset", "add", "lean"]) + + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. + assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output + + +class TestPresetAddFromUrlResolution: + """CLI-level tests for preset add --from GitHub release resolution.""" + + def test_preset_add_from_github_release_url_resolves_and_downloads( + self, project_dir + ): + """'preset add --from ' resolves to API asset URL.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manifest_content = yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "my-preset", + "name": "My Preset", + "version": "1.0.0", + "description": "Test preset", + "author": "Test", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "t", + "file": "templates/t.md", + "description": "t", + } + ] + }, + } + ) + zip_buf = __import__("io").BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return io.BytesIO( + json.dumps( + { + "assets": [ + { + "name": "preset.zip", + "url": "https://api.github.com/repos/org/repo/releases/assets/42", + } + ] + } + ).encode() + ) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="1.0.0"), + patch( + "specify_cli.authentication.http.open_url", side_effect=fake_open_url + ), + ): + result = runner.invoke( + app, + [ + "preset", + "add", + "--from", + "https://github.com/org/repo/releases/download/v1.0/preset.zip", + ], + ) + + assert result.exit_code == 0, result.output + assert "My Preset" in result.output + # First call should resolve the release tag + assert any("releases/tags/v1.0" in url for url, _ in captured_urls) + # Second call should download from the resolved asset URL with octet-stream + asset_calls = [ + (url, h) for url, h in captured_urls if "releases/assets/" in url + ] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir): + """'preset add --from ' uses URL directly with octet-stream.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manifest_content = yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "my-preset", + "name": "My Preset", + "version": "1.0.0", + "description": "Test preset", + "author": "Test", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "t", + "file": "templates/t.md", + "description": "t", + } + ] + }, + } + ) + zip_buf = __import__("io").BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + captured_urls.append((url, extra_headers)) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="1.0.0"), + patch( + "specify_cli.authentication.http.open_url", side_effect=fake_open_url + ), + ): + result = runner.invoke( + app, + [ + "preset", + "add", + "--from", + "https://api.github.com/repos/org/repo/releases/assets/42", + ], + ) + + assert result.exit_code == 0, result.output + # Should go directly to the asset URL with Accept header + assert len(captured_urls) == 1 + assert ( + captured_urls[0][0] + == "https://api.github.com/repos/org/repo/releases/assets/42" + ) + assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + + def test_preset_add_from_ghes_release_url_resolves_via_api_v3( + self, project_dir, monkeypatch + ): + """'preset add --from ' resolves via GHES /api/v3 endpoint.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr( + _auth_http, + "_config_override", + [ + AuthConfigEntry( + hosts=("ghes.example",), provider="github", auth="bearer", token="t" + ), + ], + ) + + manifest_content = yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": "my-preset", + "name": "My Preset", + "version": "1.0.0", + "description": "Test preset", + "author": "Test", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "t", + "file": "templates/t.md", + "description": "t", + } + ] + }, + } + ) + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + def fake_open_url( + url, timeout=None, extra_headers=None, redirect_validator=None + ): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return io.BytesIO( + json.dumps( + { + "assets": [ + { + "name": "preset.zip", + "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42", + } + ] + } + ).encode() + ) + return io.BytesIO(zip_bytes) + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch("specify_cli.get_speckit_version", return_value="1.0.0"), + patch( + "specify_cli.authentication.http.open_url", side_effect=fake_open_url + ), + ): + result = runner.invoke( + app, + [ + "preset", + "add", + "--from", + "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", + ], + ) + + assert result.exit_code == 0, result.output + # The tag-lookup call must use the GHES /api/v3 endpoint + assert any( + "ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url + for url, _ in captured_urls + ) + # The asset download call must carry Accept: application/octet-stream + asset_calls = [ + (url, h) for url, h in captured_urls if "releases/assets/" in url + ] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} diff --git a/tests/specify_cli/presets/test_command_disable.py b/tests/specify_cli/presets/test_command_disable.py new file mode 100644 index 0000000000..b788fbbc80 --- /dev/null +++ b/tests/specify_cli/presets/test_command_disable.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetManager, +) + + +class TestPresetDisable: + """Test preset enable/disable CLI commands.""" + + def test_disable_preset(self, project_dir, pack_dir): + """Test disable command sets enabled=False.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Verify initially enabled + assert manager.registry.get("test-pack").get("enabled", True) is True + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "disabled" in result.output.lower() + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is False + + def test_disable_already_disabled(self, project_dir, pack_dir): + """Test disable on already disabled preset shows warning.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset and disable it + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.update("test-pack", {"enabled": False}) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "already disabled" in result.output.lower() + + def test_disable_not_installed(self, project_dir): + """Test disable fails for non-installed preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "nonexistent"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test disable fails gracefully for corrupted registry entry.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "disable", "test-pack"]) + + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() diff --git a/tests/specify_cli/presets/test_command_enable.py b/tests/specify_cli/presets/test_command_enable.py new file mode 100644 index 0000000000..4eb4ebfe9d --- /dev/null +++ b/tests/specify_cli/presets/test_command_enable.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetManager, +) +from tests.specify_cli.presets._helpers import ( + install_constitution_sync_preset, + install_self_test_preset, + make_convention_constitution_preset as _make_convention_constitution_preset, +) + + +class TestPresetEnable: + """Test preset enable/disable CLI commands.""" + + def test_enable_preset(self, project_dir, pack_dir): + """Test enable command sets enabled=True.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset and disable it + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.update("test-pack", {"enabled": False}) + + # Verify disabled + assert manager.registry.get("test-pack")["enabled"] is False + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "enabled" in result.output.lower() + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["enabled"] is True + + def test_enable_disable_reconciles_generated_constitution( + self, project_dir, temp_dir + ): + """Enable and disable rematerialize the winning constitution layer.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + manager.install_from_directory( + _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 + ) + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert memory.read_text() == "# Convention Constitution\n" + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + disabled = runner.invoke( + app, ["preset", "disable", "convention-constitution"] + ) + + assert disabled.exit_code == 0, disabled.output + assert "preset:self-test" in memory.read_text() + + with patch.object(Path, "cwd", return_value=project_dir): + enabled = runner.invoke( + app, ["preset", "enable", "convention-constitution"] + ) + + assert enabled.exit_code == 0, enabled.output + assert memory.read_text() == "# Convention Constitution\n" + + def test_stack_changes_do_not_create_missing_constitution( + self, project_dir, pack_dir + ): + """Stack changes for non-providers do not seed a missing constitution.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") + memory = project_dir / ".specify" / "memory" / "constitution.md" + runner = CliRunner() + + for args in ( + ["preset", "set-priority", "test-pack", "5"], + ["preset", "disable", "test-pack"], + ["preset", "enable", "test-pack"], + ): + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, args) + assert result.exit_code == 0, result.output + assert not memory.exists() + + def test_enable_already_enabled(self, project_dir, pack_dir): + """Test enable on already enabled preset shows warning.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset (enabled by default) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 0, result.output + assert "already enabled" in result.output.lower() + + def test_enable_not_installed(self, project_dir): + """Test enable fails for non-installed preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "nonexistent"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() + + def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): + """Test enable fails gracefully for corrupted registry entry.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset then corrupt the registry entry + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + manager.registry.data["presets"]["test-pack"] = "corrupted-string" + manager.registry._save() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "enable", "test-pack"]) + + assert result.exit_code == 1 + assert "corrupted state" in result.output.lower() diff --git a/tests/specify_cli/presets/test_command_info.py b/tests/specify_cli/presets/test_command_info.py new file mode 100644 index 0000000000..1379e6e3a1 --- /dev/null +++ b/tests/specify_cli/presets/test_command_info.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from pathlib import Path +from typing import ClassVar + +import yaml + +from specify_cli.presets import ( + PresetCatalog, + PresetManager, +) +from tests.conftest import strip_ansi +from tests.specify_cli.presets._helpers import ( + default_catalog_entries, + seed_catalog, +) + + +class TestPresetInfoTags: + """Non-string catalog tags must not crash preset display commands. + + Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain + numbers or other non-strings. The display path joins them; a raw + ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. + Sibling command surfaces (extensions/integrations/workflows) already guard + this with ``str(t) for t in ...`` — presets must match. + """ + + def test_info_renders_non_string_tags(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, [1, 2]) + default_only = default_catalog_entries(catalog) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, "get_active_catalogs", return_value=default_only + ), + ): + result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Tags: 1, 2" in plain + + def test_info_tolerates_non_list_tags(self, project_dir): + """``preset info`` must not crash rendering a scalar ``tags:`` value.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, 5) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "numeric-tags" in plain + assert "Tags:" not in plain + + +class TestPresetInfoCatalogMarkup: + """Catalog metadata must render as literal text in Rich output.""" + + MARKUP_PRESET: ClassVar[dict[str, object]] = { + "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]", + "tags": ["[italic]markup-tag[/italic]"], + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def test_info_escapes_catalog_markup(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_pack_info", + return_value=self.MARKUP_PRESET, + ), + ): + result = CliRunner().invoke( + app, + ["preset", "info", self.MARKUP_PRESET["id"]], + ) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for field in ( + "id", + "name", + "version", + "description", + "author", + "repository", + "license", + ): + value = self.MARKUP_PRESET[field] + assert value in output + # Tags are joined into a single line, so assert on the rendered join. + assert ", ".join(self.MARKUP_PRESET["tags"]) in output + + +class TestPresetInfoInstalledMarkup: + """Locally installed preset metadata must render as literal text. + + ``preset.yml`` is user-editable, so its fields can contain ``[...]``. + ``TestPresetCatalogRichMarkup`` covers the catalog branch of these + commands; the installed-preset branch of ``preset list``/``preset info`` + and all of ``preset resolve`` were left unescaped, so a field like + ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an + unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, + aborting the command with a traceback. + """ + + MARKUP_FIELDS: ClassVar[dict[str, str]] = { + "name": "[green]Markup Name[/green]", + "version": "1.0.0", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def _install( + self, + temp_dir, + project_dir, + preset_overrides=None, + strategy=None, + pack_id="markup-pack", + priority=10, + tmpl_description=None, + ): + """Install a preset from a directory built with the given manifest fields.""" + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + + preset_section = { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + } + preset_section.update(preset_overrides or {}) + tmpl = { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + if tmpl_description is not None: + tmpl["description"] = tmpl_description + if strategy: + tmpl["strategy"] = strategy + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": preset_section, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [tmpl]}, + "tags": ["[italic]markup-tag[/italic]"], + } + ) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(src, "9.9.9", priority) + return manager + + def _invoke(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir): + """Every ``preset.yml`` field must survive verbatim in list/info output.""" + self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS) + + for args in (["preset", "list"], ["preset", "info", "markup-pack"]): + result = self._invoke(project_dir, args) + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + # `preset list` does not render repository/license. + fields = ( + ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS + ) + for field in fields: + assert self.MARKUP_FIELDS[field] in output, (field, args, output) + assert "[italic]markup-tag[/italic]" in output, (args, output) + + def test_info_does_not_swallow_template_description(self, temp_dir, project_dir): + """The per-template line in ``preset info`` must escape the template description. + + ``name``/``type`` are format-restricted by manifest validation, but + ``description`` is free-form, so it is the field that can carry markup. + """ + self._install( + temp_dir, + project_dir, + tmpl_description="Template [desc] here", + ) + result = self._invoke(project_dir, ["preset", "info", "markup-pack"]) + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + assert "spec-template (template): Template [desc] here" in output, output + + def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir): + """An unbalanced tag must not raise MarkupError and abort the command.""" + self._install( + temp_dir, + project_dir, + preset_overrides={"description": "Broken [/red] tag"}, + ) + + for args in (["preset", "list"], ["preset", "info", "markup-pack"]): + result = self._invoke(project_dir, args) + assert result.exit_code == 0, (args, result.output, result.exception) + assert "Broken [/red] tag" in strip_ansi(result.output) diff --git a/tests/specify_cli/presets/test_command_list.py b/tests/specify_cli/presets/test_command_list.py new file mode 100644 index 0000000000..243bae5adf --- /dev/null +++ b/tests/specify_cli/presets/test_command_list.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.presets import ( + PresetManager, +) +from tests.conftest import strip_ansi + + +class TestPresetListOrdering: + """``preset list`` must print presets in actual resolution/precedence order. + + Regression coverage for #4086: the printed order was registry/insertion + order, so a preset with a *higher* priority number (lower precedence) could + appear before one with a lower number, misleading users about which preset + wins. Output must be sorted by (priority, id) to match + ``PresetRegistry.list_by_priority()``. + """ + + def _install(self, temp_dir, project_dir, pack_id, priority): + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + }, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + ] + }, + } + ) + ) + PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) + + def _invoke(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_sorted_by_priority(self, temp_dir, project_dir): + """Lower priority number is listed first regardless of install order.""" + # Install in an order that does NOT match precedence. + self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) + self._install(temp_dir, project_dir, "lean", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + # `lean` (priority 10) must appear before `copilot-sub-agents` (100). + assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output + assert "resolution order" in output, output + assert "Ties are broken by preset id" in output, output + + def test_list_ties_broken_by_id(self, temp_dir, project_dir): + """Equal priority ties are broken alphabetically by preset id.""" + self._install(temp_dir, project_dir, "zebra", priority=10) + self._install(temp_dir, project_dir, "alpha", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + assert output.index("(alpha)") < output.index("(zebra)"), output diff --git a/tests/specify_cli/presets/test_command_remove.py b/tests/specify_cli/presets/test_command_remove.py new file mode 100644 index 0000000000..50a7e89f7b --- /dev/null +++ b/tests/specify_cli/presets/test_command_remove.py @@ -0,0 +1,21 @@ +"""Tests for the ``specify preset remove`` command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.presets import PresetManager + + +def test_remove_installed_preset(project_dir, pack_dir): + PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke(app, ["preset", "remove", "test-pack"]) + + assert result.exit_code == 0, result.output + assert not PresetManager(project_dir).registry.is_installed("test-pack") diff --git a/tests/specify_cli/presets/test_command_resolve.py b/tests/specify_cli/presets/test_command_resolve.py new file mode 100644 index 0000000000..42e55d572a --- /dev/null +++ b/tests/specify_cli/presets/test_command_resolve.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from specify_cli.presets import ( + PresetManager, +) +from tests.conftest import strip_ansi + + +class TestPresetResolve: + """Locally installed preset metadata must render as literal text. + + ``preset.yml`` is user-editable, so its fields can contain ``[...]``. + ``TestPresetCatalogRichMarkup`` covers the catalog branch of these + commands; the installed-preset branch of ``preset list``/``preset info`` + and all of ``preset resolve`` were left unescaped, so a field like + ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an + unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, + aborting the command with a traceback. + """ + + def _install( + self, + temp_dir, + project_dir, + preset_overrides=None, + strategy=None, + pack_id="markup-pack", + priority=10, + tmpl_description=None, + ): + """Install a preset from a directory built with the given manifest fields.""" + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + + preset_section = { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + } + preset_section.update(preset_overrides or {}) + tmpl = { + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + } + if tmpl_description is not None: + tmpl["description"] = tmpl_description + if strategy: + tmpl["strategy"] = strategy + (src / "preset.yml").write_text( + yaml.dump( + { + "schema_version": "1.0", + "preset": preset_section, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [tmpl]}, + "tags": ["[italic]markup-tag[/italic]"], + } + ) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(src, "9.9.9", priority) + return manager + + def _invoke(self, project_dir, args): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_resolve_rejects_invalid_template_name(self, project_dir): + """``preset resolve`` rejects names before joining them into paths.""" + result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) + assert result.exit_code == 1, (result.output, result.exception) + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_rejects_path_traversal(self, project_dir): + """The resolver rejects traversal before joining names into paths.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "../../../README"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_accepts_dotted_command_name(self, project_dir): + """Documented dotted command identifiers use command resolution.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit.constitution"], + ) + + assert result.exit_code == 0, (result.output, result.exception) + assert "constitution.md" in "".join(strip_ansi(result.output).split()) + + def test_resolve_rejects_empty_command_segments(self, project_dir): + """Dotted command identifiers cannot contain empty path-like segments.""" + result = self._invoke( + project_dir, + ["preset", "resolve", "speckit..constitution"], + ) + + assert result.exit_code == 1 + assert "invalid template name" in strip_ansi(result.output) + + def test_resolve_escapes_layer_path_and_source(self, project_dir): + """The top-layer path/source lines must render markup literally. + + A preset can be installed from any directory, so the resolved path can + contain ``[...]``; the layer source carries the pack id and version. + """ + from unittest.mock import patch + + from specify_cli.presets import PresetResolver + + # A closing tag cannot live inside a path segment: `Path` treats its + # `/` as a separator on POSIX and rewrites it to `\` on Windows. The + # opening tag covers the swallowing case for the path; the unbalanced + # closing tag rides on `source`, which is a plain string. + layer = { + "path": Path("/tmp/[red]dir/spec-template.md"), + "source": "pack [/red] v1.0.0", + "strategy": "replace", + } + with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "[red]dir" in output, output + assert "pack [/red] v1.0.0" in output, output + + def test_resolve_escapes_fallback_path_and_source(self, project_dir): + """The no-layer fallback branch must escape ``resolve_with_source`` output.""" + from unittest.mock import patch + + from specify_cli.presets import PresetResolver + + with ( + patch.object(PresetResolver, "collect_all_layers", return_value=[]), + patch.object( + PresetResolver, + "resolve_with_source", + return_value={ + "path": "/tmp/[blue]fallback[/blue]/spec-template.md", + "source": "fallback [/red] source", + }, + ), + ): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "[blue]fallback[/blue]" in output, output + assert "fallback [/red] source" in output, output + + def test_resolve_escapes_composition_error(self, project_dir): + """A composition exception message must not be parsed as markup.""" + from unittest.mock import patch + + from specify_cli.presets import PresetResolver + + layers = [ + { + "path": Path("/tmp/top/spec-template.md"), + "source": "top-pack v1.0.0", + "strategy": "append", + }, + { + "path": Path("/tmp/base/spec-template.md"), + "source": "base-pack v1.0.0", + "strategy": "append", + }, + ] + with ( + patch.object(PresetResolver, "collect_all_layers", return_value=layers), + patch.object( + PresetResolver, + "resolve_content", + side_effect=RuntimeError("compose failed: [/red] bad layer"), + ), + ): + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + + assert result.exit_code == 0, (result.output, result.exception) + output = " ".join(strip_ansi(result.output).split()) + assert "compose failed: [/red] bad layer" in output, output + + def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir): + """The composition chain's ``[]`` label must not be eaten as a tag.""" + self._install( + temp_dir, project_dir, strategy="replace", pack_id="base-pack", priority=20 + ) + self._install( + temp_dir, project_dir, strategy="append", pack_id="app-pack", priority=5 + ) + + result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) + assert result.exit_code == 0, (result.output, result.exception) + output = strip_ansi(result.output) + assert "Composition chain" in output, output + assert "[base]" in output, output + assert "[append]" in output, output diff --git a/tests/specify_cli/presets/test_command_search.py b/tests/specify_cli/presets/test_command_search.py new file mode 100644 index 0000000000..91e2447619 --- /dev/null +++ b/tests/specify_cli/presets/test_command_search.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from pathlib import Path +from typing import ClassVar + +from specify_cli.presets import PresetCatalog +from tests.conftest import strip_ansi +from tests.specify_cli.presets._helpers import ( + default_catalog_entries, + seed_catalog, +) + + +class TestPresetTagsNonString: + """Non-string catalog tags must not crash preset display commands. + + Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain + numbers or other non-strings. The display path joins them; a raw + ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. + Sibling command surfaces (extensions/integrations/workflows) already guard + this with ``str(t) for t in ...`` — presets must match. + """ + + def test_search_renders_non_string_tags(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, [1, 2]) + default_only = default_catalog_entries(catalog) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, "get_active_catalogs", return_value=default_only + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Tags: 1, 2" in plain + + def test_search_by_author_tolerates_non_string_author(self, project_dir): + """``--author`` must not crash on a numeric catalog ``author``. + + ``PresetCatalog.search`` called ``.lower()`` straight on the raw value, + raising ``AttributeError: 'int' object has no attribute 'lower'``. The + sibling extension/integration catalogs coerce with ``str(...)`` first. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, ["ci"], extra={"author": 789}) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "--author", "789"]) + + assert result.exit_code == 0, result.output + assert "Numeric Tags" in strip_ansi(result.output) + + def test_search_query_tolerates_non_string_name_and_description(self, project_dir): + """A query search must not crash on numeric ``name``/``description``. + + The searchable-text join passed the raw values through, raising + ``TypeError: sequence item 0: expected str instance, int found``. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog( + project_dir, ["ci"], extra={"name": 123, "description": 456} + ) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "123"]) + + assert result.exit_code == 0, result.output + assert "numeric-tags" in strip_ansi(result.output) + + def test_search_tolerates_non_list_tags(self, project_dir): + """A scalar ``tags:`` value must not crash the tag filter or display. + + ``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and + the result-display join raised ``TypeError: 'int' object is not + iterable``. Siblings guard with ``isinstance(raw_tags, list)``. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, 5) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"]) + displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert filtered.exit_code == 0, filtered.output + assert "No presets found" in strip_ansi(filtered.output) + + assert displayed.exit_code == 0, displayed.output + plain = strip_ansi(displayed.output) + assert "Numeric Tags" in plain + assert "Tags:" not in plain + + def test_search_escapes_rich_markup_in_tags(self, project_dir): + """Bracketed tag text must survive Rich markup parsing. + + ``preset search`` printed tags unescaped, so a tag like ``[bold]`` was + swallowed as a style tag. ``preset list`` already escaped this. + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + catalog = seed_catalog(project_dir, ["[bold]ci"]) + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "get_active_catalogs", + return_value=default_catalog_entries(catalog), + ), + ): + result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) + + assert result.exit_code == 0, result.output + assert "[bold]ci" in strip_ansi(result.output) + + +class TestPresetSearchRichMarkup: + """Catalog metadata must render as literal text in Rich output.""" + + MARKUP_PRESET: ClassVar[dict[str, object]] = { + "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]", + "tags": ["[italic]markup-tag[/italic]"], + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def test_search_escapes_catalog_markup(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + PresetCatalog, + "search", + return_value=[self.MARKUP_PRESET], + ), + ): + result = CliRunner().invoke(app, ["preset", "search"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + self.MARKUP_PRESET["id"], + self.MARKUP_PRESET["name"], + self.MARKUP_PRESET["version"], + self.MARKUP_PRESET["description"], + ): + assert value in output diff --git a/tests/specify_cli/presets/test_command_set_priority.py b/tests/specify_cli/presets/test_command_set_priority.py new file mode 100644 index 0000000000..14afba58a0 --- /dev/null +++ b/tests/specify_cli/presets/test_command_set_priority.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from pathlib import Path + +from specify_cli.presets import ( + PresetManager, +) +from tests.conftest import strip_ansi +from tests.specify_cli.presets._helpers import ( + install_constitution_sync_preset, + install_self_test_preset, + make_convention_constitution_preset as _make_convention_constitution_preset, +) + + +class TestPresetSetPriority: + """Test preset set-priority CLI command.""" + + def test_set_priority_changes_priority(self, project_dir, pack_dir): + """Test set-priority command changes preset priority.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset with default priority + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Verify default priority + assert manager.registry.get("test-pack")["priority"] == 10 + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "priority changed: 10 → 5" in plain + + # Reload registry to see updated value + manager2 = PresetManager(project_dir) + assert manager2.registry.get("test-pack")["priority"] == 5 + + def test_set_priority_reconciles_generated_constitution( + self, project_dir, temp_dir + ): + """Changing priority rematerializes an unchanged generated constitution.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + manager = PresetManager(project_dir) + install_constitution_sync_preset(manager) + install_self_test_preset(manager) + manager.install_from_directory( + _make_convention_constitution_preset(temp_dir), "0.1.5", priority=20 + ) + memory = project_dir / ".specify" / "memory" / "constitution.md" + assert "preset:self-test" in memory.read_text() + + with patch.object(Path, "cwd", return_value=project_dir): + result = CliRunner().invoke( + app, + ["preset", "set-priority", "convention-constitution", "1"], + ) + + assert result.exit_code == 0, result.output + assert memory.read_text() == "# Convention Constitution\n" + + def test_set_priority_same_value_no_change(self, project_dir, pack_dir): + """Test set-priority with same value shows already set message.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset with priority 5 + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "already has priority 5" in plain + + def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir): + """A corrupted boolean priority must be repaired, not skipped. + + ``isinstance(True, int)`` is True and ``True == 1`` in Python, so a + stored ``True`` priority would short-circuit the ``already has + priority 1`` skip path and never get rewritten to a real int — + contradicting the comment that promises corrupted values are + repaired. The guard must exclude bools (like normalize_priority). + """ + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5", priority=5) + # Inject a corrupted boolean priority (True == 1). + manager.registry.update("test-pack", {"priority": True}) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + # The corrupted bool must be repaired, not reported as already-set. + assert "already has priority" not in plain + assert "priority changed" in plain + + # The stored value is now a real int, not a bool. + reloaded = PresetManager(project_dir).registry.get("test-pack") + assert reloaded["priority"] == 1 + assert not isinstance(reloaded["priority"], bool) + + def test_set_priority_invalid_value(self, project_dir, pack_dir): + """Test set-priority rejects invalid priority values.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + # Install preset + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"]) + + assert result.exit_code == 1, result.output + assert "Priority must be a positive integer" in result.output + + def test_set_priority_not_installed(self, project_dir): + """Test set-priority fails for non-installed preset.""" + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() diff --git a/tests/specify_cli/presets/test_command_update.py b/tests/specify_cli/presets/test_command_update.py new file mode 100644 index 0000000000..4b2cf2d3d1 --- /dev/null +++ b/tests/specify_cli/presets/test_command_update.py @@ -0,0 +1,282 @@ +"""Tests for the ``specify preset update`` command.""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import sys +from types import SimpleNamespace + +import pytest +import typer + +from specify_cli.presets._commands import ( + _render_powershell_argv, + preset_update, +) +from tests.conftest import strip_ansi + + +class TestPresetUpdateCommand: + """Test the destructive remove-then-add update contract.""" + + @staticmethod + def _manager(monkeypatch, project_dir, installed=True): + from specify_cli.presets import _commands as commands + + registry = SimpleNamespace(is_installed=lambda _preset_id: installed) + manager = SimpleNamespace(registry=registry) + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.presets.PresetManager", lambda _root: manager) + return commands + + def test_unknown_preset_fails_without_remove_or_add(self, project_dir, monkeypatch): + commands = self._manager(monkeypatch, project_dir, installed=False) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda *_args: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update("missing", from_url=None, dev=None, priority=10) + + assert exc_info.value.exit_code == 1 + assert calls == [] + + @pytest.mark.parametrize( + ("from_url", "dev"), + [ + ("https://example.com/preset.zip", "./preset"), + ("", "./preset"), + ("https://example.com/preset.zip", ""), + ], + ) + def test_mutually_exclusive_sources_are_rejected( + self, project_dir, monkeypatch, from_url, dev + ): + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda *_args: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=from_url, + dev=dev, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + assert calls == [] + + @pytest.mark.parametrize( + ("from_url", "dev", "option"), + [("", None, "--from"), (None, "", "--dev")], + ) + def test_empty_source_is_rejected_before_removal( + self, project_dir, monkeypatch, capsys, from_url, dev, option + ): + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda *_args: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=from_url, + dev=dev, + priority=10, + ) + + assert exc_info.value.exit_code == 1 + assert calls == [] + assert f"{option} must not be empty" in strip_ansi(capsys.readouterr().out) + + def test_remove_failure_prevents_add(self, project_dir, monkeypatch): + commands = self._manager(monkeypatch, project_dir) + calls = [] + + def fail_remove(_preset_id): + calls.append("remove") + raise typer.Exit(1) + + monkeypatch.setattr(commands, "preset_remove", fail_remove) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update("test-pack", from_url=None, dev=None, priority=10) + + assert exc_info.value.exit_code == 1 + assert calls == ["remove"] + + def test_update_forwards_id_sources_and_priority_to_add( + self, project_dir, monkeypatch + ): + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, + "preset_remove", + lambda preset_id: calls.append(("remove", preset_id)), + ) + monkeypatch.setattr( + commands, + "preset_add", + lambda **kwargs: calls.append(("add", kwargs)), + ) + + preset_update( + "test-pack", + from_url="https://example.com/replacement.zip", + dev=None, + priority=4, + ) + + assert calls == [ + ("remove", "test-pack"), + ( + "add", + { + "preset_id": "test-pack", + "from_url": "https://example.com/replacement.zip", + "dev": None, + "priority": 4, + }, + ), + ] + + def test_add_failure_states_removed_and_prints_retry_command( + self, project_dir, monkeypatch, capsys + ): + commands = self._manager(monkeypatch, project_dir) + monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) + + def fail_add(**_kwargs): + raise typer.Exit(1) + + monkeypatch.setattr(commands, "preset_add", fail_add) + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=None, + dev="/tmp/replacement preset", + priority=6, + ) + + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + assert "previous preset was removed" in output + retry_args = [ + "specify", + "preset", + "add", + "test-pack", + "--dev", + "/tmp/replacement preset", + "--priority", + "6", + ] + expected = ( + _render_powershell_argv(retry_args) + if os.name == "nt" + else shlex.join(retry_args) + ) + assert expected in output + + def test_retry_command_quotes_powershell_metacharacters( + self, project_dir, monkeypatch, capsys + ): + """Windows retry commands keep PowerShell metacharacters literal.""" + commands = self._manager(monkeypatch, project_dir) + monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) + + def fail_add(**_kwargs): + raise typer.Exit(1) + + monkeypatch.setattr(commands, "preset_add", fail_add) + monkeypatch.setattr(os, "name", "nt") + + with pytest.raises(typer.Exit) as exc_info: + preset_update( + "test-pack", + from_url=None, + dev=r"C:\replacement&$backup's presets", + priority=6, + ) + + assert exc_info.value.exit_code == 1 + output = strip_ansi(capsys.readouterr().out) + expected = ( + "& 'specify' 'preset' 'add' 'test-pack' '--dev' " + "'C:\\replacement&$backup''s presets' '--priority' '6'" + ) + assert "Retry in PowerShell: " in output + assert expected in output + + def test_powershell_retry_renderer_preserves_literal_arguments(self): + """The rendered command survives parsing by a real PowerShell.""" + powershell = shutil.which("pwsh") or shutil.which("powershell") + if powershell is None: + pytest.skip("PowerShell is not available") + + arguments = [ + "https://example.com/archive.zip?one=1&two=$value", + r"C:\owner's presets", + ] + rendered = _render_powershell_argv( + [ + sys.executable, + "-c", + "import json,sys; print(json.dumps(sys.argv[1:]))", + *arguments, + ] + ) + result = subprocess.run( + [powershell, "-NoProfile", "-Command", rendered], + check=True, + capture_output=True, + text=True, + ) + + assert json.loads(result.stdout) == arguments + + def test_invalid_priority_rejected_before_removal( + self, project_dir, monkeypatch, capsys + ): + """--priority 0 must fail without removing the installed preset.""" + commands = self._manager(monkeypatch, project_dir) + calls = [] + monkeypatch.setattr( + commands, "preset_remove", lambda preset_id: calls.append("remove") + ) + monkeypatch.setattr( + commands, "preset_add", lambda **_kwargs: calls.append("add") + ) + + with pytest.raises(typer.Exit) as exc_info: + preset_update("test-pack", from_url=None, dev=None, priority=0) + + assert exc_info.value.exit_code == 1 + assert calls == [] + output = strip_ansi(capsys.readouterr().out) + assert "Priority must be a positive integer" in output + assert "previous preset was removed" not in output diff --git a/tests/specify_cli/presets/test_registration.py b/tests/specify_cli/presets/test_registration.py new file mode 100644 index 0000000000..0aa4d74b34 --- /dev/null +++ b/tests/specify_cli/presets/test_registration.py @@ -0,0 +1,40 @@ +"""Registration and compatibility boundaries for ``specify preset``.""" + +from __future__ import annotations + +from specify_cli.presets import _commands +from specify_cli.presets.catalog import catalog_app + + +def test_preset_commands_registered_once_in_stable_order(): + assert [command.name for command in _commands.preset_app.registered_commands] == [ + "list", + "add", + "remove", + "update", + "search", + "resolve", + "info", + "set-priority", + "enable", + "disable", + ] + assert [group.name for group in _commands.preset_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", + ] + + +def test_legacy_add_import_resolves_to_extracted_handler(): + from specify_cli.presets.command_add import preset_add + + assert _commands.preset_add.__name__ == "preset_add" + assert _commands.preset_add.__module__ == "specify_cli.presets._commands" + assert callable(preset_add) diff --git a/tests/test_init_dir_cli.py b/tests/test_init_dir_cli.py index 6f3cd570c6..beb38587ec 100644 --- a/tests/test_init_dir_cli.py +++ b/tests/test_init_dir_cli.py @@ -91,16 +91,6 @@ def test_override_trailing_slash_tolerated(tmp_path, monkeypatch): assert "No workflows installed" in result.output -def test_override_redirects_bundle_commands(tmp_path, monkeypatch): - web = _make_project(tmp_path, "web") - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - monkeypatch.chdir(elsewhere) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) - - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code == 0, result.output - assert "No bundles installed" in result.output def test_unset_override_uses_cwd(tmp_path, monkeypatch): @@ -138,50 +128,10 @@ def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch): assert "No workflows installed" not in result.output # no fallback to cwd -def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch): - """Bundle commands also honor the strict override contract.""" - cwd_proj = _make_project(tmp_path, "cwd") - monkeypatch.chdir(cwd_proj) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) - - result = runner.invoke(app, ["bundle", "list"]) - assert result.exit_code != 0 - assert "does not point to an existing directory" in result.output - assert "No bundles installed" not in result.output - - -def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch): - """Invalid override errors must not contaminate JSON stdout.""" - cwd_proj = _make_project(tmp_path, "cwd") - monkeypatch.chdir(cwd_proj) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist")) - - result = runner.invoke(app, ["bundle", "list", "--json"]) - assert result.exit_code != 0 - assert result.stdout == "" - assert "does not point to an existing directory" in result.stderr -def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch): - """A symlinked override .specify must not make bundle init fall back to cwd.""" - web = tmp_path / "web" - web.mkdir() - real = tmp_path / "real-specify" - real.mkdir() - try: - (web / ".specify").symlink_to(real, target_is_directory=True) - except (OSError, NotImplementedError): - pytest.skip("Symlinks are not available in this environment") - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - monkeypatch.chdir(elsewhere) - monkeypatch.setenv("SPECIFY_INIT_DIR", str(web)) - result = runner.invoke(app, ["bundle", "init", "--offline"]) - assert result.exit_code != 0 - assert "symlinked .specify" in result.output - assert not (elsewhere / ".specify").exists() def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch): diff --git a/tests/test_presets.py b/tests/test_presets.py index 93959f877b..d71bd52588 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -13,25 +13,16 @@ import pytest import io import json -import os -import shlex -import subprocess -import sys -import tempfile import tarfile import shutil -import warnings import zipfile from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone -from types import SimpleNamespace from unittest.mock import MagicMock import yaml -import typer -from tests.conftest import strip_ansi from specify_cli.presets import ( PresetManifest, PresetRegistry, @@ -45,106 +36,19 @@ VALID_PRESET_TEMPLATE_TYPES, ) from specify_cli.extensions import ExtensionRegistry -from specify_cli._console import console -from specify_cli.presets._commands import ( - _render_powershell_argv, - _warn_unmet_extension_dependencies, - preset_update, +from tests.specify_cli.presets import _fixtures +from tests.specify_cli.presets._helpers import ( + CORE_TEMPLATE_NAMES, + SELF_TEST_PRESET_DIR, + install_constitution_sync_preset, + install_self_test_preset, + make_convention_constitution_preset as _make_convention_constitution_preset, ) - -# ===== Fixtures ===== - - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for tests.""" - tmpdir = tempfile.mkdtemp() - yield Path(tmpdir) - shutil.rmtree(tmpdir) - - -@pytest.fixture -def valid_pack_data(): - """Valid preset manifest data.""" - return { - "schema_version": "1.0", - "preset": { - "id": "test-pack", - "name": "Test Preset", - "version": "1.0.0", - "description": "A test preset", - "author": "Test Author", - "repository": "https://github.com/test/test-pack", - "license": "MIT", - }, - "requires": { - "speckit_version": ">=0.1.0", - }, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - "description": "Custom spec template", - "replaces": "spec-template", - } - ] - }, - "tags": ["testing", "example"], - } - - -@pytest.fixture -def pack_dir(temp_dir, valid_pack_data): - """Create a complete preset directory structure.""" - p_dir = temp_dir / "test-pack" - p_dir.mkdir() - - # Write manifest - manifest_path = p_dir / "preset.yml" - with open(manifest_path, 'w') as f: - yaml.dump(valid_pack_data, f) - - # Create templates directory - templates_dir = p_dir / "templates" - templates_dir.mkdir() - - # Write template file - tmpl_file = templates_dir / "spec-template.md" - tmpl_file.write_text("# Custom Spec Template\n\nThis is a custom template.\n") - - return p_dir - - -@pytest.fixture -def project_dir(temp_dir): - """Create a mock spec-kit project directory.""" - proj_dir = temp_dir / "project" - proj_dir.mkdir() - - # Create .specify directory - specify_dir = proj_dir / ".specify" - specify_dir.mkdir() - - # Create templates directory with core templates - templates_dir = specify_dir / "templates" - templates_dir.mkdir() - - # Create core spec-template - core_spec = templates_dir / "spec-template.md" - core_spec.write_text("# Core Spec Template\n") - - # Create core plan-template - core_plan = templates_dir / "plan-template.md" - core_plan.write_text("# Core Plan Template\n") - - # Create commands subdirectory - commands_dir = templates_dir / "commands" - commands_dir.mkdir() - - return proj_dir +temp_dir = _fixtures.temp_dir +valid_pack_data = _fixtures.valid_pack_data +pack_dir = _fixtures.pack_dir +project_dir = _fixtures.project_dir # ===== PresetManifest Tests ===== @@ -1310,24 +1214,6 @@ def test_unsatisfied_version_constraint_reports_both_versions( assert unmet[0]["installed"] == "0.1.0" assert unmet[0]["version"] == ">=9.0.0" - def test_version_warning_does_not_promise_update_satisfies_constraint(self): - """Version remediation must handle constraints update cannot guarantee.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - { - "id": "speckit-inventory", - "reason": "version", - "installed": "3.0.0", - "version": "<2", - } - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = " ".join(strip_ansi(capture.get()).split()) - assert "Needs: a release of speckit-inventory satisfying <2" in output - assert "specify extension update" not in output def test_optional_dependency_is_never_reported( self, project_dir, temp_dir, valid_pack_data @@ -1428,71 +1314,8 @@ def test_corrupted_registry_entry_with_directory_is_not_satisfied( # so a plain `extension add` would be refused as already installed. assert [dep["reason"] for dep in unmet] == ["corrupt"] - def test_missing_and_stale_warnings_mention_discovery_only_catalogs(self): - """`extension add ` is rejected for discovery-only entries, so say so.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "missing", - "installed": None, "version": None} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = strip_ansi(capture.get()) - assert "discovery-only catalog" in output - assert "--from " in output - assert "Install with: specify extension add speckit-inventory" in output - - @pytest.mark.parametrize( - "reason, extra", - [ - ("missing", {"installed": None, "version": None}), - ("stale", {"installed": "0.1.0", "version": None}), - ("disabled", {"installed": "0.1.0", "version": None}), - ("version", {"installed": "0.1.0", "version": ">=9.0.0"}), - ], - ) - def test_leading_hyphen_id_is_not_emitted_into_a_command(self, reason, extra): - """A leading-hyphen id satisfies `^[a-z0-9-]+$` but breaks the command. - - Typer would read it as an option rather than the positional extension - argument, so the advertised fix would fail. Every remedy substitutes - the placeholder `_command_safe_id` returns. The id here is deliberately - not a real flag, so a match cannot be confused with `--force` appearing - legitimately in the stale remedy. - """ - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "--not-a-real-flag", "reason": reason, **extra} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = " ".join(strip_ansi(capture.get()).split()) - # Isolate the remedy: the description line legitimately shows the raw - # id, escaped for display; only the copyable command must not carry it. - label = next( - lbl for lbl in ("Install with:", "Reinstall with:", "Enable with:", "Needs:") - if lbl in output - ) - remedy = output.split(label, 1)[1].split("The preset is installed.")[0] - assert "--not-a-real-flag" not in remedy - assert "" in remedy - - def test_version_only_warning_omits_the_discovery_only_note(self): - """The note is about installing by id, which a version mismatch does not do.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "version", - "installed": "0.1.0", "version": ">=9.0.0"} - ] - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - assert "discovery-only" not in strip_ansi(capture.get()) def test_unregistered_extension_with_corrupt_registry_is_missing( self, project_dir, temp_dir, valid_pack_data @@ -1529,20 +1352,6 @@ def test_corrupted_entry_gets_a_forced_reinstall_remedy( assert [dep["reason"] for dep in unmet] == ["corrupt"] - def test_corrupt_warning_suggests_forced_reinstall(self): - """The corrupt remedy must use --force, since the id is still registered.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "corrupt", - "installed": None, "version": None} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = strip_ansi(capture.get()) - assert "unreadable registry entry" in output - assert "Reinstall with: specify extension add speckit-inventory --force" in output def test_unreadable_registry_does_not_raise( self, project_dir, temp_dir, valid_pack_data, monkeypatch @@ -1566,36 +1375,7 @@ def _boom(*args, **kwargs): manifest ) == [] - def test_version_only_footer_does_not_claim_the_feature_is_inert(self): - """A version mismatch still invokes the extension, so wording differs.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "version", - "installed": "0.1.0", "version": ">=9.0.0"} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = " ".join(strip_ansi(capture.get()).split()) - assert "may not behave as the preset expects" in output - assert "does nothing" not in output - assert "safe to use" not in output - - def test_unavailable_footer_states_the_feature_is_inert(self): - """An unavailable extension genuinely contributes nothing.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - {"id": "speckit-inventory", "reason": "missing", - "installed": None, "version": None} - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - output = " ".join(strip_ansi(capture.get()).split()) - assert "does nothing" in output - assert "may not behave as the preset expects" not in output def test_exact_duplicate_declarations_warn_once( self, project_dir, temp_dir, valid_pack_data @@ -1663,24 +1443,6 @@ def test_stale_is_reported_ahead_of_disabled_and_version( assert [dep["reason"] for dep in unmet] == ["stale"] - def test_stale_warning_suggests_a_forced_reinstall(self): - """The stale remedy must restore the files, not re-add a registered id.""" - manager = MagicMock() - manager.find_unmet_extension_dependencies.return_value = [ - { - "id": "speckit-inventory", - "reason": "stale", - "installed": "0.1.0", - "version": None, - } - ] - - with console.capture() as capture: - _warn_unmet_extension_dependencies(manager, MagicMock()) - - output = strip_ansi(capture.get()) - assert "its files are missing" in output - assert "specify extension add speckit-inventory --force" in output def test_disabled_dependency_is_reported( self, project_dir, temp_dir, valid_pack_data @@ -3871,133 +3633,10 @@ def test_default_active_catalogs(self, project_dir): assert active[1].priority == 2 assert active[1].install_allowed is False - def test_catalog_list_escapes_rich_markup(self, project_dir): - """User-editable catalog name/url/description must not be parsed as Rich markup.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - entry = PresetCatalogEntry( - url="https://example.com/[cat].json", - name="Bracket [Catalog]", - priority=1, - install_allowed=True, - description="desc [with] brackets", - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=[entry]): - result = runner.invoke(app, ["preset", "catalog", "list"]) - 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 - - def test_catalog_add_escapes_rich_markup(self, project_dir): - """`preset catalog add` must not parse the name/url as Rich markup. - - An unbalanced closing tag raised MarkupError *after* the entry was - already written to preset-catalogs.yml, so the user saw a traceback - and no confirmation for a catalog that had in fact been added. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - name = "[/red]my-catalog" - url = "https://example.com/[bold]c.json" - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["preset", "catalog", "add", url, "--name", name] - ) - assert result.exit_code == 0, result.output - # Rendered verbatim, not swallowed as markup. - assert name in result.output - assert url in result.output - # Only rendering is escaped: the raw values still round-trip to disk. - config = yaml.safe_load( - (project_dir / ".specify" / "preset-catalogs.yml").read_text( - encoding="utf-8" - ) - ) - assert config["catalogs"][0]["name"] == name - assert config["catalogs"][0]["url"] == url - - def test_catalog_remove_escapes_rich_markup(self, project_dir): - """`preset catalog remove` must not parse the name as Rich markup.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - name = "[/red]my-catalog" - (project_dir / ".specify" / "preset-catalogs.yml").write_text( - yaml.dump({ - "catalogs": [ - { - "name": name, - "url": "https://example.com/c.json", - "priority": 1, - "install_allowed": False, - } - ] - }), - encoding="utf-8", - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "catalog", "remove", name]) - assert result.exit_code == 0, result.output - assert name in result.output - - def test_catalog_remove_escapes_markup_in_not_found_error(self, project_dir): - """The not-found error path renders the name too.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - (project_dir / ".specify" / "preset-catalogs.yml").write_text( - yaml.dump({"catalogs": []}), encoding="utf-8" - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["preset", "catalog", "remove", "[/red]absent"] - ) - assert result.exit_code == 1 - assert "[/red]absent" in result.output - @pytest.mark.parametrize( - "args", - [ - [ - "preset", - "catalog", - "add", - "https://example.com/catalog.json", - "--name", - "example", - ], - ["preset", "catalog", "remove", "example"], - ], - ) - def test_catalog_mutation_rejects_non_mapping_config_root( - self, project_dir, args - ): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - config_path = project_dir / ".specify" / "preset-catalogs.yml" - original = "[]\n" - config_path.write_text(original, encoding="utf-8") - with patch.object(Path, "cwd", return_value=project_dir): - result = CliRunner().invoke(app, args) - assert result.exit_code == 1 - assert "expected a mapping" in result.output - assert config_path.read_text(encoding="utf-8") == original def test_env_var_overrides_catalogs(self, project_dir, monkeypatch): """Test that SPECKIT_PRESET_CATALOG_URL env var overrides defaults.""" @@ -4328,75 +3967,6 @@ def test_url_cache_expired(self, project_dir): # ===== Self-Test Preset Tests ===== -SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" -CONSTITUTION_SYNC_PRESET_DIR = ( - Path(__file__).parent.parent / "presets" / "constitution-sync" -) -SELF_TEST_WRAP_WARNING = ( - r"Cannot compose command 'speckit\.wrap-test': no base layer\. " - r"Stale command files may remain\." -) - -CORE_TEMPLATE_NAMES = [ - "spec-template", - "plan-template", - "tasks-template", - "checklist-template", - "constitution-template", -] - - -def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest: - """Install self-test while filtering its intentionally missing wrap base.""" - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message=SELF_TEST_WRAP_WARNING, - category=UserWarning, - module=r"specify_cli\.presets", - ) - return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) - - -def install_constitution_sync_preset(manager: PresetManager) -> PresetManifest: - """Enable guarded install-time constitution materialization.""" - return manager.install_from_directory(CONSTITUTION_SYNC_PRESET_DIR, "0.15.0") - - -def _make_convention_constitution_preset(temp_dir: Path) -> Path: - """Create a preset whose constitution is found by convention, not its manifest.""" - preset_dir = temp_dir / "convention-constitution" - (preset_dir / "templates").mkdir(parents=True) - (preset_dir / "templates" / "constitution-template.md").write_text( - "# Convention Constitution\n" - ) - (preset_dir / "templates" / "spec-template.md").write_text("# Spec\n") - (preset_dir / "preset.yml").write_text( - yaml.dump( - { - "schema_version": "1.0", - "preset": { - "id": "convention-constitution", - "name": "Convention Constitution", - "version": "1.0.0", - "description": "Convention-based constitution for testing", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "templates": [ - { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - } - ] - }, - } - ) - ) - return preset_dir - - class TestSelfTestPreset: """Tests using the self-test preset that ships with the repo. @@ -10899,145 +10469,6 @@ def test_short_and_namespaced_commands_scaffold_consistently( assert manager.registry.get("ns-cmd")["registered_commands"] != {} -class TestPresetSetPriority: - """Test preset set-priority CLI command.""" - - def test_set_priority_changes_priority(self, project_dir, pack_dir): - """Test set-priority command changes preset priority.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset with default priority - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Verify default priority - assert manager.registry.get("test-pack")["priority"] == 10 - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "priority changed: 10 → 5" in plain - - # Reload registry to see updated value - manager2 = PresetManager(project_dir) - assert manager2.registry.get("test-pack")["priority"] == 5 - - def test_set_priority_reconciles_generated_constitution( - self, project_dir, temp_dir - ): - """Changing priority rematerializes an unchanged generated constitution.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - manager.install_from_directory( - _make_convention_constitution_preset(temp_dir), "0.1.5", priority=20 - ) - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert "preset:self-test" in memory.read_text() - - with patch.object(Path, "cwd", return_value=project_dir): - result = CliRunner().invoke( - app, - ["preset", "set-priority", "convention-constitution", "1"], - ) - - assert result.exit_code == 0, result.output - assert memory.read_text() == "# Convention Constitution\n" - - def test_set_priority_same_value_no_change(self, project_dir, pack_dir): - """Test set-priority with same value shows already set message.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset with priority 5 - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5", priority=5) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "already has priority 5" in plain - - def test_set_priority_repairs_corrupted_bool(self, project_dir, pack_dir): - """A corrupted boolean priority must be repaired, not skipped. - - ``isinstance(True, int)`` is True and ``True == 1`` in Python, so a - stored ``True`` priority would short-circuit the ``already has - priority 1`` skip path and never get rewritten to a real int — - contradicting the comment that promises corrupted values are - repaired. The guard must exclude bools (like normalize_priority). - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5", priority=5) - # Inject a corrupted boolean priority (True == 1). - manager.registry.update("test-pack", {"priority": True}) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "1"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - # The corrupted bool must be repaired, not reported as already-set. - assert "already has priority" not in plain - assert "priority changed" in plain - - # The stored value is now a real int, not a bool. - reloaded = PresetManager(project_dir).registry.get("test-pack") - assert reloaded["priority"] == 1 - assert not isinstance(reloaded["priority"], bool) - - def test_set_priority_invalid_value(self, project_dir, pack_dir): - """Test set-priority rejects invalid priority values.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"]) - - assert result.exit_code == 1, result.output - assert "Priority must be a positive integer" in result.output - - def test_set_priority_not_installed(self, project_dir): - """Test set-priority fails for non-installed preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"]) - - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() class TestPresetPriorityBackwardsCompatibility: @@ -11123,177 +10554,13 @@ def test_mixed_legacy_and_new_presets_ordering(self, temp_dir): class TestPresetEnableDisable: """Test preset enable/disable CLI commands.""" - def test_disable_preset(self, project_dir, pack_dir): - """Test disable command sets enabled=False.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - # Verify initially enabled - assert manager.registry.get("test-pack").get("enabled", True) is True - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 0, result.output - assert "disabled" in result.output.lower() - - # Reload registry to see updated value - manager2 = PresetManager(project_dir) - assert manager2.registry.get("test-pack")["enabled"] is False - - def test_enable_preset(self, project_dir, pack_dir): - """Test enable command sets enabled=True.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset and disable it - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.update("test-pack", {"enabled": False}) - - # Verify disabled - assert manager.registry.get("test-pack")["enabled"] is False - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "test-pack"]) - assert result.exit_code == 0, result.output - assert "enabled" in result.output.lower() - # Reload registry to see updated value - manager2 = PresetManager(project_dir) - assert manager2.registry.get("test-pack")["enabled"] is True - def test_enable_disable_reconciles_generated_constitution( - self, project_dir, temp_dir - ): - """Enable and disable rematerialize the winning constitution layer.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manager = PresetManager(project_dir) - install_constitution_sync_preset(manager) - install_self_test_preset(manager) - manager.install_from_directory( - _make_convention_constitution_preset(temp_dir), "0.1.5", priority=1 - ) - memory = project_dir / ".specify" / "memory" / "constitution.md" - assert memory.read_text() == "# Convention Constitution\n" - runner = CliRunner() - - with patch.object(Path, "cwd", return_value=project_dir): - disabled = runner.invoke( - app, ["preset", "disable", "convention-constitution"] - ) - - assert disabled.exit_code == 0, disabled.output - assert "preset:self-test" in memory.read_text() - - with patch.object(Path, "cwd", return_value=project_dir): - enabled = runner.invoke( - app, ["preset", "enable", "convention-constitution"] - ) - - assert enabled.exit_code == 0, enabled.output - assert memory.read_text() == "# Convention Constitution\n" - - def test_stack_changes_do_not_create_missing_constitution( - self, project_dir, pack_dir - ): - """Stack changes for non-providers do not seed a missing constitution.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - PresetManager(project_dir).install_from_directory(pack_dir, "0.1.5") - memory = project_dir / ".specify" / "memory" / "constitution.md" - runner = CliRunner() - - for args in ( - ["preset", "set-priority", "test-pack", "5"], - ["preset", "disable", "test-pack"], - ["preset", "enable", "test-pack"], - ): - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, args) - assert result.exit_code == 0, result.output - assert not memory.exists() - - def test_disable_already_disabled(self, project_dir, pack_dir): - """Test disable on already disabled preset shows warning.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset and disable it - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.update("test-pack", {"enabled": False}) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 0, result.output - assert "already disabled" in result.output.lower() - - def test_enable_already_enabled(self, project_dir, pack_dir): - """Test enable on already enabled preset shows warning.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset (enabled by default) - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "test-pack"]) - - assert result.exit_code == 0, result.output - assert "already enabled" in result.output.lower() - - def test_disable_not_installed(self, project_dir): - """Test disable fails for non-installed preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "nonexistent"]) - - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() - def test_enable_not_installed(self, project_dir): - """Test enable fails for non-installed preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "nonexistent"]) - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): """Test that disabled presets are excluded from template resolution.""" @@ -11321,54 +10588,16 @@ def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir): result2 = resolver2.resolve("test-template", "template") assert result2 is None - def test_enable_corrupted_registry_entry(self, project_dir, pack_dir): - """Test enable fails gracefully for corrupted registry entry.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install preset then corrupt the registry entry - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.data["presets"]["test-pack"] = "corrupted-string" - manager.registry._save() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "enable", "test-pack"]) - assert result.exit_code == 1 - assert "corrupted state" in result.output.lower() - def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): - """Test disable fails gracefully for corrupted registry entry.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app +# ===== Lean Preset Tests ===== - runner = CliRunner() - # Install preset then corrupt the registry entry - manager = PresetManager(project_dir) - manager.install_from_directory(pack_dir, "0.1.5") - manager.registry.data["presets"]["test-pack"] = "corrupted-string" - manager.registry._save() - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["preset", "disable", "test-pack"]) - - assert result.exit_code == 1 - assert "corrupted state" in result.output.lower() - - -# ===== Lean Preset Tests ===== - - -LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" -CORE_CONSTITUTION_COMMAND = ( - Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" -) +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" +CORE_CONSTITUTION_COMMAND = ( + Path(__file__).parent.parent / "templates" / "commands" / "constitution.md" +) LEAN_COMMAND_NAMES = [ "speckit.specify", @@ -11493,256 +10722,6 @@ def test_lean_overrides_commands(self, project_dir): assert result is not None, f"Lean override for {name} not resolved" -# ===== Preset Update Command Tests ===== - - -class TestPresetUpdateCommand: - """Test the destructive remove-then-add update contract.""" - - @staticmethod - def _manager(monkeypatch, project_dir, installed=True): - from specify_cli.presets import _commands as commands - - registry = SimpleNamespace(is_installed=lambda _preset_id: installed) - manager = SimpleNamespace(registry=registry) - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.presets.PresetManager", lambda _root: manager) - return commands - - def test_unknown_preset_fails_without_remove_or_add(self, project_dir, monkeypatch): - commands = self._manager(monkeypatch, project_dir, installed=False) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda *_args: calls.append("remove")) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update("missing", from_url=None, dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - assert calls == [] - - @pytest.mark.parametrize( - ("from_url", "dev"), - [ - ("https://example.com/preset.zip", "./preset"), - ("", "./preset"), - ("https://example.com/preset.zip", ""), - ], - ) - def test_mutually_exclusive_sources_are_rejected( - self, project_dir, monkeypatch, from_url, dev - ): - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda *_args: calls.append("remove")) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=from_url, - dev=dev, - priority=10, - ) - - assert exc_info.value.exit_code == 1 - assert calls == [] - - @pytest.mark.parametrize( - ("from_url", "dev", "option"), - [("", None, "--from"), (None, "", "--dev")], - ) - def test_empty_source_is_rejected_before_removal( - self, project_dir, monkeypatch, capsys, from_url, dev, option - ): - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda *_args: calls.append("remove")) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=from_url, - dev=dev, - priority=10, - ) - - assert exc_info.value.exit_code == 1 - assert calls == [] - assert f"{option} must not be empty" in strip_ansi(capsys.readouterr().out) - - def test_remove_failure_prevents_add(self, project_dir, monkeypatch): - commands = self._manager(monkeypatch, project_dir) - calls = [] - - def fail_remove(_preset_id): - calls.append("remove") - raise typer.Exit(1) - - monkeypatch.setattr(commands, "preset_remove", fail_remove) - monkeypatch.setattr(commands, "preset_add", lambda **_kwargs: calls.append("add")) - - with pytest.raises(typer.Exit) as exc_info: - preset_update("test-pack", from_url=None, dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - assert calls == ["remove"] - - def test_update_forwards_id_sources_and_priority_to_add(self, project_dir, monkeypatch): - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr(commands, "preset_remove", lambda preset_id: calls.append(("remove", preset_id))) - monkeypatch.setattr( - commands, - "preset_add", - lambda **kwargs: calls.append(("add", kwargs)), - ) - - preset_update( - "test-pack", - from_url="https://example.com/replacement.zip", - dev=None, - priority=4, - ) - - assert calls == [ - ("remove", "test-pack"), - ( - "add", - { - "preset_id": "test-pack", - "from_url": "https://example.com/replacement.zip", - "dev": None, - "priority": 4, - }, - ), - ] - - def test_add_failure_states_removed_and_prints_retry_command( - self, project_dir, monkeypatch, capsys - ): - commands = self._manager(monkeypatch, project_dir) - monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) - - def fail_add(**_kwargs): - raise typer.Exit(1) - - monkeypatch.setattr(commands, "preset_add", fail_add) - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=None, - dev="/tmp/replacement preset", - priority=6, - ) - - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - assert "previous preset was removed" in output - retry_args = [ - "specify", - "preset", - "add", - "test-pack", - "--dev", - "/tmp/replacement preset", - "--priority", - "6", - ] - expected = ( - _render_powershell_argv(retry_args) - if os.name == "nt" - else shlex.join(retry_args) - ) - assert expected in output - - def test_retry_command_quotes_powershell_metacharacters( - self, project_dir, monkeypatch, capsys - ): - """Windows retry commands keep PowerShell metacharacters literal.""" - commands = self._manager(monkeypatch, project_dir) - monkeypatch.setattr(commands, "preset_remove", lambda _preset_id: None) - - def fail_add(**_kwargs): - raise typer.Exit(1) - - monkeypatch.setattr(commands, "preset_add", fail_add) - monkeypatch.setattr(os, "name", "nt") - - with pytest.raises(typer.Exit) as exc_info: - preset_update( - "test-pack", - from_url=None, - dev=r"C:\replacement&$backup's presets", - priority=6, - ) - - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - expected = ( - "& 'specify' 'preset' 'add' 'test-pack' '--dev' " - "'C:\\replacement&$backup''s presets' '--priority' '6'" - ) - assert "Retry in PowerShell: " in output - assert expected in output - - def test_powershell_retry_renderer_preserves_literal_arguments(self): - """The rendered command survives parsing by a real PowerShell.""" - powershell = shutil.which("pwsh") or shutil.which("powershell") - if powershell is None: - pytest.skip("PowerShell is not available") - - arguments = [ - "https://example.com/archive.zip?one=1&two=$value", - r"C:\owner's presets", - ] - rendered = _render_powershell_argv( - [ - sys.executable, - "-c", - "import json,sys; print(json.dumps(sys.argv[1:]))", - *arguments, - ] - ) - result = subprocess.run( - [powershell, "-NoProfile", "-Command", rendered], - check=True, - capture_output=True, - text=True, - ) - - assert json.loads(result.stdout) == arguments - - def test_invalid_priority_rejected_before_removal( - self, project_dir, monkeypatch, capsys - ): - """--priority 0 must fail without removing the installed preset. - - add rejects the same range, but only after remove has run. Validating - late would delete the preset and print a retry command carrying the - rejected priority, so the retry could never succeed. - """ - commands = self._manager(monkeypatch, project_dir) - calls = [] - monkeypatch.setattr( - commands, "preset_remove", lambda preset_id: calls.append("remove") - ) - monkeypatch.setattr( - commands, "preset_add", lambda **_kwargs: calls.append("add") - ) - - with pytest.raises(typer.Exit) as exc_info: - preset_update("test-pack", from_url=None, dev=None, priority=0) - - assert exc_info.value.exit_code == 1 - assert calls == [] - output = strip_ansi(capsys.readouterr().out) - assert "Priority must be a positive integer" in output - assert "previous preset was removed" not in output - - # ===== Bundled Preset Locator Tests ===== @@ -11772,393 +10751,17 @@ def test_locate_bundled_preset_rejects_invalid_id(self): assert _locate_bundled_preset("UPPERCASE") is None assert _locate_bundled_preset("has spaces") is None - def test_bundled_preset_add_via_cli(self, project_dir): - """Test that 'specify preset add lean' installs the bundled preset.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="0.6.0"): - result = runner.invoke(app, ["preset", "add", "lean"]) - - assert result.exit_code == 0, result.output - assert "Lean Workflow" in result.output - assert "installed" in result.output.lower() - - def test_preset_add_catalog_forwards_catalog_name(self, project_dir, monkeypatch): - """Catalog installs pass resolved provenance into the manager boundary.""" - from specify_cli.presets._commands import preset_add - - captured = {} - - def fake_install_from_zip(self, _archive, _version, priority=10, *, catalog_name=None): - captured.update(priority=priority, catalog_name=catalog_name) - return SimpleNamespace(name="Catalog Preset", version="1.0.0") - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "1.0.0") - monkeypatch.setattr( - PresetCatalog, - "get_pack_info", - lambda _self, _id: { - "name": "Catalog Preset", - "_install_allowed": True, - "_catalog_name": "preset-catalog", - }, - ) - archive = project_dir / "preset.zip" - archive.write_bytes(b"archive") - monkeypatch.setattr(PresetCatalog, "download_pack", lambda _self, _id: archive) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - preset_add(preset_id="catalog-preset", from_url=None, dev=None, priority=7) - - assert captured == {"priority": 7, "catalog_name": "preset-catalog"} - - def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch): - """URL installs reject redirects from HTTPS to non-loopback HTTP.""" - import typer - from specify_cli.presets._commands import preset_add - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "http://example.com/preset.zip" - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - assert redirect_validator is not None - redirect_validator(url, "http://example.com/preset.zip") - return FakeResponse(b"zip") - - monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) - - installed = False - - def fake_install_from_zip(self, zip_path, speckit_version, priority=10): - nonlocal installed - installed = True - - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - with pytest.raises(typer.Exit) as exc_info: - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - assert installed is False - - def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir): - """URL installs reject HTTPS URLs without a hostname before downloading.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"]) - - assert result.exit_code == 1 - output = strip_ansi(result.output) - assert "URL must use HTTPS with a hostname" in output - assert "got https://" not in output - open_url.assert_not_called() - - def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir): - """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://[::1/preset.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Invalid URL" in output - open_url.assert_not_called() - - def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir): - """A bracketed-but-invalid IPv6 host in --from must exit cleanly. - "https://[not-an-ip]/preset.zip" is a malformed authority that raises - ValueError during URL validation; the try/except guard around parsing - and the .hostname read must turn that into a clean "Invalid URL" message. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://[not-an-ip]/preset.zip"], - catch_exceptions=True, - ) - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Invalid URL" in output - open_url.assert_not_called() - def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir): - """An out-of-range port raises ValueError lazily on .port access. - The up-front guard reads ``_parsed.port`` (urllib validates the port - range/syntax there) inside its try/except, so "https://example.com:99999/ - preset.zip" must produce a clean "Invalid URL" message rather than - leaking a raw ValueError traceback past the CLI. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["preset", "add", "--from", "https://example.com:99999/preset.zip"], - catch_exceptions=True, - ) - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - assert "Invalid URL" in strip_ansi(result.output) - open_url.assert_not_called() - def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir): - """A catalog download_url with a bracketed non-IP host must render cleanly. - ``download_pack`` raises ``PresetError`` whose message embeds the raw URL - (e.g. ``https://[not-an-ip]/x``). The ``preset_add`` handler must escape - that message before printing so Rich does not interpret ``[not-an-ip]`` - as a markup tag and crash while rendering the error. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - bad_url = "https://[not-an-ip]/x" - catalog_data = { - "test-pack": { - "name": "Test Pack", - "version": "1.0.0", - "download_url": bad_url, - } - } - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "_get_merged_packs", return_value=catalog_data): - result = runner.invoke( - app, - ["preset", "add", "test-pack"], - catch_exceptions=True, - ) - assert result.exit_code == 1, result.output - assert result.exception is None or isinstance(result.exception, SystemExit) - output = strip_ansi(result.output) - assert "Error:" in output - # The malformed URL surfaces verbatim rather than crashing the renderer. - assert bad_url in output - @pytest.mark.parametrize( - ("exc_type", "label"), - [ - (PresetCompatibilityError, "Compatibility Error"), - (PresetValidationError, "Validation Error"), - (PresetError, "Error"), - ], - ) - def test_preset_add_exception_handlers_escape_markup(self, project_dir, exc_type, label): - """Preset install exceptions can include catalog-controlled values. - - The message must be escaped so Rich does not treat bracketed content as - markup and raise while rendering the error. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - dev_dir = project_dir / "dev-pack" - dev_dir.mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object( - PresetManager, - "install_from_directory", - side_effect=exc_type("bad [red]preset[/red]"), - ): - result = runner.invoke( - app, - ["preset", "add", "--dev", str(dev_dir)], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert result.exception is None or isinstance(result.exception, SystemExit) - assert f"{label}:" in result.output - assert "bad [red]preset[/red]" in result.output - - def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys): - """Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs.""" - import typer - from specify_cli.presets._commands import preset_add - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "https:///preset.zip" - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda url, timeout=None, extra_headers=None, redirect_validator=None: FakeResponse(b"zip"), - ) - monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None) - - with pytest.raises(typer.Exit) as exc_info: - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) - - assert exc_info.value.exit_code == 1 - output = strip_ansi(capsys.readouterr().out) - assert "redirected to a disallowed URL" in output - assert "must use HTTPS with a hostname" in output - - def test_preset_add_from_url_reads_in_bounded_chunks(self, project_dir, monkeypatch): - """URL installs read the response in bounded chunks.""" - from specify_cli.presets._commands import preset_add - - class FakeResponse(io.BytesIO): - def __init__(self, data): - super().__init__(data) - self.read_sizes = [] - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "https://example.com/preset.zip" - - def read(self, size=-1): - assert size not in (-1, None) - self.read_sizes.append(size) - return super().read(size) - - response = FakeResponse(b"PK\x05\x06" + b"\x00" * 18) - installed = {} - - def fake_install_from_zip(self, zip_path, speckit_version, priority=10): - installed["zip_bytes"] = Path(zip_path).read_bytes() - installed["speckit_version"] = speckit_version - installed["priority"] = priority - return SimpleNamespace(name="Test Preset", version="1.0.0") - - monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda url, timeout=None, extra_headers=None, redirect_validator=None: response, - ) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7) - - assert response.read_sizes - assert installed == { - "zip_bytes": b"PK\x05\x06" + b"\x00" * 18, - "speckit_version": "0.6.0", - "priority": 7, - } - - def test_preset_add_from_url_rejects_oversized_download( - self, project_dir, monkeypatch, capsys - ): - """An oversized direct download fails before preset installation.""" - import typer - from specify_cli._download_security import ( - read_response_limited as real_read_response_limited, - ) - from specify_cli.presets import _commands as preset_commands - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def geturl(self): - return "https://example.com/preset.zip" - - def read_with_tiny_limit(response, **kwargs): - kwargs.pop("max_bytes", None) - return real_read_response_limited(response, max_bytes=4, **kwargs) - - installed = False - - def fake_install_from_zip(*_args, **_kwargs): - nonlocal installed - installed = True - - monkeypatch.setattr( - preset_commands, - "read_response_limited", - read_with_tiny_limit, - ) - monkeypatch.setattr( - "specify_cli._require_specify_project", - lambda: project_dir, - ) - monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") - monkeypatch.setattr( - "specify_cli.authentication.http.open_url", - lambda *_args, **_kwargs: FakeResponse(b"12345"), - ) - monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) - - with pytest.raises(typer.Exit) as exc_info: - preset_commands.preset_add( - preset_id=None, - from_url="https://example.com/preset.zip", - dev=None, - priority=10, - ) - - assert exc_info.value.exit_code == 1 - output = " ".join(strip_ansi(capsys.readouterr().out).split()) - assert "exceeds maximum size of 4 bytes" in output - assert installed is False def test_bundled_preset_in_catalog(self): """Verify the lean preset is listed in catalog.json with bundled marker.""" @@ -12184,171 +10787,8 @@ def test_bundled_preset_download_raises_error(self, project_dir): with pytest.raises(PresetError, match="bundled with spec-kit"): catalog.download_pack("test-bundled") - def test_bundled_preset_missing_locally_cli_error(self, project_dir): - """CLI shows clear error when bundled preset cannot be found locally.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - # Patch _locate_bundled_preset to return None (simulating missing files) - # and mock the catalog to return a bundled entry for "lean" - fake_pack_info = { - "id": "lean", - "name": "Lean Workflow", - "version": "1.0.0", - "bundled": True, - "_install_allowed": True, - } - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli._locate_bundled_preset", return_value=None), \ - patch("specify_cli.presets.PresetCatalog") as MockCatalog: - MockCatalog.return_value.get_pack_info.return_value = fake_pack_info - result = runner.invoke(app, ["preset", "add", "lean"]) - - # Should fail with a helpful error explaining this is a bundled preset - # and suggesting how to recover. - assert result.exit_code == 1 - output = strip_ansi(result.output).lower() - assert "bundled" in output, result.output - assert "reinstall" in output, result.output - - -class TestPresetAddFromUrlResolution: - """CLI-level tests for preset add --from GitHub release resolution.""" - - def test_preset_add_from_github_release_url_resolves_and_downloads(self, project_dir): - """'preset add --from ' resolves to API asset URL.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = __import__("io").BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() - - captured_urls = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - if "releases/tags/" in url: - return io.BytesIO(json.dumps({ - "assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}] - }).encode()) - return io.BytesIO(zip_bytes) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://github.com/org/repo/releases/download/v1.0/preset.zip", - ]) - - assert result.exit_code == 0, result.output - assert "My Preset" in result.output - # First call should resolve the release tag - assert any("releases/tags/v1.0" in url for url, _ in captured_urls) - # Second call should download from the resolved asset URL with octet-stream - asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] - assert len(asset_calls) >= 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir): - """'preset add --from ' uses URL directly with octet-stream.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = __import__("io").BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() - - captured_urls = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - return io.BytesIO(zip_bytes) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://api.github.com/repos/org/repo/releases/assets/42", - ]) - - assert result.exit_code == 0, result.output - # Should go directly to the asset URL with Accept header - assert len(captured_urls) == 1 - assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" - assert captured_urls[0][1] == {"Accept": "application/octet-stream"} - - def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): - """'preset add --from ' resolves via GHES /api/v3 endpoint.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - from specify_cli.authentication import http as _auth_http - from specify_cli.authentication.config import AuthConfigEntry - - monkeypatch.setattr(_auth_http, "_config_override", [ - AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), - ]) - manifest_content = yaml.dump({ - "schema_version": "1.0", - "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, - }) - zip_buf = io.BytesIO() - with zipfile.ZipFile(zip_buf, "w") as zf: - zf.writestr("preset.yml", manifest_content) - zip_bytes = zip_buf.getvalue() - captured_urls = [] - - def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): - captured_urls.append((url, extra_headers)) - if "releases/tags/" in url: - return io.BytesIO(json.dumps({ - "assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] - }).encode()) - return io.BytesIO(zip_bytes) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): - result = runner.invoke(app, [ - "preset", "add", - "--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", - ]) - - assert result.exit_code == 0, result.output - # The tag-lookup call must use the GHES /api/v3 endpoint - assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) - # The asset download call must carry Accept: application/octet-stream - asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] - assert len(asset_calls) >= 1 - assert asset_calls[0][1] == {"Accept": "application/octet-stream"} class TestWrapStrategy: @@ -14353,559 +12793,12 @@ def test_composes_wrap_strategy_when_ensuring(self, project_dir, temp_dir): assert "[PROJECT_NAME]" in content -class TestPresetTagsNonString: - """Non-string catalog tags must not crash preset display commands. - Catalog payloads are user-editable YAML/JSON, so a `tags:` list can contain - numbers or other non-strings. The display path joins them; a raw - ``", ".join(...)`` blows up with ``TypeError: sequence item 0: expected str``. - Sibling command surfaces (extensions/integrations/workflows) already guard - this with ``str(t) for t in ...`` — presets must match. - """ - def _seed_catalog(self, project_dir, tags, extra=None): - catalog = PresetCatalog(project_dir) - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - pack = { - "name": "Numeric Tags", - "description": "Preset with non-string tags", - "version": "1.0.0", - "tags": tags, - } - if extra: - pack.update(extra) - catalog_data = { - "schema_version": "1.0", - "presets": { - "numeric-tags": pack, - }, - } - catalog.cache_file.write_text(json.dumps(catalog_data)) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - })) - return catalog - - def test_search_renders_non_string_tags(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, [1, 2]) - default_only = [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): - result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Tags: 1, 2" in plain - - def test_info_renders_non_string_tags(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, [1, 2]) - default_only = [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", return_value=default_only): - result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Tags: 1, 2" in plain - - def _default_only(self, catalog): - return [PresetCatalogEntry( - url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True - )] - - def test_search_by_author_tolerates_non_string_author(self, project_dir): - """``--author`` must not crash on a numeric catalog ``author``. - - ``PresetCatalog.search`` called ``.lower()`` straight on the raw value, - raising ``AttributeError: 'int' object has no attribute 'lower'``. The - sibling extension/integration catalogs coerce with ``str(...)`` first. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - catalog = self._seed_catalog(project_dir, ["ci"], extra={"author": 789}) - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "--author", "789"]) - assert result.exit_code == 0, result.output - assert "Numeric Tags" in strip_ansi(result.output) - def test_search_query_tolerates_non_string_name_and_description(self, project_dir): - """A query search must not crash on numeric ``name``/``description``. - - The searchable-text join passed the raw values through, raising - ``TypeError: sequence item 0: expected str instance, int found``. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog( - project_dir, ["ci"], extra={"name": 123, "description": 456} - ) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "123"]) - - assert result.exit_code == 0, result.output - assert "numeric-tags" in strip_ansi(result.output) - - def test_search_tolerates_non_list_tags(self, project_dir): - """A scalar ``tags:`` value must not crash the tag filter or display. - - ``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and - the result-display join raised ``TypeError: 'int' object is not - iterable``. Siblings guard with ``isinstance(raw_tags, list)``. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, 5) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"]) - displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"]) - - assert filtered.exit_code == 0, filtered.output - assert "No presets found" in strip_ansi(filtered.output) - - assert displayed.exit_code == 0, displayed.output - plain = strip_ansi(displayed.output) - assert "Numeric Tags" in plain - assert "Tags:" not in plain - - def test_info_tolerates_non_list_tags(self, project_dir): - """``preset info`` must not crash rendering a scalar ``tags:`` value.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, 5) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "numeric-tags" in plain - assert "Tags:" not in plain - - def test_search_escapes_rich_markup_in_tags(self, project_dir): - """Bracketed tag text must survive Rich markup parsing. - - ``preset search`` printed tags unescaped, so a tag like ``[bold]`` was - swallowed as a style tag. ``preset list`` already escaped this. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - catalog = self._seed_catalog(project_dir, ["[bold]ci"]) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(PresetCatalog, "get_active_catalogs", - return_value=self._default_only(catalog)): - result = CliRunner().invoke(app, ["preset", "search", "Numeric"]) - - assert result.exit_code == 0, result.output - assert "[bold]ci" in strip_ansi(result.output) - - -class TestPresetCatalogRichMarkup: - """Catalog metadata must render as literal text in Rich output.""" - - MARKUP_PRESET = { - "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]", - "tags": ["[italic]markup-tag[/italic]"], - "repository": "[bold]Markup Repository[/bold]", - "license": "[cyan]Markup License[/cyan]", - } - - def test_search_escapes_catalog_markup(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir), patch.object( - PresetCatalog, - "search", - return_value=[self.MARKUP_PRESET], - ): - result = CliRunner().invoke(app, ["preset", "search"]) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for value in ( - self.MARKUP_PRESET["id"], - self.MARKUP_PRESET["name"], - self.MARKUP_PRESET["version"], - self.MARKUP_PRESET["description"], - ): - assert value in output - - def test_info_escapes_catalog_markup(self, project_dir): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir), patch.object( - PresetCatalog, - "get_pack_info", - return_value=self.MARKUP_PRESET, - ): - result = CliRunner().invoke( - app, - ["preset", "info", self.MARKUP_PRESET["id"]], - ) - - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - for field in ( - "id", - "name", - "version", - "description", - "author", - "repository", - "license", - ): - value = self.MARKUP_PRESET[field] - assert value in output - # Tags are joined into a single line, so assert on the rendered join. - assert ", ".join(self.MARKUP_PRESET["tags"]) in output - - -class TestInstalledPresetRichMarkup: - """Locally installed preset metadata must render as literal text. - - ``preset.yml`` is user-editable, so its fields can contain ``[...]``. - ``TestPresetCatalogRichMarkup`` covers the catalog branch of these - commands; the installed-preset branch of ``preset list``/``preset info`` - and all of ``preset resolve`` were left unescaped, so a field like - ``Does [stuff] nicely`` silently rendered as ``Does nicely`` and an - unbalanced tag such as ``[/red]`` raised ``rich.errors.MarkupError``, - aborting the command with a traceback. - """ - - MARKUP_FIELDS = { - "name": "[green]Markup Name[/green]", - "version": "1.0.0", - "description": "[yellow]Markup Description[/yellow]", - "author": "[magenta]Markup Author[/magenta]", - "repository": "[bold]Markup Repository[/bold]", - "license": "[cyan]Markup License[/cyan]", - } - - def _install(self, temp_dir, project_dir, preset_overrides=None, strategy=None, - pack_id="markup-pack", priority=10, tmpl_description=None): - """Install a preset from a directory built with the given manifest fields.""" - from specify_cli.presets import PresetManager - - src = temp_dir / f"src-{pack_id}" - (src / "templates").mkdir(parents=True) - (src / "templates" / "spec-template.md").write_text("# tmpl\n") - - preset_section = { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "plain description", - } - preset_section.update(preset_overrides or {}) - tmpl = { - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - } - if tmpl_description is not None: - tmpl["description"] = tmpl_description - if strategy: - tmpl["strategy"] = strategy - (src / "preset.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "preset": preset_section, - "requires": {"speckit_version": ">=0.0.1"}, - "provides": {"templates": [tmpl]}, - "tags": ["[italic]markup-tag[/italic]"], - })) - - manager = PresetManager(project_dir) - manager.install_from_directory(src, "9.9.9", priority) - return manager - - def _invoke(self, project_dir, args): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir): - return CliRunner().invoke(app, args) - - def test_list_and_info_escape_installed_markup(self, temp_dir, project_dir): - """Every ``preset.yml`` field must survive verbatim in list/info output.""" - self._install(temp_dir, project_dir, preset_overrides=self.MARKUP_FIELDS) - - for args in (["preset", "list"], ["preset", "info", "markup-pack"]): - result = self._invoke(project_dir, args) - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - # `preset list` does not render repository/license. - fields = ("name", "description") if args[1] == "list" else self.MARKUP_FIELDS - for field in fields: - assert self.MARKUP_FIELDS[field] in output, (field, args, output) - assert "[italic]markup-tag[/italic]" in output, (args, output) - - def test_info_does_not_swallow_template_description(self, temp_dir, project_dir): - """The per-template line in ``preset info`` must escape the template description. - - ``name``/``type`` are format-restricted by manifest validation, but - ``description`` is free-form, so it is the field that can carry markup. - """ - self._install( - temp_dir, - project_dir, - tmpl_description="Template [desc] here", - ) - result = self._invoke(project_dir, ["preset", "info", "markup-pack"]) - assert result.exit_code == 0, result.output - output = " ".join(strip_ansi(result.output).split()) - assert "spec-template (template): Template [desc] here" in output, output - - def test_unbalanced_markup_does_not_crash_list_or_info(self, temp_dir, project_dir): - """An unbalanced tag must not raise MarkupError and abort the command.""" - self._install( - temp_dir, - project_dir, - preset_overrides={"description": "Broken [/red] tag"}, - ) - - for args in (["preset", "list"], ["preset", "info", "markup-pack"]): - result = self._invoke(project_dir, args) - assert result.exit_code == 0, (args, result.output, result.exception) - assert "Broken [/red] tag" in strip_ansi(result.output) - - def test_resolve_rejects_invalid_template_name(self, project_dir): - """``preset resolve`` rejects names before joining them into paths.""" - result = self._invoke(project_dir, ["preset", "resolve", "no[/red]such"]) - assert result.exit_code == 1, (result.output, result.exception) - assert "invalid template name" in strip_ansi(result.output) - - def test_resolve_rejects_path_traversal(self, project_dir): - """The resolver rejects traversal before joining names into paths.""" - result = self._invoke( - project_dir, - ["preset", "resolve", "../../../README"], - ) - - assert result.exit_code == 1 - assert "invalid template name" in strip_ansi(result.output) - - def test_resolve_accepts_dotted_command_name(self, project_dir): - """Documented dotted command identifiers use command resolution.""" - result = self._invoke( - project_dir, - ["preset", "resolve", "speckit.constitution"], - ) - - assert result.exit_code == 0, (result.output, result.exception) - assert "constitution.md" in "".join(strip_ansi(result.output).split()) - - def test_resolve_rejects_empty_command_segments(self, project_dir): - """Dotted command identifiers cannot contain empty path-like segments.""" - result = self._invoke( - project_dir, - ["preset", "resolve", "speckit..constitution"], - ) - - assert result.exit_code == 1 - assert "invalid template name" in strip_ansi(result.output) - - def test_resolve_escapes_layer_path_and_source(self, project_dir): - """The top-layer path/source lines must render markup literally. - - A preset can be installed from any directory, so the resolved path can - contain ``[...]``; the layer source carries the pack id and version. - """ - from unittest.mock import patch - from specify_cli.presets import PresetResolver - - # A closing tag cannot live inside a path segment: `Path` treats its - # `/` as a separator on POSIX and rewrites it to `\` on Windows. The - # opening tag covers the swallowing case for the path; the unbalanced - # closing tag rides on `source`, which is a plain string. - layer = { - "path": Path("/tmp/[red]dir/spec-template.md"), - "source": "pack [/red] v1.0.0", - "strategy": "replace", - } - with patch.object(PresetResolver, "collect_all_layers", return_value=[layer]): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "[red]dir" in output, output - assert "pack [/red] v1.0.0" in output, output - - def test_resolve_escapes_fallback_path_and_source(self, project_dir): - """The no-layer fallback branch must escape ``resolve_with_source`` output.""" - from unittest.mock import patch - from specify_cli.presets import PresetResolver - - with patch.object( - PresetResolver, "collect_all_layers", return_value=[] - ), patch.object( - PresetResolver, - "resolve_with_source", - return_value={ - "path": "/tmp/[blue]fallback[/blue]/spec-template.md", - "source": "fallback [/red] source", - }, - ): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "[blue]fallback[/blue]" in output, output - assert "fallback [/red] source" in output, output - - def test_resolve_escapes_composition_error(self, project_dir): - """A composition exception message must not be parsed as markup.""" - from unittest.mock import patch - from specify_cli.presets import PresetResolver - - layers = [ - { - "path": Path("/tmp/top/spec-template.md"), - "source": "top-pack v1.0.0", - "strategy": "append", - }, - { - "path": Path("/tmp/base/spec-template.md"), - "source": "base-pack v1.0.0", - "strategy": "append", - }, - ] - with patch.object( - PresetResolver, "collect_all_layers", return_value=layers - ), patch.object( - PresetResolver, - "resolve_content", - side_effect=RuntimeError("compose failed: [/red] bad layer"), - ): - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - - assert result.exit_code == 0, (result.output, result.exception) - output = " ".join(strip_ansi(result.output).split()) - assert "compose failed: [/red] bad layer" in output, output - - def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir): - """The composition chain's ``[]`` label must not be eaten as a tag.""" - self._install(temp_dir, project_dir, strategy="replace", - pack_id="base-pack", priority=20) - self._install(temp_dir, project_dir, strategy="append", - pack_id="app-pack", priority=5) - - result = self._invoke(project_dir, ["preset", "resolve", "spec-template"]) - assert result.exit_code == 0, (result.output, result.exception) - output = strip_ansi(result.output) - assert "Composition chain" in output, output - assert "[base]" in output, output - assert "[append]" in output, output - - -class TestPresetListOrdering: - """``preset list`` must print presets in actual resolution/precedence order. - - Regression coverage for #4086: the printed order was registry/insertion - order, so a preset with a *higher* priority number (lower precedence) could - appear before one with a lower number, misleading users about which preset - wins. Output must be sorted by (priority, id) to match - ``PresetRegistry.list_by_priority()``. - """ - - def _install(self, temp_dir, project_dir, pack_id, priority): - from specify_cli.presets import PresetManager - - src = temp_dir / f"src-{pack_id}" - (src / "templates").mkdir(parents=True) - (src / "templates" / "spec-template.md").write_text("# tmpl\n") - (src / "preset.yml").write_text(yaml.dump({ - "schema_version": "1.0", - "preset": { - "id": pack_id, - "name": pack_id, - "version": "1.0.0", - "description": "plain description", - }, - "requires": {"speckit_version": ">=0.0.1"}, - "provides": {"templates": [{ - "type": "template", - "name": "spec-template", - "file": "templates/spec-template.md", - }]}, - })) - PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) - - def _invoke(self, project_dir, args): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - with patch.object(Path, "cwd", return_value=project_dir): - return CliRunner().invoke(app, args) - - def test_list_sorted_by_priority(self, temp_dir, project_dir): - """Lower priority number is listed first regardless of install order.""" - # Install in an order that does NOT match precedence. - self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) - self._install(temp_dir, project_dir, "lean", priority=10) - - result = self._invoke(project_dir, ["preset", "list"]) - assert result.exit_code == 0, result.output - output = strip_ansi(result.output) - # `lean` (priority 10) must appear before `copilot-sub-agents` (100). - assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output - assert "resolution order" in output, output - assert "Ties are broken by preset id" in output, output - - def test_list_ties_broken_by_id(self, temp_dir, project_dir): - """Equal priority ties are broken alphabetically by preset id.""" - self._install(temp_dir, project_dir, "zebra", priority=10) - self._install(temp_dir, project_dir, "alpha", priority=10) - - result = self._invoke(project_dir, ["preset", "list"]) - assert result.exit_code == 0, result.output - output = strip_ansi(result.output) - assert output.index("(alpha)") < output.index("(zebra)"), output class TestConstitutionSyncPreset: diff --git a/tests/unit/test_bundle_download_url.py b/tests/unit/test_bundle_download_url.py deleted file mode 100644 index 6a29423b9d..0000000000 --- a/tests/unit/test_bundle_download_url.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Unit tests for malformed download-URL handling in bundle manifest resolution.""" -from __future__ import annotations - -import hashlib -import io -from types import SimpleNamespace - -import pytest -import yaml - -from specify_cli.bundler import BundlerError -from specify_cli.bundler.models.catalog import CatalogEntry -from specify_cli.commands import bundle as bundle_commands -from specify_cli.commands.bundle import _download_manifest, _require_https -from tests.bundler_helpers import catalog_entry_dict, valid_manifest_dict - -_MALFORMED_URLS = [ - "https://[::1", # unclosed IPv6 bracket - "https://[not-an-ip]/bundle.yml", - "https://example.com:notaport/bundle.yml", - "https://example.com:70000/bundle.yml", -] - - -class _Response(io.BytesIO): - def __init__(self, body: bytes, url: str) -> None: - super().__init__(body) - self._url = url - - def geturl(self) -> str: - return self._url - - -def _resolved_entry(**overrides) -> SimpleNamespace: - entry = CatalogEntry.from_dict( - catalog_entry_dict( - "demo-bundle", - download_url="https://example.com/demo-bundle.yml", - **overrides, - ) - ) - return SimpleNamespace(entry=entry) - - -def _patch_download(monkeypatch, body: bytes) -> None: - def fake_open_url( - url, - timeout=10, - extra_headers=None, - redirect_validator=None, - ): - return _Response(body, url) - - monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) - - -@pytest.mark.parametrize("url", _MALFORMED_URLS) -def test_download_manifest_rejects_malformed_url_cleanly(url): - """A malformed download_url must raise BundlerError, not a raw ValueError. - - ``urlparse`` raises ``ValueError`` on a malformed authority (e.g. an - unclosed IPv6 bracket). The bundle CLI commands only catch BundlerError, so - a raw ValueError would escape as an uncaught traceback. Sibling of the - guarded ``_validate_remote_url`` (adapters) and the merged #3576 fix. - """ - resolved = SimpleNamespace( - entry=SimpleNamespace(id="mybundle", download_url=url) - ) - with pytest.raises(BundlerError): - _download_manifest(resolved, offline=True) - - -@pytest.mark.parametrize("url", _MALFORMED_URLS) -def test_require_https_rejects_malformed_url_cleanly(url): - """``_require_https`` must also surface BundlerError on a malformed authority. - - On older Python versions the ValueError is raised at ``.hostname`` access - rather than at ``urlparse``, so guarding both keeps the contract across the - CI Python matrix. - """ - with pytest.raises(BundlerError): - _require_https("bundle 'x'", url) - - -def test_download_manifest_bounds_remote_artifact(monkeypatch): - body = yaml.safe_dump(valid_manifest_dict()).encode() - _patch_download(monkeypatch, body) - monkeypatch.setattr(bundle_commands, "MAX_DOWNLOAD_BYTES", len(body) - 1) - - with pytest.raises(BundlerError, match="exceeds maximum size"): - _download_manifest(_resolved_entry(), offline=False) - - -def test_download_manifest_accepts_matching_sha256(monkeypatch): - body = yaml.safe_dump(valid_manifest_dict()).encode() - digest = hashlib.sha256(body).hexdigest() - _patch_download(monkeypatch, body) - - manifest = _download_manifest( - _resolved_entry(sha256=f"sha256:{digest}"), - offline=False, - ) - - assert manifest.bundle.id == "demo-bundle" - - -def test_download_manifest_accepts_legacy_entry_without_sha256(monkeypatch): - body = yaml.safe_dump(valid_manifest_dict()).encode() - _patch_download(monkeypatch, body) - resolved = SimpleNamespace( - entry=SimpleNamespace( - id="demo-bundle", - version="1.2.0", - download_url="https://example.com/demo-bundle.yml", - ) - ) - - manifest = _download_manifest(resolved, offline=False) - - assert manifest.bundle.version == "1.2.0" - - -@pytest.mark.parametrize("declared", ["0" * 64, "not-a-sha256"]) -def test_download_manifest_rejects_bad_sha256(monkeypatch, declared): - body = yaml.safe_dump(valid_manifest_dict()).encode() - _patch_download(monkeypatch, body) - - with pytest.raises(BundlerError, match="sha256|Integrity check"): - _download_manifest( - _resolved_entry(sha256=declared), - offline=False, - ) - - -@pytest.mark.parametrize( - ("field", "value", "message"), - [ - ("id", "other-bundle", "id mismatch"), - ("version", "9.9.9", "version mismatch"), - ], -) -def test_download_manifest_rejects_catalog_identity_mismatch( - monkeypatch, - field, - value, - message, -): - data = valid_manifest_dict() - data["bundle"][field] = value - _patch_download(monkeypatch, yaml.safe_dump(data).encode()) - - with pytest.raises(BundlerError, match=message): - _download_manifest(_resolved_entry(), offline=False) - - -def test_download_manifest_rejects_invalid_structure(monkeypatch): - data = valid_manifest_dict() - data["bundle"]["author"] = "" - _patch_download(monkeypatch, yaml.safe_dump(data).encode()) - - with pytest.raises(BundlerError, match="invalid bundle manifest"): - _download_manifest(_resolved_entry(), offline=False)