diff --git a/AGENTS.md b/AGENTS.md index 9f4fa41f4e..18c80c80a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,14 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their preferred tools while maintaining consistent project structure and development practices. +## Repository Design References + +Before adding or reorganizing Specify CLI commands, read +[Specify CLI Command Architecture](design/cli.md). It defines command-module +naming, private command phases, nested command groups, registration ownership, +mirrored tests, and the rationale for making the CLI structure predictable for +both humans and coding agents. + --- ## Quickstart — Add a New Integration in 5 Steps diff --git a/design/cli.md b/design/cli.md new file mode 100644 index 0000000000..eb563da736 --- /dev/null +++ b/design/cli.md @@ -0,0 +1,303 @@ +# Specify CLI Command Architecture + +This document defines the target structure for multi-command groups in the +Specify Python CLI. It explains where command handlers, shared infrastructure, +command-private phases, nested command groups, and their tests belong. + +`src/specify_cli/extensions/` is the reference implementation. Apply this +design incrementally when adding or refactoring other command groups; do not +create extra modules merely to make a small command conform visually. + +## Design goals + +The CLI structure should make the answer to "where does this command live?" +predictable from the command line itself. + +The design optimizes for: + +- **Direct navigation:** a command maps to an obvious source file and test. +- **Small working context:** changing one command should not require loading an + entire command group into memory. +- **Parallel development:** unrelated commands should rarely require edits to + the same file. +- **Explicit ownership:** shared infrastructure and command-private behavior + should not be mixed. +- **Stable behavior:** structural refactoring must preserve registration, + output, error handling, compatibility paths, and tests. +- **Agentic development:** coding agents should be able to infer the relevant + files from the CLI surface without broad repository searches. + +## Naming and ownership + +### Registered command modules + +Each real CLI command uses: + +```text +command_.py +``` + +For example: + +```text +specify extension add -> extensions/command_add.py +specify extension set-priority -> extensions/command_set_priority.py +specify extension update -> extensions/command_update.py +``` + +Only modules representing actual CLI commands use the non-underscored +`command_*.py` prefix. A command module owns: + +- The Typer-decorated handler. +- User-facing arguments and options. +- Command-specific orchestration. +- Small helpers used only by that command. + +The command function's docstring is user-facing because Typer may display it +as help text. A module docstring is internal and should identify the command, +registration path, and any adjacent private implementation modules. + +### Command-private implementation modules + +When a command has cohesive phases that are independently understandable or +testable, use: + +```text +_command__.py +``` + +For example: + +```text +command_update.py +_command_update_discovery.py +_command_update_artifacts.py +_command_update_transaction.py +``` + +The leading underscore marks the module as private implementation. The +`command_update` portion groups it with the registered handler in searches and +file listings. The phase suffix communicates its ownership. + +Private phase modules must not register additional CLI commands. The public +`command_.py` module remains the sole CLI adapter. + +Split a command when a phase: + +- Has distinct invariants or failure behavior. +- Can be tested as a meaningful boundary. +- Has enough implementation detail to distract from the CLI handler. +- Is likely to change independently from other phases. + +Do not split a command solely because it crossed an arbitrary line count. +Excessive fragmentation makes control flow harder to follow and increases the +number of files an agent must inspect. + +### Command-group infrastructure + +For a multi-command group, `_commands.py` owns: + +- The command group's Typer application. +- Registration of the group's command modules. +- Infrastructure genuinely shared by multiple commands or external CLI flows. +- Thin compatibility forwarders needed to preserve established import or + monkeypatch paths. + +`_commands.py` must not contain decorated command handlers. A helper used by +only one command belongs in that command's module or one of its private phase +modules. + +Compatibility forwarders do not transfer ownership back to `_commands.py`. +They should remain thin and delegate to the module that owns the behavior. +Avoid turning `_commands.py` into a service locator for new code. + +### Package `__init__.py` + +The package `__init__.py` owns the package's domain API and package-level +behavior. It should provide a brief map to the CLI modules, but it is not the +home for command handlers. + +Moving command handlers out of `__init__.py` keeps importing the domain package +separate from understanding or modifying its CLI surface. + +## Nested command groups + +Nested CLI groups use directories matching the command surface: + +```text +specify extension catalog add + list + remove +``` + +maps to: + +```text +extensions/ +├── catalog/ +│ ├── __init__.py +│ ├── _helpers.py +│ ├── command_add.py +│ ├── command_list.py +│ └── command_remove.py +├── command_add.py +├── command_list.py +└── ... +``` + +The nested package's `__init__.py` owns its Typer application and registration. +Shared helpers for that nested surface can live in `_helpers.py`. + +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`. + +Do not create a nested directory for an implementation phase that is not a CLI +subcommand. For example, an `update/` directory would incorrectly suggest an +`extension update ...` subcommand group. Use `_command_update_.py` +instead. + +## Registration + +Command registration remains centralized at the command-group boundary. + +For the extension group: + +1. `src/specify_cli/extensions/_commands.py` owns `extension_app`. +2. `_commands.register()` registers the nested catalog group. +3. It imports each `command_*.py` module so its decorator registers the + handler. +4. It attaches `extension_app` to the root application. + +The nested catalog group follows the same pattern through +`catalog.register()`. + +Registration imports should be explicit and ordered consistently. Do not rely +on filesystem discovery to import arbitrary modules, because command exposure +should remain reviewable in one place. + +## Test structure + +Command-focused tests mirror the source command surface under +`tests/specify_cli/`. + +For example: + +```text +src/specify_cli/extensions/command_add.py +tests/specify_cli/extensions/test_command_add.py + +src/specify_cli/extensions/catalog/command_add.py +tests/specify_cli/extensions/catalog/test_command_add.py +``` + +Private phases use: + +```text +src/specify_cli/extensions/_command_update_discovery.py +tests/specify_cli/extensions/test_command_update_discovery.py + +src/specify_cli/extensions/_command_update_artifacts.py +tests/specify_cli/extensions/test_command_update_artifacts.py + +src/specify_cli/extensions/_command_update_transaction.py +tests/specify_cli/extensions/test_command_update_transaction.py +``` + +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 model, registry, manager, and catalog behavior remains in domain test + suites such as `tests/test_extensions.py`. +- Cross-domain CLI contracts remain with the broader integration tests. +- Shared fixtures belong in the narrowest `conftest.py` that serves all of + their consumers. +- Test helpers should be shared rather than copied when both command and domain + tests depend on the same behavior. + +Moving tests must preserve coverage rather than duplicating it. Run both the +new command-focused suites and the legacy suites from which tests were moved. + +## Reference layout + +The extension command group currently demonstrates the complete pattern: + +```text +src/specify_cli/extensions/ +├── __init__.py +├── _commands.py +├── command_add.py +├── command_disable.py +├── command_enable.py +├── command_info.py +├── command_list.py +├── command_remove.py +├── command_search.py +├── command_set_priority.py +├── command_update.py +├── _command_update_discovery.py +├── _command_update_artifacts.py +├── _command_update_transaction.py +└── catalog/ + ├── __init__.py + ├── _helpers.py + ├── command_add.py + ├── command_list.py + └── command_remove.py +``` + +The update command illustrates the distinction: + +- `command_update.py` is the registered CLI adapter. +- `_command_update_discovery.py` determines available updates. +- `_command_update_artifacts.py` prepares and validates update archives. +- `_command_update_transaction.py` owns backup, installation, rollback, and + cleanup behavior. + +## Decision guide + +When deciding where code belongs: + +| Question | Location | +|---|---| +| Does it define a real CLI command? | `command_.py` | +| Is it used only by one small command? | That command module | +| Is it a cohesive private phase of one complex command? | `_command__.py` | +| Is it shared by multiple commands or an external CLI flow? | `_commands.py` or a focused shared module | +| Does it define a nested CLI namespace? | A directory matching that namespace | +| Is it shared only by commands in a nested namespace? | The nested package's `_helpers.py` | +| Is it domain behavior independent of the CLI? | The package domain modules, not command modules | + +## Anti-patterns + +Avoid: + +- Adding decorated handlers back to `_commands.py` or package `__init__.py`. +- Naming a private implementation module `command_*.py`. +- Creating nested directories that do not correspond to CLI namespaces. +- Creating `_commands.py` files only for visual symmetry. +- Moving command-private helpers into shared infrastructure preemptively. +- Duplicating fixtures or helpers to make tests appear more mirrored. +- Splitting a linear function into many files without cohesive phase + boundaries. +- Changing established monkeypatch or import paths without either migrating + their consumers or preserving a thin compatibility forwarder. + +## Review checklist + +For a new or refactored command: + +- [ ] The CLI path maps predictably to a `command_.py` module. +- [ ] Only the real command module registers a handler. +- [ ] Private phase modules use `_command__.py`. +- [ ] `_commands.py` contains only group infrastructure and genuinely shared + behavior. +- [ ] Nested directories correspond to real CLI namespaces. +- [ ] Command tests mirror the source structure. +- [ ] Domain and cross-domain tests remain in their appropriate suites. +- [ ] Compatibility paths and user-visible help remain unchanged unless the + change explicitly requires otherwise. +- [ ] Focused tests, relevant legacy suites, lint, and the full test suite pass. diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 882379d2f1..ec47e46c1e 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4,6 +4,10 @@ Handles installation, removal, and management of Spec Kit extensions. Extensions are modular packages that add commands and functionality to spec-kit without bloating the core framework. + +CLI handlers live in ``command_*.py`` modules, registered through +``_commands.py``. Command-private phases use ``_command__*.py``; +nested catalog handlers live under ``catalog/``. """ from __future__ import annotations diff --git a/src/specify_cli/extensions/_command_update_artifacts.py b/src/specify_cli/extensions/_command_update_artifacts.py new file mode 100644 index 0000000000..2d54177f72 --- /dev/null +++ b/src/specify_cli/extensions/_command_update_artifacts.py @@ -0,0 +1,164 @@ +"""Artifact preparation helpers for ``specify extension update``.""" +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml +from packaging import version as pkg_version + +from .._download_security import safe_extract_archive + + +@dataclass(frozen=True) +class PreflightResult: + """Validated manifest-derived outputs needed by the update transaction.""" + + command_names: list[str] + skill_names: list[str] + + +def _archive_extension_directory(source_dir: Path) -> Path: + """Package an extension directory as a ZIP archive for the update flow.""" + import zipfile + + fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip") + try: + with os.fdopen(fd, "wb") as archive_file: + with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as archive: + for path in sorted(source_dir.rglob("*")): + if path.is_symlink(): + continue + if path.is_file(): + archive.write(path, path.relative_to(source_dir).as_posix()) + except BaseException: + Path(tmp_name).unlink(missing_ok=True) + raise + return Path(tmp_name) + + +def preflight_update_archive( + manager: Any, + archive_path: Path, + extension_id: str, + available_version: str, + speckit_version: str, +) -> PreflightResult: + """Validate an update archive before the installed extension is modified.""" + from . import ExtensionManifest + + with tempfile.TemporaryDirectory( + prefix="speckit-update-archive-" + ) as archive_tmpdir: + extracted_root = Path(archive_tmpdir) + try: + safe_extract_archive(archive_path, extracted_root) + except ValueError as exc: + if ( + "Conflicting path" in str(exc) + and "extension.yml" in str(exc).casefold() + ): + raise ValueError( + "Downloaded extension archive contains multiple " + "extension.yml manifests" + ) from exc + raise + + top_level = list(extracted_root.iterdir()) + root_manifest_entries = [ + entry + for entry in top_level + if entry.name.casefold() == "extension.yml" + ] + if any(entry.name != "extension.yml" for entry in root_manifest_entries): + raise ValueError("Archive must use canonical 'extension.yml' casing") + + canonical_root_manifest = next( + ( + entry + for entry in root_manifest_entries + if entry.name == "extension.yml" + ), + None, + ) + if canonical_root_manifest is not None: + manifest_path = canonical_root_manifest + else: + top_level_dirs = [entry for entry in top_level if entry.is_dir()] + if len(top_level_dirs) != 1: + raise ValueError( + "Downloaded extension archive must contain exactly " + "one top-level directory" + ) + manifest_root = top_level_dirs[0] + nested_manifest_entries = [ + entry + for entry in manifest_root.iterdir() + if entry.name.casefold() == "extension.yml" + ] + if any(entry.name != "extension.yml" for entry in nested_manifest_entries): + raise ValueError("Archive must use canonical 'extension.yml' casing") + manifest_path = next( + ( + entry + for entry in nested_manifest_entries + if entry.name == "extension.yml" + ), + manifest_root / "extension.yml", + ) + + if not manifest_path.is_file(): + raise ValueError( + "Downloaded extension archive is missing 'extension.yml'" + ) + manifest_bytes = manifest_path.read_bytes() + parsed_manifest = yaml.safe_load(manifest_bytes) + manifest_data = parsed_manifest if parsed_manifest is not None else {} + if not isinstance(manifest_data, dict): + raise ValueError( + "Invalid extension manifest in downloaded archive: " + "expected YAML mapping" + ) + extension_data = manifest_data.get("extension", {}) + if not isinstance(extension_data, dict): + raise ValueError( + "Invalid extension manifest in downloaded archive: " + "expected 'extension' mapping" + ) + + with tempfile.TemporaryDirectory( + prefix="speckit-update-manifest-" + ) as manifest_tmpdir: + manifest_file = Path(manifest_tmpdir) / "extension.yml" + manifest_file.write_bytes(manifest_bytes) + preflight_manifest = ExtensionManifest(manifest_file) + manager.check_compatibility(preflight_manifest, speckit_version) + + if preflight_manifest.id != extension_id: + raise ValueError( + f"Extension ID mismatch: expected '{extension_id}', " + f"got '{preflight_manifest.id}'" + ) + + expected_version = pkg_version.Version(available_version) + archive_version = pkg_version.Version(preflight_manifest.version) + if archive_version != expected_version: + raise ValueError( + "Extension version mismatch: " + f"expected '{available_version}', got '{preflight_manifest.version}'" + ) + + manager._validate_install_conflicts(preflight_manifest) + command_names = list( + manager._collect_manifest_command_names(preflight_manifest) + ) + skill_names = list( + dict.fromkeys( + manager._skill_name_for_command(command_name) + for command_name in command_names + ) + ) + return PreflightResult(command_names=command_names, skill_names=skill_names) diff --git a/src/specify_cli/extensions/_command_update_discovery.py b/src/specify_cli/extensions/_command_update_discovery.py new file mode 100644 index 0000000000..c4bbd5241d --- /dev/null +++ b/src/specify_cli/extensions/_command_update_discovery.py @@ -0,0 +1,145 @@ +"""Discovery helpers for ``specify extension update``.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from packaging import version as pkg_version +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands + + +@dataclass(frozen=True) +class UpdateCandidate: + """A validated catalog update ready for user confirmation.""" + + extension_id: str + name: str + installed: str + available: str + download_url: str | None + bundled_dir: Path | None + catalog_name: str | None + + +def _bundled_update_source(ext_id: str): + """Locate the local bundled copy of an extension and its parsed version.""" + from . import ExtensionManifest, ValidationError + + bundled_dir = _commands._locate_bundled_extension(ext_id) + if bundled_dir is None: + return None, None + try: + manifest = ExtensionManifest(bundled_dir / "extension.yml") + return bundled_dir, pkg_version.Version(manifest.version) + except (ValidationError, pkg_version.InvalidVersion, OSError): + return None, None + + +def discover_updates( + manager: Any, + catalog: Any, + extension: str | None, +) -> tuple[list[UpdateCandidate], list[str], bool]: + """Find installable updates and report skipped or blocked entries.""" + installed = manager.list_installed() + if extension: + extension_id, _ = _commands._resolve_installed_extension( + extension, installed, "update" + ) + extension_ids = [extension_id] + else: + extension_ids = [ext["id"] for ext in installed] + + if not extension_ids: + return [], [], False + + console.print("🔄 Checking for updates...\n") + + updates_available: list[UpdateCandidate] = [] + blocked_updates: list[str] = [] + + for ext_id in extension_ids: + safe_ext_id = _escape_markup(str(ext_id)) + metadata = manager.registry.get(ext_id) + if ( + metadata is None + or not isinstance(metadata, dict) + or "version" not in metadata + ): + console.print( + f"⚠ {safe_ext_id}: Registry entry corrupted or missing (skipping)" + ) + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {safe_ext_id}: Invalid installed version " + f"'{_escape_markup(str(metadata.get('version')))}' in registry " + "(skipping)" + ) + continue + + ext_info = catalog.get_extension_info(ext_id) + if not ext_info: + console.print(f"⚠ {safe_ext_id}: Not found in catalog (skipping)") + continue + + if not ext_info.get("_install_allowed", True): + console.print( + f"⚠ {safe_ext_id}: Updates not allowed from " + f"'{_escape_markup(str(ext_info.get('_catalog_name', 'catalog')))}' " + "(skipping)" + ) + continue + + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {safe_ext_id}: Invalid catalog version " + f"'{_escape_markup(str(ext_info.get('version')))}' (skipping)" + ) + continue + + if catalog_version <= installed_version: + console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") + continue + + download_url = ext_info.get("download_url") + bundled_dir = None + available_version = catalog_version + if ext_info.get("bundled") and not download_url: + bundled_dir, bundled_version = _commands._bundled_update_source(ext_id) + if bundled_dir is None or bundled_version < catalog_version: + local_desc = ( + f"only ships v{bundled_version}" + if bundled_dir is not None + else "does not ship a local copy" + ) + console.print( + f"⚠ {safe_ext_id}: v{catalog_version} is available, but this " + f"spec-kit release {local_desc} — upgrade spec-kit, then rerun " + f"'specify extension update'" + ) + blocked_updates.append(ext_id) + continue + available_version = bundled_version + + updates_available.append( + UpdateCandidate( + extension_id=ext_id, + name=ext_info.get("name", ext_id), + installed=str(installed_version), + available=str(available_version), + download_url=download_url, + bundled_dir=bundled_dir, + catalog_name=ext_info.get("_catalog_name"), + ) + ) + + return updates_available, blocked_updates, True diff --git a/src/specify_cli/extensions/_command_update_transaction.py b/src/specify_cli/extensions/_command_update_transaction.py new file mode 100644 index 0000000000..b5dd995c1c --- /dev/null +++ b/src/specify_cli/extensions/_command_update_transaction.py @@ -0,0 +1,871 @@ +"""Transactional implementation supporting ``specify extension update``. + +The registered CLI adapter lives in ``command_update.py``. Discovery and +archive preparation live in adjacent ``_command_update_*`` modules. +""" +from __future__ import annotations + +import hashlib +import os +import shutil +from pathlib import Path +from uuid import uuid4 + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from .._init_options import is_ai_skills_enabled +from . import _commands +from ._command_update_artifacts import preflight_update_archive +from ._command_update_discovery import discover_updates + + +def run_update_command(extension: str | None) -> None: + """Run discovery, confirmation, and transactional extension updates.""" + from . import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + normalize_priority, + ) + + project_root = _commands._require_specify_project() + manager = ExtensionManager(project_root) + catalog = ExtensionCatalog(project_root) + speckit_version = _commands.get_speckit_version() + + try: + updates_available, blocked_updates, has_installed = discover_updates( + manager, catalog, extension + ) + if not has_installed: + console.print("[yellow]No extensions installed[/yellow]") + raise typer.Exit(0) + + if not updates_available: + if blocked_updates: + console.print( + "\n[yellow]Update(s) exist but require a newer spec-kit " + "release — upgrade spec-kit, then rerun " + "'specify extension update'.[/yellow]" + ) + else: + console.print("\n[green]All extensions are up to date![/green]") + raise typer.Exit(0) + + # Show available updates + console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + console.print( + f" • {_escape_markup(update.extension_id)}: " + f"{update.installed} → {update.available}" + ) + + console.print() + confirm = typer.confirm("Update these extensions?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Perform updates with atomic backup/restore + console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + from ..agents import CommandRegistrar as _AgentReg # used in backup and rollback paths + + # UNSET sentinel: backup not yet captured (exception before backup step) + UNSET = object() + + for update in updates_available: + extension_id = update.extension_id + ext_name = update.name + safe_ext_name = _escape_markup(str(ext_name)) + console.print(f"📦 Updating {safe_ext_name}...") + + # Backup paths + backup_root = manager.extensions_dir / ".backup" + backup_key = hashlib.sha256( + extension_id.encode("utf-8") + ).hexdigest()[:16] + backup_base = ( + backup_root + / f"update-{backup_key}-{uuid4().hex}" + ) + backup_ext_dir = backup_base / "extension" + backup_commands_dir = backup_base / "commands" + backup_skills_dir = backup_base / "skills" + backup_config_dir = backup_base / "config" + + # Store backup state + backup_registry_entry = None # None means registry entry not yet captured + backup_installed = UNSET # Original installed list from extensions.yml + backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured + backed_up_command_files = {} + backed_up_command_symlinks = {} + backed_up_skill_dirs = {} + new_command_dirs_absent_before_update = [] + new_command_paths_absent_before_update = [] + new_skill_names = [] + new_skill_paths_absent_before_update = [] + # Validation failures must not rewrite an untouched installation. + installation_modified = False + zip_cleanup_error = None + backup_created_by_attempt = False + + def backup_command_artifact(original_file, backup_file): + """Back up one command artifact once, preserving its full path.""" + nonlocal backup_created_by_attempt + original_key = str(original_file) + if original_key in backed_up_command_files: + return + if original_file.is_symlink(): + backed_up_command_symlinks[original_key] = os.readlink( + original_file + ) + else: + if original_file.stat().st_nlink > 1: + raise RuntimeError( + "Cannot safely update hard-linked generated " + f"artifact '{original_file}'" + ) + backup_created_by_attempt = True + backup_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(original_file, backup_file) + backed_up_command_files[original_key] = str(backup_file) + + def restore_command_artifact(original_path, backup_path): + """Restore one regular file or symlink without following it.""" + original_key = str(original_path) + original_file = Path(original_path) + backup_file = Path(backup_path) + symlink_state = backed_up_command_symlinks.get( + original_key + ) + + if symlink_state is not None: + if original_file.is_symlink() or original_file.is_file(): + original_file.unlink() + elif original_file.exists(): + raise RuntimeError( + "Command rollback found an unexpected directory " + f"at '{original_file}'" + ) + original_file.parent.mkdir(parents=True, exist_ok=True) + os.symlink(symlink_state, original_file) + return + + if not backup_file.is_file() or backup_file.is_symlink(): + raise RuntimeError( + "Command rollback backup is missing for " + f"'{original_file}'" + ) + if original_file.is_symlink() or original_file.is_file(): + original_file.unlink() + elif original_file.exists(): + raise RuntimeError( + "Command rollback found an unexpected directory " + f"at '{original_file}'" + ) + original_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup_file, original_file) + + def remember_absent_parent_dirs(artifact_path, root_dir): + """Remember absent parents a failed renderer may create.""" + boundary = root_dir.parent + if root_dir.is_relative_to(project_root): + boundary = project_root + parent = artifact_path.parent + while parent != boundary: + if parent.exists() or parent.is_symlink(): + break + new_command_dirs_absent_before_update.append(parent) + parent = parent.parent + + def backup_extension_skills(skill_names, *, skills_dir=None): + """Back up every owned skill directory that remove() may delete.""" + nonlocal backup_created_by_attempt + for skill_dir in manager._find_extension_skill_dirs( + skill_names, + extension_id, + skills_dir=skills_dir, + create_skills_dir=False, + ): + original_key = str(skill_dir) + if original_key in backed_up_skill_dirs: + continue + backup_created_by_attempt = True + backup_skills_dir.mkdir(parents=True, exist_ok=True) + backup_skill_dir = backup_skills_dir / str( + len(backed_up_skill_dirs) + ) + shutil.copytree(skill_dir, backup_skill_dir, symlinks=True) + backed_up_skill_dirs[original_key] = str(backup_skill_dir) + + try: + if backup_root.is_symlink(): + raise RuntimeError( + "Cannot safely create update backup under symlinked " + f"directory '{backup_root}'" + ) + if backup_base.exists() or backup_base.is_symlink(): + raise RuntimeError( + "Cannot safely reuse an existing update backup " + f"directory '{backup_base}'" + ) + + # 1. Backup registry entry (always, even if extension dir doesn't exist) + backup_registry_entry = manager.registry.get(extension_id) + + # 2. Backup extension directory + extension_dir = manager.extensions_dir / extension_id + if extension_dir.exists(): + backup_created_by_attempt = True + backup_base.mkdir(parents=True, exist_ok=True) + if backup_ext_dir.exists(): + shutil.rmtree(backup_ext_dir) + shutil.copytree(extension_dir, backup_ext_dir) + + # Backup config files separately so they can be restored + # after a successful install (install_from_directory clears dest dir). + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) + for cfg_file in config_files: + backup_config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) + + # 3. Backup command files for all agents + registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = _AgentReg._resolve_agent_dir( + agent_name, agent_config, project_root + ) + dirs_to_backup = [commands_dir] + legacy = agent_config.get("legacy_dir") + if legacy: + legacy_dir = project_root / legacy + if ( + legacy_dir.exists() + and legacy_dir != commands_dir + ): + dirs_to_backup.append(legacy_dir) + + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name( + agent_name, cmd_name, agent_config + ) + names_to_backup = [output_name] + if ( + output_name != cmd_name + and _AgentReg._is_safe_command_name(cmd_name) + ): + names_to_backup.append(cmd_name) + + for dir_index, target_dir in enumerate( + dirs_to_backup + ): + for name in names_to_backup: + cmd_file = ( + target_dir + / f"{name}{agent_config['extension']}" + ) + try: + _AgentReg._ensure_inside( + cmd_file, target_dir + ) + except ValueError: + continue + if ( + cmd_file.exists() + or cmd_file.is_symlink() + ): + # Keep both the directory location and + # relative path unique. unregister_commands() + # removes legacy and canonical copies, and + # skills agents place every SKILL.md in its + # own command subdirectory. + backup_cmd_path = ( + backup_commands_dir + / agent_name + / f"location-{dir_index}" + / cmd_file.relative_to(target_dir) + ) + backup_command_artifact( + cmd_file, backup_cmd_path + ) + + # Also backup copilot prompt files + if agent_name == "copilot": + prompts_dir = ( + project_root / ".github" / "prompts" + ) + prompt_file = ( + prompts_dir / f"{cmd_name}.prompt.md" + ) + try: + _AgentReg._ensure_inside( + prompt_file, prompts_dir + ) + except ValueError: + continue + if prompt_file.exists() or prompt_file.is_symlink(): + backup_prompt_path = ( + backup_commands_dir + / "copilot-prompts" + / prompt_file.relative_to(prompts_dir) + ) + backup_command_artifact( + prompt_file, backup_prompt_path + ) + + raw_registered_skills = ( + backup_registry_entry.get("registered_skills", []) + if isinstance(backup_registry_entry, dict) + else [] + ) + registered_skills = manager._valid_name_list(raw_registered_skills) + backup_extension_skills(registered_skills) + + # 4. Backup hooks and installed list from extensions.yml + # get_project_config() always normalizes installed->[] and hooks->{}, + # so no sentinel is needed to distinguish key-absent from key-empty. + config = hook_executor.get_project_config() + if isinstance(config, dict): + import copy + # Deep-copy so nested mapping entries (e.g. version-pin dicts) + # are not affected by in-place mutations during the update. + backup_installed = copy.deepcopy(config.get("installed", [])) + backup_hooks = {} + for hook_name, hook_list in config.get("hooks", {}).items(): + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks + + # 5. Acquire the new version. Bundled extensions install from + # the copy shipped with the running spec-kit release (they + # have no download URL); everything else downloads. Both are + # packaged as archives so the identical validation, + # backup/rollback, and install pipeline below applies. + if update.bundled_dir is not None: + archive_path = _commands._archive_extension_directory( + update.bundled_dir + ) + else: + archive_path = catalog.download_extension(extension_id) + try: + preflight = preflight_update_archive( + manager, + archive_path, + extension_id, + update.available, + speckit_version, + ) + new_command_names = preflight.command_names + new_skill_names = preflight.skill_names + + # Command rendering happens before hook registration and + # registry.add(). Preserve every candidate output that + # already exists, and remember paths that are absent now so + # rollback can remove files created before registry state is + # available. Include aliases and Copilot companion prompts. + for ( + agent_name, + commands_dir, + ) in manager._command_registration_targets().items(): + agent_config = registrar.AGENT_CONFIGS[agent_name] + for command_name in new_command_names: + output_name = _AgentReg._compute_output_name( + agent_name, command_name, agent_config + ) + command_file = ( + commands_dir + / f"{output_name}{agent_config['extension']}" + ) + _AgentReg._ensure_inside(command_file, commands_dir) + backup_command_path = ( + backup_commands_dir + / agent_name + / command_file.relative_to(commands_dir) + ) + if command_file.exists() or command_file.is_symlink(): + backup_command_artifact( + command_file, backup_command_path + ) + else: + new_command_paths_absent_before_update.append( + command_file + ) + remember_absent_parent_dirs( + command_file, commands_dir + ) + + if agent_name == "copilot": + prompts_dir = ( + project_root / ".github" / "prompts" + ) + prompt_file = ( + prompts_dir / f"{command_name}.prompt.md" + ) + _AgentReg._ensure_inside( + prompt_file, prompts_dir + ) + if prompt_file.is_symlink(): + raise RuntimeError( + "Cannot safely update symlinked Copilot " + f"prompt artifact '{prompt_file}'" + ) + backup_prompt_path = ( + backup_commands_dir + / "copilot-prompts" + / prompt_file.relative_to(prompts_dir) + ) + if ( + prompt_file.exists() + or prompt_file.is_symlink() + ): + backup_command_artifact( + prompt_file, backup_prompt_path + ) + else: + new_command_paths_absent_before_update.append( + prompt_file + ) + remember_absent_parent_dirs( + prompt_file, prompts_dir + ) + + new_command_paths_absent_before_update = list( + dict.fromkeys( + new_command_paths_absent_before_update + ) + ) + new_command_dirs_absent_before_update = list( + dict.fromkeys( + new_command_dirs_absent_before_update + ) + ) + + # A newly introduced command may reuse an existing + # extension-owned skill directory that was not present in + # the old registry. Back it up before cleanup can touch it. + backup_extension_skills(new_skill_names) + new_skills_dir = manager._get_skills_dir(create=False) + if new_skills_dir is not None: + # Unscoped removal deliberately ignores home-scoped + # outputs because the flat registry cannot establish + # project ownership. The active install can still + # replace a marker-owned skill in its explicit root, + # so back up that exact project/home target separately. + backup_extension_skills( + list( + dict.fromkeys( + registered_skills + new_skill_names + ) + ), + skills_dir=new_skills_dir, + ) + init_options = _commands.load_init_options(project_root) + if ( + isinstance(init_options, dict) + and is_ai_skills_enabled(init_options) + and isinstance(init_options.get("ai"), str) + and init_options["ai"] + ): + # resolve_active_skills_dir() first creates the + # configured project-local skills marker. Some + # agents (notably Hermes) then redirect rendered + # skills to a different global root, so snapshot + # both locations for exact rollback. + from .. import _get_skills_dir + + configured_skills_dir = _get_skills_dir( + project_root, init_options["ai"] + ) + remember_absent_parent_dirs( + configured_skills_dir / ".update-marker", + configured_skills_dir, + ) + new_skills_root = new_skills_dir.resolve() + for skill_name in new_skill_names: + skill_path = new_skills_dir / skill_name + resolved_skill_path = skill_path.resolve(strict=False) + resolved_skill_path.relative_to(new_skills_root) + if not ( + skill_path.exists() or skill_path.is_symlink() + ): + new_skill_paths_absent_before_update.append( + skill_path + ) + remember_absent_parent_dirs( + skill_path / "SKILL.md", + new_skills_dir, + ) + + new_command_dirs_absent_before_update = list( + dict.fromkeys( + new_command_dirs_absent_before_update + ) + ) + + # 7. Remove old extension (handles command file cleanup and registry removal) + installation_modified = True + manager.remove(extension_id, keep_config=True) + + # 8. Install new version + _ = manager.install_from_zip( + archive_path, + speckit_version, + catalog_name=update.catalog_name, + ) + + # Restore user config files from backup after successful install. + new_extension_dir = manager.extensions_dir / extension_id + if backup_config_dir.exists() and new_extension_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) + + # 9. Restore metadata from backup (installed_at, enabled state) + if backup_registry_entry and isinstance(backup_registry_entry, dict): + # Copy current registry entry to avoid mutating internal + # registry state before explicit restore(). + current_metadata = manager.registry.get(extension_id) + if current_metadata is None or not isinstance(current_metadata, dict): + raise RuntimeError( + f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" + ) + new_metadata = dict(current_metadata) + + # Preserve the original installation timestamp + if "installed_at" in backup_registry_entry: + new_metadata["installed_at"] = backup_registry_entry["installed_at"] + + # Preserve the original priority (normalized to handle corruption) + if "priority" in backup_registry_entry: + new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) + + # If extension was disabled before update, disable it again + if not backup_registry_entry.get("enabled", True): + new_metadata["enabled"] = False + + # Use restore() instead of update() because update() always + # preserves the existing installed_at, ignoring our override + manager.registry.restore(extension_id, new_metadata) + + # Also disable hooks in extensions.yml if extension was disabled + if not backup_registry_entry.get("enabled", True): + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + finally: + # Archive cleanup is housekeeping: never replace an install + # error or roll back an already committed update because a + # scanner temporarily locks the download on Windows. + try: + archive_path.unlink(missing_ok=True) + except OSError as error: + zip_cleanup_error = error + + # 10. Clean up backup on success. The update has committed at + # this point, so a locked backup file must not trigger rollback + # of an otherwise successful installation. + cleanup_error = None + if backup_created_by_attempt and backup_base.exists(): + try: + shutil.rmtree(backup_base) + except OSError as error: + cleanup_error = error + + console.print( + f" [green]✓[/green] Updated to v{update.available}" + ) + if cleanup_error is not None: + console.print( + " [yellow]Warning:[/yellow] Could not fully remove " + "update backup: " + f"{_escape_markup(str(cleanup_error))}" + ) + console.print( + " [dim]Backup may remain at: " + f"{_escape_markup(str(backup_base))}[/dim]" + ) + if zip_cleanup_error is not None: + console.print( + " [yellow]Warning:[/yellow] Could not remove " + "downloaded update archive: " + f"{_escape_markup(str(zip_cleanup_error))}" + ) + updated_extensions.append(ext_name) + + except KeyboardInterrupt: + raise + except Exception as e: + console.print(f" [red]✗[/red] Failed: {_escape_markup(str(e))}") + failed_updates.append((ext_name, str(e))) + if zip_cleanup_error is not None: + console.print( + " [yellow]Warning:[/yellow] Could not remove " + "downloaded update archive: " + f"{_escape_markup(str(zip_cleanup_error))}" + ) + + if not installation_modified: + if backup_created_by_attempt and backup_base.exists(): + try: + shutil.rmtree(backup_base) + except OSError as cleanup_error: + console.print( + " [yellow]Warning:[/yellow] Could not remove " + "untouched-update backup: " + f"{_escape_markup(str(cleanup_error))}" + ) + continue + + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {safe_ext_name}...") + + try: + # Restore extension directory + # Only perform destructive rollback if backup exists (meaning we + # actually modified the extension). This avoids deleting a valid + # installation when failure happened before changes were made. + extension_dir = manager.extensions_dir / extension_id + if backup_ext_dir.exists(): + if extension_dir.exists(): + shutil.rmtree(extension_dir) + shutil.copytree(backup_ext_dir, extension_dir) + + # Remove any NEW command files created by failed install + # (files that weren't in the original backup). Registration + # writes before registry.add(), so start with the paths that + # were absent at the destructive boundary instead of relying + # only on a possibly missing new registry entry. + for command_path in new_command_paths_absent_before_update: + if command_path.is_symlink() or command_path.is_file(): + command_path.unlink() + elif command_path.exists(): + raise RuntimeError( + "Command rollback found an unexpected directory " + f"at '{command_path}'" + ) + new_registered_skills = [] + try: + new_registry_entry = manager.registry.get(extension_id) + if new_registry_entry is None or not isinstance(new_registry_entry, dict): + new_registered_commands = {} + else: + new_registered_commands = new_registry_entry.get("registered_commands", {}) + new_registered_skills = manager._valid_name_list( + new_registry_entry.get("registered_skills", []) + ) + for agent_name, cmd_names in new_registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = _AgentReg._resolve_agent_dir( + agent_name, agent_config, project_root + ) + + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + # Delete if it exists and wasn't in our backup + if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: + cmd_file.unlink() + + # Also handle copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: + prompt_file.unlink() + except KeyError: + pass # No new registry entry exists, nothing to clean up + + # Restore command artifacts that existed before the update + # before extension-skill cleanup inspects ownership. A + # failed skills registrar may have overwritten a user's + # pre-existing SKILL.md with extension metadata; restoring + # it first prevents the conservative skill unregistrar from + # misclassifying and deleting the user's whole directory. + for original_path, backup_path in backed_up_command_files.items(): + restore_command_artifact( + original_path, backup_path + ) + + # Skill generation happens before hooks and registry.add(), + # so a failed install may have created skills that are not + # recorded in any registry entry yet. Derive names from the + # preflighted manifest as well as any partial new entry. + skills_to_remove = list( + dict.fromkeys(new_skill_names + new_registered_skills) + ) + # A write failure can leave a partial skill without valid + # ownership metadata, which the normal conservative + # unregistrar intentionally refuses to delete. Paths that + # were absent at the destructive boundary are safe to + # remove directly during rollback. + for skill_path in new_skill_paths_absent_before_update: + if skill_path.is_symlink() or skill_path.is_file(): + skill_path.unlink() + elif skill_path.exists(): + shutil.rmtree(skill_path) + manager._unregister_extension_skills( + skills_to_remove, extension_id + ) + + # Restore all original registered skill artifacts after + # removing skills created by the failed installation. + for original_path, backup_path in backed_up_skill_dirs.items(): + backup_skill_dir = Path(backup_path) + if not backup_skill_dir.is_dir(): + raise RuntimeError( + "Skill rollback backup is missing for " + f"'{original_path}'" + ) + original_skill_dir = Path(original_path) + if ( + original_skill_dir.is_symlink() + or original_skill_dir.is_file() + ): + original_skill_dir.unlink() + elif original_skill_dir.exists(): + shutil.rmtree(original_skill_dir) + original_skill_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree( + backup_skill_dir, + original_skill_dir, + symlinks=True, + ) + + # Remove empty artifact directories that did not exist at + # the destructive boundary. Do this after skill cleanup and + # restoration so newly created skills roots and their + # project-local parents can also be removed exactly. + for command_dir in sorted( + new_command_dirs_absent_before_update, + key=lambda path: len(path.parts), + reverse=True, + ): + if command_dir.is_dir() and not command_dir.is_symlink(): + try: + command_dir.rmdir() + except OSError: + # Preserve any non-empty directory: other + # content may belong to the user. + pass + + # Restore metadata in extensions.yml (hooks and installed list). + # Only run if backup step 4 was reached (backup_hooks is not None); + # otherwise we have no safe baseline to restore from and could corrupt + # the config by removing pre-existing hooks. + if backup_hooks is not None: + config = hook_executor.get_project_config() + if not isinstance(config, dict): + config = {} + + modified = False + + # 1. Restore hooks in extensions.yml + if not isinstance(config.get("hooks"), dict): + config["hooks"] = {} + modified = True + + # Remove any hooks for this extension added by the failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + config["hooks"][hook_name] = [] + modified = True + continue + + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed-up hooks + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if not isinstance(config["hooks"].get(hook_name), list): + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True + + # 2. Restore installed list in extensions.yml + if backup_installed is not UNSET: + if config.get("installed") != backup_installed: + config["installed"] = backup_installed + modified = True + + if modified: + hook_executor.save_project_config(config) + + # Restore registry entry (use restore() since entry was removed) + if backup_registry_entry: + manager.registry.restore(extension_id, backup_registry_entry) + + # Backup cleanup is post-rollback housekeeping. A locked + # file (notably on Windows) must not turn successfully + # restored state into a contradictory "Rollback failed". + cleanup_error = None + if backup_created_by_attempt and backup_base.exists(): + try: + shutil.rmtree(backup_base) + except OSError as error: + cleanup_error = error + console.print(" [green]✓[/green] Rollback successful") + if cleanup_error is not None: + console.print( + " [yellow]Warning:[/yellow] Could not fully " + "remove rollback backup: " + f"{_escape_markup(str(cleanup_error))}" + ) + console.print( + " [dim]Backup may remain at: " + f"{_escape_markup(str(backup_base))}[/dim]" + ) + except Exception as rollback_error: + console.print(f" [red]✗[/red] Rollback failed: {_escape_markup(str(rollback_error))}") + console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]") + + # Summary + console.print() + if updated_extensions: + console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") + if failed_updates: + console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") + for ext_name, error in failed_updates: + console.print(f" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}") + raise typer.Exit(1) + + # S4: regenerate native event config after a successful update. An + # update replaces the installed extension.yml, so any added/removed/ + # changed event declarations would otherwise leave native configs + # stale until a manual integration upgrade. + if updated_extensions: + _commands._refresh_events_and_warn(project_root) + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 454482d054..ce6d229878 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -1,49 +1,32 @@ -"""specify extension * and catalog * command handlers — app objects and register(). +"""Shared infrastructure and registration for ``specify extension`` commands. -Moved out of __init__.py (PR-7/8). Handlers reference helpers that remain in -the package root (`_require_specify_project`, `_locate_bundled_extension`, -`load_init_options`, `_display_project_path`) through the thin shims below, -which re-fetch from the parent package at call time so test monkeypatching of -`specify_cli.` keeps working. +Command handlers belong in ``command_*.py`` modules. Keep helpers here only +when multiple commands or external CLI flows share them; compatibility shims +re-fetch package helpers at call time so existing monkeypatch paths keep +working. Cohesive private phases use ``_command__*.py`` modules. """ from __future__ import annotations import errno -import hashlib import os -import shutil import stat -import tempfile from pathlib import Path -from typing import Optional, TYPE_CHECKING +from typing import Optional from uuid import uuid4 -if TYPE_CHECKING: - from packaging.version import Version - import typer -import yaml +import yaml as yaml from rich.markup import escape as _escape_markup -from rich.panel import Panel from rich.table import Table 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 .._assets import get_speckit_version +from .._assets import get_speckit_version as get_speckit_version from .._download_security import ( archive_format_from_name, detect_archive_format, is_https_or_localhost_http, read_response_limited, - safe_extract_archive, ) -from .._init_options import is_ai_skills_enabled extension_app = typer.Typer( name="extension", @@ -51,22 +34,6 @@ add_completion=False, ) -catalog_app = typer.Typer( - name="catalog", - help=( - "Manage extension catalogs.\n\n" - "Catalogs are either install sources (install_allowed) or discovery-only " - "search surfaces. The built-in 'community' catalog is discovery-only by " - "design: it is unvetted, so it is searchable but not installable. To install " - "something you found there, either use 'specify extension add --from " - "' after vetting it, or curate your own catalog you control. Never flip a " - "discovery-only catalog to install_allowed — that is the vetting boundary." - ), - add_completion=False, -) -extension_app.add_typer(catalog_app, name="catalog") - - # Root helpers re-fetched at call time so test monkeypatching of # `specify_cli.` keeps working after the move. def _require_specify_project(*args, **kwargs): @@ -116,56 +83,18 @@ def _command_safe_id(raw_id: object, placeholder: str = "") -> str return placeholder -def _bundled_update_source(ext_id: str) -> tuple[Path, Version] | tuple[None, None]: - """Locate the local bundled copy of *ext_id* and its parsed version. - - Bundled extensions have no download URL, so an update can only come - from the copy shipped with the running spec-kit release — which may - lag the version the catalog on main advertises. Returns - ``(path, Version)`` when a valid local copy exists, ``(None, None)`` - otherwise. - """ - from . import ExtensionManifest, ValidationError - from packaging import version as pkg_version - - bundled_dir = _locate_bundled_extension(ext_id) - if bundled_dir is None: - return None, None - try: - manifest = ExtensionManifest(bundled_dir / "extension.yml") - return bundled_dir, pkg_version.Version(manifest.version) - except (ValidationError, pkg_version.InvalidVersion, OSError): - return None, None +def _bundled_update_source(*args, **kwargs): + """Forward calls to the update command's bundled-source helper.""" + from ._command_update_discovery import _bundled_update_source as _helper + return _helper(*args, **kwargs) -def _archive_extension_directory(source_dir: Path) -> Path: - """Package an extension directory as a ZIP archive for the update flow. - The update pipeline validates and installs archives (bounded - extraction, manifest preflight, ID/version checks, backup/rollback), - so a locally bundled extension is fed through that identical hardened - path rather than growing a second install code path. The caller - deletes the archive after the update, the same as a downloaded one. - """ - import zipfile +def _archive_extension_directory(*args, **kwargs): + """Forward calls to the update command's archive helper.""" + from ._command_update_artifacts import _archive_extension_directory as _helper - fd, tmp_name = tempfile.mkstemp(prefix="speckit-bundled-update-", suffix=".zip") - try: - with os.fdopen(fd, "wb") as archive_file: - with zipfile.ZipFile(archive_file, "w", zipfile.ZIP_DEFLATED) as zf: - for path in sorted(source_dir.rglob("*")): - # Never follow symlinks: is_file() follows the target - # and ZipFile.write() reads its bytes, which would turn - # an out-of-tree target into a regular archive member - # before the hardened extractor ever sees it. - if path.is_symlink(): - continue - if path.is_file(): - zf.write(path, path.relative_to(source_dir).as_posix()) - except BaseException: - Path(tmp_name).unlink(missing_ok=True) - raise - return Path(tmp_name) + return _helper(*args, **kwargs) def _refresh_events_and_warn(project_root: Path) -> None: @@ -327,27 +256,6 @@ def install_extension_from_url( pass -def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict: - """Load extension catalog CLI config with user-facing shape errors.""" - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) - except Exception as e: - config_label = _escape_markup(str(_display_project_path(project_root, config_path))) - console.print(f"[red]Error:[/red] Failed to read {config_label}: {_escape_markup(str(e))}") - raise typer.Exit(1) - - if config is None: - return {} - if not isinstance(config, dict): - config_label = _escape_markup(str(_display_project_path(project_root, config_path))) - console.print( - f"[red]Error:[/red] Invalid catalog config {config_label}: " - "expected a YAML mapping at the root." - ) - raise typer.Exit(1) - return config - - def _resolve_installed_extension( argument: str, installed_extensions: list, @@ -477,230 +385,6 @@ def _resolve_catalog_extension( return (None, e) -@extension_app.command("list", cls=InstalledListJSONCommand) -def extension_list( - available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), - all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), - json_output: bool = typer.Option(False, "--json", help="Output installed extensions as JSON"), -): - """List installed extensions.""" - from . import ExtensionManager, normalize_priority - - if json_output: - try: - project_root = resolve_specify_project_root() - manager = ExtensionManager(project_root) - installed = manager.list_installed() - installed = sorted( - installed, - key=lambda extension: ( - normalize_priority(extension.get("priority")), - str(extension.get("id", "")), - ), - ) - emit_json( - [installed_list_item(ext, include_hooks=True) for ext in installed] - ) - return - except Exception as error: - emit_json_error(error) - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - installed = manager.list_installed() - - if not installed and not (available or all_extensions): - console.print("[yellow]No extensions installed.[/yellow]") - console.print("\nInstall an extension with:") - console.print(" specify extension add ") - return - - if installed: - console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") - - for ext in installed: - status_icon = "✓" if ext["enabled"] else "✗" - status_color = "green" if ext["enabled"] else "red" - - console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{_escape_markup(ext['name'])}[/bold] (v{_escape_markup(str(ext['version']))})") - console.print(f" [dim]{_escape_markup(ext['id'])}[/dim]") - console.print(f" {_escape_markup(ext['description'])}") - console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") - console.print() - - if available or all_extensions: - console.print("\nInstall an extension:") - console.print(" [cyan]specify extension add [/cyan]") - - -@catalog_app.command("list") -def catalog_list(): - """List all active extension catalogs.""" - from . import ExtensionCatalog, ValidationError - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - - try: - active_catalogs = catalog.get_active_catalogs() - except ValidationError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Active Extension 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(entry.name)}[/bold] (priority {entry.priority})") - if entry.description: - console.print(f" {_escape_markup(entry.description)}") - console.print(f" URL: {_escape_markup(str(entry.url))}") - console.print(f" Install: {install_str}") - console.print() - - if any(not entry.install_allowed for entry in active_catalogs): - console.print( - "[dim]Discovery-only catalogs are searchable but not installable by design " - "(unvetted sources). To install something you found in one, vet it and run " - "'specify extension add --from ', or add it to a catalog you " - "control. Don't flip a discovery-only catalog to install_allowed.[/dim]\n" - ) - - config_path = project_root / ".specify" / "extension-catalogs.yml" - user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" - if os.environ.get("SPECKIT_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") - else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except ValidationError: - proj_loaded = False - if proj_loaded: - config_label = _escape_markup(str(_display_project_path(project_root, config_path))) - console.print(f"[dim]Config: {config_label}[/dim]") - else: - try: - user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None - except ValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" - ) - - -@catalog_app.command("add") -def 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=( - "Mark this catalog as a trusted install source. Only enable this for a " - "catalog you own and vet; leave it off (the default) for discovery-only " - "search surfaces. Never enable it for an unvetted public catalog." - ), - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), -): - """Add a catalog to .specify/extension-catalogs.yml.""" - from . import ExtensionCatalog, ValidationError - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Validate URL - tmp_catalog = ExtensionCatalog(project_root) - try: - tmp_catalog._validate_catalog_url(url) - except ValidationError as e: - console.print(f"[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - config_path = specify_dir / "extension-catalogs.yml" - - # Load existing config - if config_path.exists(): - config = _load_catalog_command_config(project_root, config_path) - 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) - - safe_name = _escape_markup(name) - safe_url = _escape_markup(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 extension 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}") - - -@catalog_app.command("remove") -def catalog_remove( - name: str = typer.Argument(help="Catalog name to remove"), -): - """Remove a catalog from .specify/extension-catalogs.yml.""" - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - config_path = specify_dir / "extension-catalogs.yml" - if not config_path.exists(): - console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") - raise typer.Exit(1) - - config = _load_catalog_command_config(project_root, config_path) - - 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) - safe_name = _escape_markup(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]") - - # Relative path, below the project root, of the extension URL download cache. _CACHE_REL_PARTS = (".specify", "extensions", ".cache", "downloads") @@ -973,1939 +657,34 @@ def _open_download_zip_via_paths( return download_fd -@extension_app.command("add") -def extension_add( - extension: str = typer.Argument(help="Extension name or path"), - dev: bool = typer.Option(False, "--dev", help="Install from local directory"), - from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), - force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install an extension.""" - from . import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = ExtensionManager(project_root) - speckit_version = get_speckit_version() - - if force: - console.print("[yellow]--force:[/yellow] Will overwrite if already installed") - - # Prompt for URL-based installs BEFORE the spinner so the user can - # actually see and respond to the confirmation (the Rich status - # spinner overwrites the typer.confirm prompt line, making it appear - # as though the command is hung). - # Guard with ``not dev`` so that --dev + --from does not show a - # confusing confirmation for a URL that will be ignored. - if from_url and not dev: - from urllib.parse import urlparse - - try: - parsed = urlparse(from_url) - # Read .hostname inside the try: parsing a malformed authority -- or - # accessing .hostname on one, e.g. an invalid bracketed IPv6 host like - # "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the - # parse and the .hostname read inside the guard surfaces a clean - # "Invalid URL" message instead of leaking a raw traceback past the - # CLI. Reuse the value below. - hostname = parsed.hostname - parsed.port - except ValueError: - console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") - raise typer.Exit(1) - if not hostname: - console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") - raise typer.Exit(1) - - if not is_https_or_localhost_http(from_url): - console.print("[red]Error:[/red] URL must use HTTPS for security.") - console.print("HTTP is only allowed for loopback URLs.") - raise typer.Exit(1) - - safe_url = _escape_markup(from_url) - - # Warn about untrusted sources — default-deny confirmation - console.print() - console.print(Panel( - f"[bold]You are installing an extension directly from an external URL,\n" - f"bypassing your trusted (install-allowed) extension catalogs.[/bold]\n\n" - f"URL: {safe_url}\n\n" - f"Only install extensions from sources you trust.", - title="[bold yellow]⚠ Untrusted Source[/bold yellow]", - border_style="yellow", - padding=(1, 2), - )) - console.print() - confirm = typer.confirm("Continue with installation?", default=False) - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - safe_extension = _escape_markup(extension) - - try: - with console.status(f"[cyan]Installing extension: {safe_extension}[/cyan]"): - if dev: - # Install from local directory - source_path = Path(extension).expanduser().resolve() - safe_source_path = _escape_markup(str(source_path)) - if not source_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {safe_source_path}") - raise typer.Exit(1) - - if not (source_path / "extension.yml").exists(): - console.print(f"[red]Error:[/red] No extension.yml found in {safe_source_path}") - raise typer.Exit(1) - - if force: - console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{safe_source_path}[/cyan] (will overwrite if already installed)...") - - manifest = manager.install_from_directory( - source_path, - speckit_version, - priority=priority, - link_commands=True, - force=force - ) - - elif from_url: - # Install from URL archive via the shared hardened downloader - # (HTTPS enforcement, authenticated redirect-guarded fetch, - # bounded read, archive-format detection, TOCTOU-safe transient - # archive). Same path used by ``specify init --extension ``. - console.print(f"Downloading from {safe_url}...") - manifest = install_extension_from_url( - manager, - project_root, - from_url, - speckit_version, - priority=priority, - force=force, - ) - - else: - # Try bundled extensions first (shipped with spec-kit) - bundled_path = _locate_bundled_extension(extension) - if bundled_path is not None: - manifest = manager.install_from_directory( - bundled_path, speckit_version, priority=priority, force=force - ) - else: - # Install from catalog (also resolves display names to IDs) - catalog = ExtensionCatalog(project_root) - - # Check if extension exists in catalog (supports both ID and display name) - ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}") - raise typer.Exit(1) - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{safe_extension}' not found in catalog") - console.print("\nSearch available extensions:") - console.print(" specify extension search") - raise typer.Exit(1) - - # If catalog resolved a display name to an ID, check bundled again - resolved_id = ext_info['id'] - if resolved_id != extension: - bundled_path = _locate_bundled_extension(resolved_id) - if bundled_path is not None: - manifest = manager.install_from_directory( - bundled_path, speckit_version, priority=priority, force=force - ) - - if bundled_path is None: - # Bundled extensions without a download URL must come from the local package - if ext_info.get("bundled") and not ext_info.get("download_url"): - console.print( - f"[red]Error:[/red] Extension '{_escape_markup(ext_info['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) - - # Enforce install_allowed policy - if not ext_info.get("_install_allowed", True): - catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community"))) - resolved_id = _command_safe_id(ext_info["id"]) - console.print( - f"[red]Error:[/red] '{safe_extension}' was found in the " - f"'{catalog_name}' catalog, which is discovery-only — a search " - f"surface, not an install source." - ) - console.print( - "\nDiscovery-only catalogs are intentionally not installable so " - "unvetted extensions can't be pulled in without review. Don't flip " - "such a catalog to install_allowed. Instead, once you've vetted this " - "extension:" - ) - console.print( - f" • install it directly from its archive URL:\n" - f" specify extension add {resolved_id} --from " - ) - console.print( - " • or add it to a catalog you curate and control " - "(install_allowed: true)." - ) - raise typer.Exit(1) - - # Download extension archive (use the resolved catalog ID). - extension_id = ext_info['id'] - console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...") - archive_path = catalog.download_extension(extension_id) - - try: - manifest = manager.install_from_zip( - archive_path, - speckit_version, - priority=priority, - force=force, - catalog_name=ext_info.get("_catalog_name"), - ) - finally: - archive_path.unlink(missing_ok=True) - - console.print("\n[green]✓[/green] Extension installed successfully!") - console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})") - console.print(f" {_escape_markup(str(manifest.description))}") - - # #1: regenerate native event config for installed event-capable - # integrations so the new extension's events take effect immediately. - _refresh_events_and_warn(project_root) - - for warning in manifest.warnings: - console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}") - - selected_ai = load_init_options(project_root).get("ai") - is_cline = selected_ai == "cline" - is_forge = selected_ai == "forge" - - if is_cline: - from specify_cli.integrations.cline import format_cline_command_name - if is_forge: - from specify_cli.integrations.forge import format_forge_command_name - - console.print("\n[bold cyan]Provided commands:[/bold cyan]") - for cmd in manifest.commands: - cmd_name = cmd['name'] - if is_cline: - cmd_name = format_cline_command_name(cmd_name) - elif is_forge: - cmd_name = format_forge_command_name(cmd_name) - console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}") - - # Report agent skills registration - reg_meta = manager.registry.get(manifest.id) - reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] - # Normalize to guard against corrupted registry entries - if not isinstance(reg_skills, list): - reg_skills = [] - if reg_skills: - console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") - - # Scaffold config templates automatically - deployed, skipped, failed = manager.scaffold_config(manifest.id) - config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}" - if deployed: - console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") - for cfg in deployed: - console.print(f" • {config_home}/{_escape_markup(str(cfg))}") - if skipped: - console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") - if failed: - console.print( - f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " - f"{_escape_markup(', '.join(failed))}. " - "Verify the extension manifest and template files." - ) - - # Only warn when configuration is actually unresolved. Scaffolding that - # deployed or preserved every template has already answered this, and an - # extension without provides.config has nothing to configure; the blanket - # warning contradicted the output directly above it. - if failed or not (deployed or skipped): - console.print("\n[yellow]⚠[/yellow] Configuration may be required") - console.print(f" Check: {config_home}/") - - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except CompatibilityError as e: - console.print(f"\n[red]Compatibility Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - -@extension_app.command("remove") -def extension_remove( - extension: str = typer.Argument(help="Extension ID or name to remove"), - keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Uninstall an extension.""" - from . import ExtensionManager - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - safe_extension_id = _escape_markup(str(extension_id)) - - # Get extension info for command and skill counts - ext_manifest = manager.get_extension(extension_id) - reg_meta = manager.registry.get(extension_id) - # Derive cmd_count from the registry's registered_commands (includes aliases) - # rather than from the manifest (primary commands only). Use max() across - # agents to get the per-agent count; sum() would double-count since users - # think in logical commands, not per-agent file counts. - # Use get() without a default so we can distinguish "key missing" (fall back - # to manifest) from "key present but empty dict" (zero commands registered). - registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None - if isinstance(registered_commands, dict): - cmd_count = max( - (len(v) for v in registered_commands.values() if isinstance(v, list)), - default=0, - ) - else: - cmd_count = len(ext_manifest.commands) if ext_manifest else 0 - raw_skills = reg_meta.get("registered_skills") if reg_meta else None - skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 - - # Confirm removal - if not force: - console.print("\n[yellow]⚠ This will remove:[/yellow]") - console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") - if skill_count: - console.print(f" • {skill_count} agent skill(s)") - console.print(f" • Extension directory: .specify/extensions/{safe_extension_id}/") - if not keep_config: - console.print(" • Config files (will be backed up)") - console.print() - - confirm = typer.confirm("Continue?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - # Remove extension - success = manager.remove(extension_id, keep_config=keep_config) - - if success: - console.print(f"\n[green]✓[/green] Extension '{_escape_markup(str(display_name))}' removed successfully") - if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/") - else: - console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/") - - # #1: regenerate native event config so the removed extension's events - # are stripped from installed integrations. - _refresh_events_and_warn(project_root) - console.print(f"\nTo reinstall: specify extension add {safe_extension_id}") - else: - console.print("[red]Error:[/red] Failed to remove extension") - raise typer.Exit(1) - - -@extension_app.command("search") -def extension_search( - query: 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"), - verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), -): - """Search for available extensions in catalog.""" - from . import ExtensionCatalog, ExtensionError - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - - try: - console.print("🔍 Searching extension catalog...") - results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) - - if not results: - console.print("\n[yellow]No extensions found matching criteria[/yellow]") - if query or tag or author or verified: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify extension search (show all)") - raise typer.Exit(0) - - console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") - - for ext in results: - # Extension header - verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" - console.print(f"[bold]{_escape_markup(str(ext['name']))}[/bold] (v{_escape_markup(str(ext['version']))}){verified_badge}") - console.print(f" {_escape_markup(str(ext['description']))}") - - # Metadata - console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}") - ext_tags = ext.get('tags', []) - if isinstance(ext_tags, list) and ext_tags: - tags_str = ", ".join(str(t) for t in ext_tags) - console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}") - - # Source catalog - catalog_name = _escape_markup(str(ext.get("_catalog_name", ""))) - install_allowed = ext.get("_install_allowed", True) - if catalog_name: - if install_allowed: - console.print(f" [dim]Catalog:[/dim] {catalog_name}") - else: - console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") - - # Stats - stats = [] - downloads = ext.get('downloads') - if downloads is not None: - # Catalog fields are untrusted; a non-numeric ``downloads`` - # (e.g. the JSON string "1500") would crash the ``:,`` format - # with "Cannot specify ',' with 's'". Only group-format numbers, - # and escape the fallback: the joined stats are rendered as Rich - # markup, so a value like "[/red]foo" would raise MarkupError - # (matching how every other catalog field here is escaped). - stats.append( - f"Downloads: {downloads:,}" - if isinstance(downloads, (int, float)) - else f"Downloads: {_escape_markup(str(downloads))}" - ) - stars = ext.get('stars') - if stars is not None: - # Same untrusted-value/Rich-markup hazard as `downloads` above, - # in the same joined string. - stats.append(f"Stars: {_escape_markup(str(stars))}") - if stats: - console.print(f" [dim]{' | '.join(stats)}[/dim]") - - # Links - if ext.get('repository'): - console.print(f" [dim]Repository:[/dim] {_escape_markup(str(ext['repository']))}") - - # Install command (show warning if not installable) - cmd_id = _command_safe_id(ext['id']) - if install_allowed: - console.print(f"\n [cyan]Install:[/cyan] specify extension add {cmd_id}") - else: - console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}' (discovery-only).") - console.print( - f" Once vetted, install it directly: specify extension add {cmd_id} --from " - ) - console.print( - " Don't flip a discovery-only catalog to install_allowed — that's the vetting boundary." - ) - console.print() - - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") - raise typer.Exit(1) - - -@extension_app.command("info") -def extension_info( - extension: str = typer.Argument(help="Extension ID or name"), -): - """Show detailed information about an extension.""" - from . import ExtensionCatalog, ExtensionManager, normalize_priority - - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) - manager = ExtensionManager(project_root) - installed = manager.list_installed() - - # Try to resolve from installed extensions first (by ID or name) - # Use allow_not_found=True since the extension may be catalog-only - resolved_installed_id, resolved_installed_name = _resolve_installed_extension( - extension, installed, "info", allow_not_found=True - ) - - # Try catalog lookup (with error handling) - # If we resolved an installed extension by display name, use its ID for catalog lookup - # to ensure we get the correct catalog entry (not a different extension with same name) - lookup_key = resolved_installed_id if resolved_installed_id else extension - ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") - - # Case 1: Found in catalog - show full catalog info - if ext_info: - _print_extension_info(ext_info, manager) - return - - # Case 2: Installed locally but catalog lookup failed or not in catalog - if resolved_installed_id: - # Get local manifest info - ext_manifest = manager.get_extension(resolved_installed_id) - metadata = manager.registry.get(resolved_installed_id) - metadata_is_dict = isinstance(metadata, dict) - if not metadata_is_dict: - console.print( - "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " - "some information may be unavailable." - ) - version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" - - console.print(f"\n[bold]{_escape_markup(str(resolved_installed_name))}[/bold] (v{_escape_markup(str(version))})") - console.print(f"ID: {_escape_markup(str(resolved_installed_id))}") - console.print() - - if ext_manifest: - console.print(f"{_escape_markup(str(ext_manifest.description))}") - console.print() - # Author is optional in extension.yml, safely retrieve it - author = ext_manifest.data.get("extension", {}).get("author") - if author: - console.print(f"[dim]Author:[/dim] {_escape_markup(str(author))}") - if ext_manifest.category: - console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_manifest.category))}") - if ext_manifest.effect: - console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_manifest.effect))}") - console.print() - - if ext_manifest.commands: - # Print each command the way the active agent registers it. - # Cline and Forge hyphenate command names (e.g. Forge invokes - # `/speckit-jira-sync`, not the manifest's dotted - # `speckit.jira.sync`), so mirror the same formatting used by - # `extension add`'s "Provided commands" listing — otherwise the - # names shown here don't match what the user actually types. - selected_ai = load_init_options(project_root).get("ai") - if selected_ai == "cline": - from specify_cli.integrations.cline import ( - format_cline_command_name as _format_command_name, - ) - elif selected_ai == "forge": - from specify_cli.integrations.forge import ( - format_forge_command_name as _format_command_name, - ) - else: - _format_command_name = None - - console.print("[bold]Commands:[/bold]") - for cmd in ext_manifest.commands: - cmd_name = cmd['name'] - if _format_command_name is not None: - cmd_name = _format_command_name(cmd_name) - console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}") - console.print() - - # Show catalog status - if catalog_error: - console.print(f"[yellow]Catalog unavailable:[/yellow] {_escape_markup(str(catalog_error))}") - console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") - else: - console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") - - console.print() - console.print("[green]✓ Installed[/green]") - priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) - console.print(f"[dim]Priority:[/dim] {priority}") - console.print(f"\nTo remove: specify extension remove {_escape_markup(str(resolved_installed_id))}") - return - - # Case 3: Not found anywhere - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}") - console.print("\nTry again when online, or use the extension ID directly.") - else: - console.print(f"[red]Error:[/red] Extension '{_escape_markup(extension)}' not found") - console.print("\nTry: specify extension search") - raise typer.Exit(1) - - -def _print_extension_info(ext_info: dict, manager): - """Print formatted extension info from catalog data.""" - from . import normalize_priority - - # Header - verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" - console.print(f"\n[bold]{_escape_markup(str(ext_info['name']))}[/bold] (v{_escape_markup(str(ext_info['version']))}){verified_badge}") - console.print(f"ID: {_escape_markup(str(ext_info['id']))}") - console.print() - - # Description - console.print(f"{_escape_markup(str(ext_info['description']))}") - console.print() - - # Author and License - console.print(f"[dim]Author:[/dim] {_escape_markup(str(ext_info.get('author', 'Unknown')))}") - console.print(f"[dim]License:[/dim] {_escape_markup(str(ext_info.get('license', 'Unknown')))}") - - # Category and Effect - if ext_info.get('category'): - console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_info['category']))}") - if ext_info.get('effect'): - console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_info['effect']))}") - - # Source catalog - if ext_info.get("_catalog_name"): - install_allowed = ext_info.get("_install_allowed", True) - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f"[dim]Source catalog:[/dim] {_escape_markup(str(ext_info['_catalog_name']))}{install_note}") - console.print() - - # Requirements - if ext_info.get('requires'): - console.print("[bold]Requirements:[/bold]") - reqs = ext_info['requires'] - if reqs.get('speckit_version'): - console.print(f" • Spec Kit: {_escape_markup(str(reqs['speckit_version']))}") - if reqs.get('tools'): - for tool in reqs['tools']: - tool_name = _escape_markup(str(tool['name'])) - tool_version = _escape_markup(str(tool.get('version', 'any'))) - required = " (required)" if tool.get('required') else " (optional)" - console.print(f" • {tool_name}: {tool_version}{required}") - console.print() - - # Provides - if ext_info.get('provides'): - console.print("[bold]Provides:[/bold]") - provides = ext_info['provides'] - if provides.get('commands'): - console.print(f" • Commands: {_escape_markup(str(provides['commands']))}") - if provides.get('hooks'): - console.print(f" • Hooks: {_escape_markup(str(provides['hooks']))}") - console.print() - - # Tags - info_tags = ext_info.get('tags', []) - if isinstance(info_tags, list) and info_tags: - tags_str = ", ".join(str(t) for t in info_tags) - console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}") - console.print() - - # Statistics - stats = [] - downloads = ext_info.get('downloads') - if downloads is not None: - # Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the - # JSON string "1500") would crash the ``:,`` format with "Cannot - # specify ',' with 's'". Only group-format numbers, and escape the - # fallback: the joined stats are rendered as Rich markup, so a value - # like "[/red]foo" would raise MarkupError (matching how every other - # catalog field here is escaped). - stats.append( - f"Downloads: {downloads:,}" - if isinstance(downloads, (int, float)) - else f"Downloads: {_escape_markup(str(downloads))}" - ) - stars = ext_info.get('stars') - if stars is not None: - # Same untrusted-value/Rich-markup hazard as `downloads` above, in the - # same joined string. - stats.append(f"Stars: {_escape_markup(str(stars))}") - if stats: - console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") - console.print() - - # Links - console.print("[bold]Links:[/bold]") - if ext_info.get('repository'): - console.print(f" • Repository: {_escape_markup(str(ext_info['repository']))}") - if ext_info.get('homepage'): - console.print(f" • Homepage: {_escape_markup(str(ext_info['homepage']))}") - if ext_info.get('documentation'): - console.print(f" • Documentation: {_escape_markup(str(ext_info['documentation']))}") - if ext_info.get('changelog'): - console.print(f" • Changelog: {_escape_markup(str(ext_info['changelog']))}") - console.print() - - # Installation status and command - is_installed = manager.registry.is_installed(ext_info['id']) - install_allowed = ext_info.get("_install_allowed", True) - safe_id = _escape_markup(str(ext_info['id'])) - cmd_id = _command_safe_id(ext_info['id']) - if is_installed: - console.print("[green]✓ Installed[/green]") - metadata = manager.registry.get(ext_info['id']) - priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) - console.print(f"[dim]Priority:[/dim] {priority}") - console.print(f"\nTo remove: specify extension remove {cmd_id}") - elif install_allowed: - console.print("[yellow]Not installed[/yellow]") - console.print(f"\n[cyan]Install:[/cyan] specify extension add {cmd_id}") - else: - catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community"))) - console.print("[yellow]Not installed[/yellow]") - console.print( - f"\n[yellow]⚠[/yellow] '{safe_id}' is in the '{catalog_name}' catalog, which is " - f"discovery-only (a search surface, not an install source)." - ) - download_url = ext_info.get("download_url") - if download_url: - console.print( - f"Candidate archive (vet before installing): {_escape_markup(str(download_url))}" - ) - console.print( - f"Once vetted, install directly: specify extension add {cmd_id} --from " - ) - else: - console.print( - f"Once you've vetted its release archive, install directly: " - f"specify extension add {cmd_id} --from " - ) - console.print( - "Discovery-only catalogs are intentionally not install sources — don't set " - "install_allowed on them." - ) +def extension_info(*args, **kwargs): + """Forward direct calls to the extracted info command handler.""" + from .command_info import extension_info as _extension_info + return _extension_info(*args, **kwargs) -@extension_app.command("update") -def extension_update( - extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), -): - """Update extension(s) to latest version.""" - from . import ( - ExtensionManager, - ExtensionCatalog, - ExtensionManifest, - ExtensionError, - ValidationError, - CommandRegistrar, - HookExecutor, - normalize_priority, - ) - from packaging import version as pkg_version - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - catalog = ExtensionCatalog(project_root) - speckit_version = get_speckit_version() - - try: - # Get list of extensions to update - installed = manager.list_installed() - if extension: - # Update specific extension - resolve ID from argument (handles ambiguous names) - extension_id, _ = _resolve_installed_extension(extension, installed, "update") - extensions_to_update = [extension_id] - else: - # Update all extensions - extensions_to_update = [ext["id"] for ext in installed] - - if not extensions_to_update: - console.print("[yellow]No extensions installed[/yellow]") - raise typer.Exit(0) - - console.print("🔄 Checking for updates...\n") - updates_available = [] - blocked_updates = [] +def _print_extension_info(*args, **kwargs): + """Forward calls to the extracted catalog-info renderer.""" + from .command_info import _print_extension_info as _renderer - for ext_id in extensions_to_update: - safe_ext_id = _escape_markup(str(ext_id)) - # Get installed version - metadata = manager.registry.get(ext_id) - if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: - console.print(f"⚠ {safe_ext_id}: Registry entry corrupted or missing (skipping)") - continue - try: - installed_version = pkg_version.Version(metadata["version"]) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {safe_ext_id}: Invalid installed version '{_escape_markup(str(metadata.get('version')))}' in registry (skipping)" - ) - continue - - # Get catalog info - ext_info = catalog.get_extension_info(ext_id) - if not ext_info: - console.print(f"⚠ {safe_ext_id}: Not found in catalog (skipping)") - continue + return _renderer(*args, **kwargs) - # Check if installation is allowed from this catalog - if not ext_info.get("_install_allowed", True): - console.print(f"⚠ {safe_ext_id}: Updates not allowed from '{_escape_markup(str(ext_info.get('_catalog_name', 'catalog')))}' (skipping)") - continue - - try: - catalog_version = pkg_version.Version(ext_info["version"]) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {safe_ext_id}: Invalid catalog version '{_escape_markup(str(ext_info.get('version')))}' (skipping)" - ) - continue - - if catalog_version > installed_version: - download_url = ext_info.get("download_url") - bundled_dir = None - available_version = catalog_version - if ext_info.get("bundled") and not download_url: - # Bundled extensions cannot be downloaded; the update has - # to come from the copy shipped with the running spec-kit - # release, which may lag the catalog on main (#4345). - bundled_dir, bundled_version = _bundled_update_source(ext_id) - # Block whenever the local copy lags the catalog, not - # just when it lags the installation: installing an - # intermediate version would leave the project behind - # the catalog while reporting success, contrary to the - # documented "upgrade spec-kit first" behavior. - if bundled_dir is None or bundled_version < catalog_version: - local_desc = ( - f"only ships v{bundled_version}" - if bundled_dir is not None - else "does not ship a local copy" - ) - console.print( - f"⚠ {safe_ext_id}: v{catalog_version} is available, but this " - f"spec-kit release {local_desc} — upgrade spec-kit, then rerun " - f"'specify extension update'" - ) - blocked_updates.append(ext_id) - continue - available_version = bundled_version - updates_available.append( - { - "id": ext_id, - "name": ext_info.get("name", ext_id), # Display name for status messages - "installed": str(installed_version), - "available": str(available_version), - "download_url": download_url, - "bundled_dir": bundled_dir, - "catalog_name": ext_info.get("_catalog_name"), - } - ) - else: - console.print(f"✓ {safe_ext_id}: Up to date (v{installed_version})") - - if not updates_available: - if blocked_updates: - console.print( - "\n[yellow]Update(s) exist but require a newer spec-kit " - "release — upgrade spec-kit, then rerun " - "'specify extension update'.[/yellow]" - ) - else: - console.print("\n[green]All extensions are up to date![/green]") - raise typer.Exit(0) - - # Show available updates - console.print("\n[bold]Updates available:[/bold]\n") - for update in updates_available: - console.print( - f" • {_escape_markup(str(update['id']))}: {update['installed']} → {update['available']}" - ) - - console.print() - confirm = typer.confirm("Update these extensions?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) - - # Perform updates with atomic backup/restore - console.print() - updated_extensions = [] - failed_updates = [] - registrar = CommandRegistrar() - hook_executor = HookExecutor(project_root) - from ..agents import CommandRegistrar as _AgentReg # used in backup and rollback paths - - # UNSET sentinel: backup not yet captured (exception before backup step) - UNSET = object() - - for update in updates_available: - extension_id = update["id"] - ext_name = update["name"] # Use display name for user-facing messages - safe_ext_name = _escape_markup(str(ext_name)) - console.print(f"📦 Updating {safe_ext_name}...") - - # Backup paths - backup_root = manager.extensions_dir / ".backup" - backup_key = hashlib.sha256( - extension_id.encode("utf-8") - ).hexdigest()[:16] - backup_base = ( - backup_root - / f"update-{backup_key}-{uuid4().hex}" - ) - backup_ext_dir = backup_base / "extension" - backup_commands_dir = backup_base / "commands" - backup_skills_dir = backup_base / "skills" - backup_config_dir = backup_base / "config" - - # Store backup state - backup_registry_entry = None # None means registry entry not yet captured - backup_installed = UNSET # Original installed list from extensions.yml - backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured - backed_up_command_files = {} - backed_up_command_symlinks = {} - backed_up_skill_dirs = {} - new_command_dirs_absent_before_update = [] - new_command_paths_absent_before_update = [] - new_skill_names = [] - new_skill_paths_absent_before_update = [] - # Validation failures must not rewrite an untouched installation. - installation_modified = False - zip_cleanup_error = None - backup_created_by_attempt = False - - def backup_command_artifact(original_file, backup_file): - """Back up one command artifact once, preserving its full path.""" - nonlocal backup_created_by_attempt - original_key = str(original_file) - if original_key in backed_up_command_files: - return - if original_file.is_symlink(): - backed_up_command_symlinks[original_key] = os.readlink( - original_file - ) - else: - if original_file.stat().st_nlink > 1: - raise RuntimeError( - "Cannot safely update hard-linked generated " - f"artifact '{original_file}'" - ) - backup_created_by_attempt = True - backup_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(original_file, backup_file) - backed_up_command_files[original_key] = str(backup_file) - - def restore_command_artifact(original_path, backup_path): - """Restore one regular file or symlink without following it.""" - original_key = str(original_path) - original_file = Path(original_path) - backup_file = Path(backup_path) - symlink_state = backed_up_command_symlinks.get( - original_key - ) - - if symlink_state is not None: - if original_file.is_symlink() or original_file.is_file(): - original_file.unlink() - elif original_file.exists(): - raise RuntimeError( - "Command rollback found an unexpected directory " - f"at '{original_file}'" - ) - original_file.parent.mkdir(parents=True, exist_ok=True) - os.symlink(symlink_state, original_file) - return - - if not backup_file.is_file() or backup_file.is_symlink(): - raise RuntimeError( - "Command rollback backup is missing for " - f"'{original_file}'" - ) - if original_file.is_symlink() or original_file.is_file(): - original_file.unlink() - elif original_file.exists(): - raise RuntimeError( - "Command rollback found an unexpected directory " - f"at '{original_file}'" - ) - original_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(backup_file, original_file) - - def remember_absent_parent_dirs(artifact_path, root_dir): - """Remember absent parents a failed renderer may create.""" - boundary = root_dir.parent - if root_dir.is_relative_to(project_root): - boundary = project_root - parent = artifact_path.parent - while parent != boundary: - if parent.exists() or parent.is_symlink(): - break - new_command_dirs_absent_before_update.append(parent) - parent = parent.parent - - def backup_extension_skills(skill_names, *, skills_dir=None): - """Back up every owned skill directory that remove() may delete.""" - nonlocal backup_created_by_attempt - for skill_dir in manager._find_extension_skill_dirs( - skill_names, - extension_id, - skills_dir=skills_dir, - create_skills_dir=False, - ): - original_key = str(skill_dir) - if original_key in backed_up_skill_dirs: - continue - backup_created_by_attempt = True - backup_skills_dir.mkdir(parents=True, exist_ok=True) - backup_skill_dir = backup_skills_dir / str( - len(backed_up_skill_dirs) - ) - shutil.copytree(skill_dir, backup_skill_dir, symlinks=True) - backed_up_skill_dirs[original_key] = str(backup_skill_dir) - - try: - if backup_root.is_symlink(): - raise RuntimeError( - "Cannot safely create update backup under symlinked " - f"directory '{backup_root}'" - ) - if backup_base.exists() or backup_base.is_symlink(): - raise RuntimeError( - "Cannot safely reuse an existing update backup " - f"directory '{backup_base}'" - ) - - # 1. Backup registry entry (always, even if extension dir doesn't exist) - backup_registry_entry = manager.registry.get(extension_id) - - # 2. Backup extension directory - extension_dir = manager.extensions_dir / extension_id - if extension_dir.exists(): - backup_created_by_attempt = True - backup_base.mkdir(parents=True, exist_ok=True) - if backup_ext_dir.exists(): - shutil.rmtree(backup_ext_dir) - shutil.copytree(extension_dir, backup_ext_dir) - - # Backup config files separately so they can be restored - # after a successful install (install_from_directory clears dest dir). - config_files = list(extension_dir.glob("*-config.yml")) + list( - extension_dir.glob("*-config.local.yml") - ) - for cfg_file in config_files: - backup_config_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) - - # 3. Backup command files for all agents - registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} - for agent_name, cmd_names in registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = _AgentReg._resolve_agent_dir( - agent_name, agent_config, project_root - ) - dirs_to_backup = [commands_dir] - legacy = agent_config.get("legacy_dir") - if legacy: - legacy_dir = project_root / legacy - if ( - legacy_dir.exists() - and legacy_dir != commands_dir - ): - dirs_to_backup.append(legacy_dir) - - for cmd_name in cmd_names: - output_name = _AgentReg._compute_output_name( - agent_name, cmd_name, agent_config - ) - names_to_backup = [output_name] - if ( - output_name != cmd_name - and _AgentReg._is_safe_command_name(cmd_name) - ): - names_to_backup.append(cmd_name) - - for dir_index, target_dir in enumerate( - dirs_to_backup - ): - for name in names_to_backup: - cmd_file = ( - target_dir - / f"{name}{agent_config['extension']}" - ) - try: - _AgentReg._ensure_inside( - cmd_file, target_dir - ) - except ValueError: - continue - if ( - cmd_file.exists() - or cmd_file.is_symlink() - ): - # Keep both the directory location and - # relative path unique. unregister_commands() - # removes legacy and canonical copies, and - # skills agents place every SKILL.md in its - # own command subdirectory. - backup_cmd_path = ( - backup_commands_dir - / agent_name - / f"location-{dir_index}" - / cmd_file.relative_to(target_dir) - ) - backup_command_artifact( - cmd_file, backup_cmd_path - ) - - # Also backup copilot prompt files - if agent_name == "copilot": - prompts_dir = ( - project_root / ".github" / "prompts" - ) - prompt_file = ( - prompts_dir / f"{cmd_name}.prompt.md" - ) - try: - _AgentReg._ensure_inside( - prompt_file, prompts_dir - ) - except ValueError: - continue - if prompt_file.exists() or prompt_file.is_symlink(): - backup_prompt_path = ( - backup_commands_dir - / "copilot-prompts" - / prompt_file.relative_to(prompts_dir) - ) - backup_command_artifact( - prompt_file, backup_prompt_path - ) - - raw_registered_skills = ( - backup_registry_entry.get("registered_skills", []) - if isinstance(backup_registry_entry, dict) - else [] - ) - registered_skills = manager._valid_name_list(raw_registered_skills) - backup_extension_skills(registered_skills) - - # 4. Backup hooks and installed list from extensions.yml - # get_project_config() always normalizes installed->[] and hooks->{}, - # so no sentinel is needed to distinguish key-absent from key-empty. - config = hook_executor.get_project_config() - if isinstance(config, dict): - import copy - # Deep-copy so nested mapping entries (e.g. version-pin dicts) - # are not affected by in-place mutations during the update. - backup_installed = copy.deepcopy(config.get("installed", [])) - backup_hooks = {} - for hook_name, hook_list in config.get("hooks", {}).items(): - if not isinstance(hook_list, list): - continue - ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] - if ext_hooks: - backup_hooks[hook_name] = ext_hooks - - # 5. Acquire the new version. Bundled extensions install from - # the copy shipped with the running spec-kit release (they - # have no download URL); everything else downloads. Both are - # packaged as archives so the identical validation, - # backup/rollback, and install pipeline below applies. - if update.get("bundled_dir") is not None: - archive_path = _archive_extension_directory(update["bundled_dir"]) - else: - archive_path = catalog.download_extension(extension_id) - try: - # 6. Validate the archive and extension ID before modifying - # the existing installation. The shared extractor applies - # the same bounded security checks to ZIP and tar archives. - with tempfile.TemporaryDirectory( - prefix="speckit-update-archive-" - ) as archive_tmpdir: - extracted_root = Path(archive_tmpdir) - try: - safe_extract_archive(archive_path, extracted_root) - except ValueError as exc: - if ( - "Conflicting path" in str(exc) - and "extension.yml" in str(exc).casefold() - ): - raise ValueError( - "Downloaded extension archive contains multiple " - "extension.yml manifests" - ) from exc - raise - manifest_root = extracted_root - top_level = list(extracted_root.iterdir()) - root_manifest_entries = [ - entry - for entry in top_level - if entry.name.casefold() == "extension.yml" - ] - if any( - entry.name != "extension.yml" - for entry in root_manifest_entries - ): - raise ValueError( - "Archive must use canonical 'extension.yml' casing" - ) - canonical_root_manifest = next( - ( - entry - for entry in root_manifest_entries - if entry.name == "extension.yml" - ), - None, - ) - if canonical_root_manifest is not None: - manifest_path = canonical_root_manifest - else: - top_level_dirs = [ - entry for entry in top_level if entry.is_dir() - ] - if len(top_level_dirs) != 1: - raise ValueError( - "Downloaded extension archive must contain exactly " - "one top-level directory" - ) - manifest_root = top_level_dirs[0] - nested_manifest_entries = [ - entry - for entry in manifest_root.iterdir() - if entry.name.casefold() == "extension.yml" - ] - if any( - entry.name != "extension.yml" - for entry in nested_manifest_entries - ): - raise ValueError( - "Archive must use canonical 'extension.yml' casing" - ) - manifest_path = next( - ( - entry - for entry in nested_manifest_entries - if entry.name == "extension.yml" - ), - manifest_root / "extension.yml", - ) - if not manifest_path.is_file(): - raise ValueError( - "Downloaded extension archive is missing 'extension.yml'" - ) - manifest_bytes = manifest_path.read_bytes() - parsed_manifest = yaml.safe_load(manifest_bytes) - manifest_data = ( - parsed_manifest if parsed_manifest is not None else {} - ) - if not isinstance(manifest_data, dict): - raise ValueError( - "Invalid extension manifest in downloaded archive: " - "expected YAML mapping" - ) - extension_data = manifest_data.get("extension", {}) - if not isinstance(extension_data, dict): - raise ValueError( - "Invalid extension manifest in downloaded archive: " - "expected 'extension' mapping" - ) - - # Run the same manifest and compatibility validation as a - # normal install while the existing extension is still - # untouched. Reuse the exact bounded bytes selected above. - with tempfile.TemporaryDirectory( - prefix="speckit-update-manifest-" - ) as manifest_tmpdir: - manifest_file = Path(manifest_tmpdir) / "extension.yml" - manifest_file.write_bytes(manifest_bytes) - preflight_manifest = ExtensionManifest(manifest_file) - manager.check_compatibility( - preflight_manifest, speckit_version - ) - - zip_extension_id = preflight_manifest.id - if zip_extension_id != extension_id: - raise ValueError( - f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" - ) - - expected_version = pkg_version.Version(update["available"]) - archive_version = pkg_version.Version( - preflight_manifest.version - ) - if archive_version != expected_version: - raise ValueError( - "Extension version mismatch: " - f"expected '{update['available']}', " - f"got '{preflight_manifest.version}'" - ) - - # Match the remaining deterministic install validation - # before crossing the destructive boundary. The helper - # excludes this extension's current registry entry while - # still detecting namespace, core, duplicate, and - # cross-extension command conflicts. - manager._validate_install_conflicts(preflight_manifest) - - new_command_names = list( - manager._collect_manifest_command_names( - preflight_manifest - ) - ) - new_skill_names = list( - dict.fromkeys( - manager._skill_name_for_command(command_name) - for command_name in new_command_names - ) - ) - - # Command rendering happens before hook registration and - # registry.add(). Preserve every candidate output that - # already exists, and remember paths that are absent now so - # rollback can remove files created before registry state is - # available. Include aliases and Copilot companion prompts. - for ( - agent_name, - commands_dir, - ) in manager._command_registration_targets().items(): - agent_config = registrar.AGENT_CONFIGS[agent_name] - for command_name in new_command_names: - output_name = _AgentReg._compute_output_name( - agent_name, command_name, agent_config - ) - command_file = ( - commands_dir - / f"{output_name}{agent_config['extension']}" - ) - _AgentReg._ensure_inside(command_file, commands_dir) - backup_command_path = ( - backup_commands_dir - / agent_name - / command_file.relative_to(commands_dir) - ) - if command_file.exists() or command_file.is_symlink(): - backup_command_artifact( - command_file, backup_command_path - ) - else: - new_command_paths_absent_before_update.append( - command_file - ) - remember_absent_parent_dirs( - command_file, commands_dir - ) - - if agent_name == "copilot": - prompts_dir = ( - project_root / ".github" / "prompts" - ) - prompt_file = ( - prompts_dir / f"{command_name}.prompt.md" - ) - _AgentReg._ensure_inside( - prompt_file, prompts_dir - ) - if prompt_file.is_symlink(): - raise RuntimeError( - "Cannot safely update symlinked Copilot " - f"prompt artifact '{prompt_file}'" - ) - backup_prompt_path = ( - backup_commands_dir - / "copilot-prompts" - / prompt_file.relative_to(prompts_dir) - ) - if ( - prompt_file.exists() - or prompt_file.is_symlink() - ): - backup_command_artifact( - prompt_file, backup_prompt_path - ) - else: - new_command_paths_absent_before_update.append( - prompt_file - ) - remember_absent_parent_dirs( - prompt_file, prompts_dir - ) - - new_command_paths_absent_before_update = list( - dict.fromkeys( - new_command_paths_absent_before_update - ) - ) - new_command_dirs_absent_before_update = list( - dict.fromkeys( - new_command_dirs_absent_before_update - ) - ) - - # A newly introduced command may reuse an existing - # extension-owned skill directory that was not present in - # the old registry. Back it up before cleanup can touch it. - backup_extension_skills(new_skill_names) - new_skills_dir = manager._get_skills_dir(create=False) - if new_skills_dir is not None: - # Unscoped removal deliberately ignores home-scoped - # outputs because the flat registry cannot establish - # project ownership. The active install can still - # replace a marker-owned skill in its explicit root, - # so back up that exact project/home target separately. - backup_extension_skills( - list( - dict.fromkeys( - registered_skills + new_skill_names - ) - ), - skills_dir=new_skills_dir, - ) - init_options = load_init_options(project_root) - if ( - isinstance(init_options, dict) - and is_ai_skills_enabled(init_options) - and isinstance(init_options.get("ai"), str) - and init_options["ai"] - ): - # resolve_active_skills_dir() first creates the - # configured project-local skills marker. Some - # agents (notably Hermes) then redirect rendered - # skills to a different global root, so snapshot - # both locations for exact rollback. - from .. import _get_skills_dir - - configured_skills_dir = _get_skills_dir( - project_root, init_options["ai"] - ) - remember_absent_parent_dirs( - configured_skills_dir / ".update-marker", - configured_skills_dir, - ) - new_skills_root = new_skills_dir.resolve() - for skill_name in new_skill_names: - skill_path = new_skills_dir / skill_name - resolved_skill_path = skill_path.resolve(strict=False) - resolved_skill_path.relative_to(new_skills_root) - if not ( - skill_path.exists() or skill_path.is_symlink() - ): - new_skill_paths_absent_before_update.append( - skill_path - ) - remember_absent_parent_dirs( - skill_path / "SKILL.md", - new_skills_dir, - ) - - new_command_dirs_absent_before_update = list( - dict.fromkeys( - new_command_dirs_absent_before_update - ) - ) - - # 7. Remove old extension (handles command file cleanup and registry removal) - installation_modified = True - manager.remove(extension_id, keep_config=True) - - # 8. Install new version - _ = manager.install_from_zip( - archive_path, - speckit_version, - catalog_name=update["catalog_name"], - ) - - # Restore user config files from backup after successful install. - new_extension_dir = manager.extensions_dir / extension_id - if backup_config_dir.exists() and new_extension_dir.exists(): - for cfg_file in backup_config_dir.iterdir(): - if cfg_file.is_file(): - shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) - - # 9. Restore metadata from backup (installed_at, enabled state) - if backup_registry_entry and isinstance(backup_registry_entry, dict): - # Copy current registry entry to avoid mutating internal - # registry state before explicit restore(). - current_metadata = manager.registry.get(extension_id) - if current_metadata is None or not isinstance(current_metadata, dict): - raise RuntimeError( - f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" - ) - new_metadata = dict(current_metadata) - - # Preserve the original installation timestamp - if "installed_at" in backup_registry_entry: - new_metadata["installed_at"] = backup_registry_entry["installed_at"] - - # Preserve the original priority (normalized to handle corruption) - if "priority" in backup_registry_entry: - new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) - - # If extension was disabled before update, disable it again - if not backup_registry_entry.get("enabled", True): - new_metadata["enabled"] = False - - # Use restore() instead of update() because update() always - # preserves the existing installed_at, ignoring our override - manager.registry.restore(extension_id, new_metadata) - - # Also disable hooks in extensions.yml if extension was disabled - if not backup_registry_entry.get("enabled", True): - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = False - hook_executor.save_project_config(config) - finally: - # Archive cleanup is housekeeping: never replace an install - # error or roll back an already committed update because a - # scanner temporarily locks the download on Windows. - try: - archive_path.unlink(missing_ok=True) - except OSError as error: - zip_cleanup_error = error - - # 10. Clean up backup on success. The update has committed at - # this point, so a locked backup file must not trigger rollback - # of an otherwise successful installation. - cleanup_error = None - if backup_created_by_attempt and backup_base.exists(): - try: - shutil.rmtree(backup_base) - except OSError as error: - cleanup_error = error - - console.print(f" [green]✓[/green] Updated to v{update['available']}") - if cleanup_error is not None: - console.print( - " [yellow]Warning:[/yellow] Could not fully remove " - "update backup: " - f"{_escape_markup(str(cleanup_error))}" - ) - console.print( - " [dim]Backup may remain at: " - f"{_escape_markup(str(backup_base))}[/dim]" - ) - if zip_cleanup_error is not None: - console.print( - " [yellow]Warning:[/yellow] Could not remove " - "downloaded update archive: " - f"{_escape_markup(str(zip_cleanup_error))}" - ) - updated_extensions.append(ext_name) - - except KeyboardInterrupt: - raise - except Exception as e: - console.print(f" [red]✗[/red] Failed: {_escape_markup(str(e))}") - failed_updates.append((ext_name, str(e))) - if zip_cleanup_error is not None: - console.print( - " [yellow]Warning:[/yellow] Could not remove " - "downloaded update archive: " - f"{_escape_markup(str(zip_cleanup_error))}" - ) - - if not installation_modified: - if backup_created_by_attempt and backup_base.exists(): - try: - shutil.rmtree(backup_base) - except OSError as cleanup_error: - console.print( - " [yellow]Warning:[/yellow] Could not remove " - "untouched-update backup: " - f"{_escape_markup(str(cleanup_error))}" - ) - continue - - # Rollback on failure - console.print(f" [yellow]↩[/yellow] Rolling back {safe_ext_name}...") - try: - # Restore extension directory - # Only perform destructive rollback if backup exists (meaning we - # actually modified the extension). This avoids deleting a valid - # installation when failure happened before changes were made. - extension_dir = manager.extensions_dir / extension_id - if backup_ext_dir.exists(): - if extension_dir.exists(): - shutil.rmtree(extension_dir) - shutil.copytree(backup_ext_dir, extension_dir) - - # Remove any NEW command files created by failed install - # (files that weren't in the original backup). Registration - # writes before registry.add(), so start with the paths that - # were absent at the destructive boundary instead of relying - # only on a possibly missing new registry entry. - for command_path in new_command_paths_absent_before_update: - if command_path.is_symlink() or command_path.is_file(): - command_path.unlink() - elif command_path.exists(): - raise RuntimeError( - "Command rollback found an unexpected directory " - f"at '{command_path}'" - ) - new_registered_skills = [] - try: - new_registry_entry = manager.registry.get(extension_id) - if new_registry_entry is None or not isinstance(new_registry_entry, dict): - new_registered_commands = {} - else: - new_registered_commands = new_registry_entry.get("registered_commands", {}) - new_registered_skills = manager._valid_name_list( - new_registry_entry.get("registered_skills", []) - ) - for agent_name, cmd_names in new_registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = _AgentReg._resolve_agent_dir( - agent_name, agent_config, project_root - ) - - for cmd_name in cmd_names: - output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) - cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" - # Delete if it exists and wasn't in our backup - if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: - cmd_file.unlink() - - # Also handle copilot prompt files - if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: - prompt_file.unlink() - except KeyError: - pass # No new registry entry exists, nothing to clean up - - # Restore command artifacts that existed before the update - # before extension-skill cleanup inspects ownership. A - # failed skills registrar may have overwritten a user's - # pre-existing SKILL.md with extension metadata; restoring - # it first prevents the conservative skill unregistrar from - # misclassifying and deleting the user's whole directory. - for original_path, backup_path in backed_up_command_files.items(): - restore_command_artifact( - original_path, backup_path - ) - - # Skill generation happens before hooks and registry.add(), - # so a failed install may have created skills that are not - # recorded in any registry entry yet. Derive names from the - # preflighted manifest as well as any partial new entry. - skills_to_remove = list( - dict.fromkeys(new_skill_names + new_registered_skills) - ) - # A write failure can leave a partial skill without valid - # ownership metadata, which the normal conservative - # unregistrar intentionally refuses to delete. Paths that - # were absent at the destructive boundary are safe to - # remove directly during rollback. - for skill_path in new_skill_paths_absent_before_update: - if skill_path.is_symlink() or skill_path.is_file(): - skill_path.unlink() - elif skill_path.exists(): - shutil.rmtree(skill_path) - manager._unregister_extension_skills( - skills_to_remove, extension_id - ) - - # Restore all original registered skill artifacts after - # removing skills created by the failed installation. - for original_path, backup_path in backed_up_skill_dirs.items(): - backup_skill_dir = Path(backup_path) - if not backup_skill_dir.is_dir(): - raise RuntimeError( - "Skill rollback backup is missing for " - f"'{original_path}'" - ) - original_skill_dir = Path(original_path) - if ( - original_skill_dir.is_symlink() - or original_skill_dir.is_file() - ): - original_skill_dir.unlink() - elif original_skill_dir.exists(): - shutil.rmtree(original_skill_dir) - original_skill_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree( - backup_skill_dir, - original_skill_dir, - symlinks=True, - ) - - # Remove empty artifact directories that did not exist at - # the destructive boundary. Do this after skill cleanup and - # restoration so newly created skills roots and their - # project-local parents can also be removed exactly. - for command_dir in sorted( - new_command_dirs_absent_before_update, - key=lambda path: len(path.parts), - reverse=True, - ): - if command_dir.is_dir() and not command_dir.is_symlink(): - try: - command_dir.rmdir() - except OSError: - # Preserve any non-empty directory: other - # content may belong to the user. - pass - - # Restore metadata in extensions.yml (hooks and installed list). - # Only run if backup step 4 was reached (backup_hooks is not None); - # otherwise we have no safe baseline to restore from and could corrupt - # the config by removing pre-existing hooks. - if backup_hooks is not None: - config = hook_executor.get_project_config() - if not isinstance(config, dict): - config = {} - - modified = False - - # 1. Restore hooks in extensions.yml - if not isinstance(config.get("hooks"), dict): - config["hooks"] = {} - modified = True - - # Remove any hooks for this extension added by the failed install - for hook_name in list(config["hooks"].keys()): - hooks_list = config["hooks"][hook_name] - if not isinstance(hooks_list, list): - config["hooks"][hook_name] = [] - modified = True - continue - - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if isinstance(h, dict) and h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - - # Add back the backed-up hooks - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if not isinstance(config["hooks"].get(hook_name), list): - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True - - # 2. Restore installed list in extensions.yml - if backup_installed is not UNSET: - if config.get("installed") != backup_installed: - config["installed"] = backup_installed - modified = True - - if modified: - hook_executor.save_project_config(config) - - # Restore registry entry (use restore() since entry was removed) - if backup_registry_entry: - manager.registry.restore(extension_id, backup_registry_entry) - - # Backup cleanup is post-rollback housekeeping. A locked - # file (notably on Windows) must not turn successfully - # restored state into a contradictory "Rollback failed". - cleanup_error = None - if backup_created_by_attempt and backup_base.exists(): - try: - shutil.rmtree(backup_base) - except OSError as error: - cleanup_error = error - console.print(" [green]✓[/green] Rollback successful") - if cleanup_error is not None: - console.print( - " [yellow]Warning:[/yellow] Could not fully " - "remove rollback backup: " - f"{_escape_markup(str(cleanup_error))}" - ) - console.print( - " [dim]Backup may remain at: " - f"{_escape_markup(str(backup_base))}[/dim]" - ) - except Exception as rollback_error: - console.print(f" [red]✗[/red] Rollback failed: {_escape_markup(str(rollback_error))}") - console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]") - - # Summary - console.print() - if updated_extensions: - console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") - if failed_updates: - console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") - for ext_name, error in failed_updates: - console.print(f" • {_escape_markup(str(ext_name))}: {_escape_markup(str(error))}") - raise typer.Exit(1) - - # S4: regenerate native event config after a successful update. An - # update replaces the installed extension.yml, so any added/removed/ - # changed event declarations would otherwise leave native configs - # stale until a manual integration upgrade. - if updated_extensions: - _refresh_events_and_warn(project_root) - - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") - raise typer.Exit(1) - - -@extension_app.command("enable") -def extension_enable( - extension: str = typer.Argument(help="Extension ID or name to enable"), -): - """Enable a disabled extension.""" - from . import ExtensionManager, HookExecutor - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") - - # Update registry - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print( - f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " - "not found in registry (corrupted state)" - ) - raise typer.Exit(1) - - if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]") - raise typer.Exit(0) - - manager.registry.update(extension_id, {"enabled": True}) - - # Enable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = True - hook_executor.save_project_config(config) - - console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled") - - # #1: regenerate native event config so the enabled extension's events - # are re-emitted in installed integrations. - _refresh_events_and_warn(project_root) - - # Scaffold config templates on enable - try: - deployed, skipped, failed = manager.scaffold_config(extension_id) - except Exception as exc: - console.print( - f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension " - f"'{_escape_markup(str(display_name))}'." - ) - console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]") - deployed, skipped, failed = [], [], [] - config_home = f".specify/extensions/{_escape_markup(str(extension_id))}" - if deployed: - console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") - for cfg in deployed: - console.print(f" • {config_home}/{_escape_markup(str(cfg))}") - if skipped: - console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") - if failed: - console.print( - f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " - f"{_escape_markup(', '.join(failed))}. " - "Verify the extension manifest and template files." - ) - - -@extension_app.command("disable") -def extension_disable( - extension: str = typer.Argument(help="Extension ID or name to disable"), -): - """Disable an extension without removing it.""" - from . import ExtensionManager, HookExecutor - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") - - # Update registry - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print( - f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " - "not found in registry (corrupted state)" - ) - raise typer.Exit(1) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already disabled[/yellow]") - raise typer.Exit(0) - - manager.registry.update(extension_id, {"enabled": False}) - - # Disable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = False - hook_executor.save_project_config(config) - - console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' disabled") - console.print("\nCommands will no longer be available. Hooks will not execute.") - console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}") - - # #1: regenerate native event config so the disabled extension's events - # are stripped from installed integrations. - _refresh_events_and_warn(project_root) - - -@extension_app.command("set-priority") -def extension_set_priority( - extension: str = typer.Argument(help="Extension ID or name"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): - """Set the resolution priority of an installed extension.""" - from . import ExtensionManager - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = ExtensionManager(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") - - # Get current metadata - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print( - f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " - "not found in registry (corrupted state)" - ) - raise typer.Exit(1) - - from . 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]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(extension_id, {"priority": priority}) +def register(app: typer.Typer) -> None: + """Attach the extension command group to the root Typer app.""" + from .catalog import register as register_catalog - console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + register_catalog(extension_app) + from . import command_add # noqa: F401 — registers handler via decorator + from . import command_disable # noqa: F401 — registers handler via decorator + from . import command_enable # 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_remove # noqa: F401 — registers handler via decorator + from . import command_search # noqa: F401 — registers handler via decorator + from . import command_set_priority # noqa: F401 — registers handler via decorator + from . import command_update # noqa: F401 — registers handler via decorator -def register(app: typer.Typer) -> None: - """Attach the extension command group to the root Typer app.""" app.add_typer(extension_app, name="extension") diff --git a/src/specify_cli/extensions/catalog/__init__.py b/src/specify_cli/extensions/catalog/__init__.py new file mode 100644 index 0000000000..211be74d5e --- /dev/null +++ b/src/specify_cli/extensions/catalog/__init__.py @@ -0,0 +1,32 @@ +"""Registration for the nested ``specify extension catalog`` command group. + +Command handlers live in ``command_*.py`` modules and shared catalog helpers +live in ``_helpers.py``. +""" +from __future__ import annotations + +import typer + + +catalog_app = typer.Typer( + name="catalog", + help=( + "Manage extension catalogs.\n\n" + "Catalogs are either install sources (install_allowed) or discovery-only " + "search surfaces. The built-in 'community' catalog is discovery-only by " + "design: it is unvetted, so it is searchable but not installable. To install " + "something you found there, either use 'specify extension add --from " + "' after vetting it, or curate your own catalog you control. Never flip a " + "discovery-only catalog to install_allowed — that is the vetting boundary." + ), + add_completion=False, +) + + +def register(app: typer.Typer) -> None: + """Attach the catalog command group to the extension Typer app.""" + from . import command_add # noqa: F401 — registers handler via decorator + from . import command_list # 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/extensions/catalog/_helpers.py b/src/specify_cli/extensions/catalog/_helpers.py new file mode 100644 index 0000000000..8b7cc58716 --- /dev/null +++ b/src/specify_cli/extensions/catalog/_helpers.py @@ -0,0 +1,37 @@ +"""Shared helpers for extension catalog commands.""" +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .. import _commands + + +def load_catalog_command_config(project_root: Path, config_path: Path) -> dict: + """Load extension catalog CLI config with user-facing shape errors.""" + try: + config = _commands.yaml.safe_load(config_path.read_text(encoding="utf-8")) + except Exception as error: + config_label = _escape_markup( + str(_commands._display_project_path(project_root, config_path)) + ) + _commands.console.print( + f"[red]Error:[/red] Failed to read {config_label}: " + f"{_escape_markup(str(error))}" + ) + raise typer.Exit(1) + + if config is None: + return {} + if not isinstance(config, dict): + config_label = _escape_markup( + str(_commands._display_project_path(project_root, config_path)) + ) + _commands.console.print( + f"[red]Error:[/red] Invalid catalog config {config_label}: " + "expected a YAML mapping at the root." + ) + raise typer.Exit(1) + return config diff --git a/src/specify_cli/extensions/catalog/command_add.py b/src/specify_cli/extensions/catalog/command_add.py new file mode 100644 index 0000000000..0844b9d479 --- /dev/null +++ b/src/specify_cli/extensions/catalog/command_add.py @@ -0,0 +1,102 @@ +"""Implementation of ``specify extension catalog add``. + +Registered by ``catalog.register()``; shared catalog helpers live in +``catalog._helpers``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .. import _commands +from . import catalog_app +from ._helpers import load_catalog_command_config + + +@catalog_app.command("add") +def 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=( + "Mark this catalog as a trusted install source. Only enable this for a " + "catalog you own and vet; leave it off (the default) for discovery-only " + "search surfaces. Never enable it for an unvetted public catalog." + ), + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/extension-catalogs.yml.""" + from .. import ExtensionCatalog, ValidationError + + project_root = _commands._require_specify_project() + specify_dir = project_root / ".specify" + + # Validate URL + tmp_catalog = ExtensionCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except ValidationError as error: + _commands.console.print(f"[red]Error:[/red] {_escape_markup(str(error))}") + raise typer.Exit(1) + + config_path = specify_dir / "extension-catalogs.yml" + + # Load existing config + if config_path.exists(): + config = load_catalog_command_config(project_root, config_path) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + _commands.console.print( + "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." + ) + raise typer.Exit(1) + + safe_name = _escape_markup(name) + safe_url = _escape_markup(url) + + # Check for duplicate name + for existing in catalogs: + if isinstance(existing, dict) and existing.get("name") == name: + _commands.console.print( + f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists." + ) + _commands.console.print( + "Use 'specify extension 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( + _commands.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" + _commands.console.print( + f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})" + ) + _commands.console.print(f" URL: {safe_url}") + _commands.console.print(f" Priority: {priority}") + config_label = _escape_markup( + str(_commands._display_project_path(project_root, config_path)) + ) + _commands.console.print(f"\nConfig saved to {config_label}") diff --git a/src/specify_cli/extensions/catalog/command_list.py b/src/specify_cli/extensions/catalog/command_list.py new file mode 100644 index 0000000000..ea0355577b --- /dev/null +++ b/src/specify_cli/extensions/catalog/command_list.py @@ -0,0 +1,94 @@ +"""Implementation of ``specify extension catalog list``. + +Registered by ``catalog.register()``; shared catalog helpers live in +``catalog._helpers``. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import typer +from rich.markup import escape as _escape_markup + +from .. import _commands +from . import catalog_app + + +@catalog_app.command("list") +def catalog_list(): + """List all active extension catalogs.""" + from .. import ExtensionCatalog, ValidationError + + project_root = _commands._require_specify_project() + catalog = ExtensionCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except ValidationError as error: + _commands.console.print(f"[red]Error:[/red] {_escape_markup(str(error))}") + raise typer.Exit(1) + + _commands.console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + _commands.console.print( + f" [bold]{_escape_markup(entry.name)}[/bold] " + f"(priority {entry.priority})" + ) + if entry.description: + _commands.console.print(f" {_escape_markup(entry.description)}") + _commands.console.print(f" URL: {_escape_markup(str(entry.url))}") + _commands.console.print(f" Install: {install_str}") + _commands.console.print() + + if any(not entry.install_allowed for entry in active_catalogs): + _commands.console.print( + "[dim]Discovery-only catalogs are searchable but not installable by design " + "(unvetted sources). To install something you found in one, vet it and run " + "'specify extension add --from ', or add it to a catalog you " + "control. Don't flip a discovery-only catalog to install_allowed.[/dim]\n" + ) + + config_path = project_root / ".specify" / "extension-catalogs.yml" + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + if os.environ.get("SPECKIT_CATALOG_URL"): + _commands.console.print( + "[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]" + ) + else: + try: + proj_loaded = ( + config_path.exists() + and catalog._load_catalog_config(config_path) is not None + ) + except ValidationError: + proj_loaded = False + if proj_loaded: + config_label = _escape_markup( + str(_commands._display_project_path(project_root, config_path)) + ) + _commands.console.print(f"[dim]Config: {config_label}[/dim]") + else: + try: + user_loaded = ( + user_config_path.exists() + and catalog._load_catalog_config(user_config_path) is not None + ) + except ValidationError: + user_loaded = False + if user_loaded: + _commands.console.print( + "[dim]Config: ~/.specify/extension-catalogs.yml[/dim]" + ) + else: + _commands.console.print( + "[dim]Using built-in default catalog stack.[/dim]" + ) + _commands.console.print( + "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" + ) diff --git a/src/specify_cli/extensions/catalog/command_remove.py b/src/specify_cli/extensions/catalog/command_remove.py new file mode 100644 index 0000000000..e99bc7faa9 --- /dev/null +++ b/src/specify_cli/extensions/catalog/command_remove.py @@ -0,0 +1,66 @@ +"""Implementation of ``specify extension catalog remove``. + +Registered by ``catalog.register()``; shared catalog helpers live in +``catalog._helpers``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .. import _commands +from . import catalog_app +from ._helpers import load_catalog_command_config + + +@catalog_app.command("remove") +def catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/extension-catalogs.yml.""" + project_root = _commands._require_specify_project() + specify_dir = project_root / ".specify" + + config_path = specify_dir / "extension-catalogs.yml" + if not config_path.exists(): + _commands.console.print( + "[red]Error:[/red] No catalog config found. Nothing to remove." + ) + raise typer.Exit(1) + + config = load_catalog_command_config(project_root, config_path) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + _commands.console.print( + "[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list." + ) + raise typer.Exit(1) + safe_name = _escape_markup(name) + original_count = len(catalogs) + catalogs = [ + catalog + for catalog in catalogs + if isinstance(catalog, dict) and catalog.get("name") != name + ] + + if len(catalogs) == original_count: + _commands.console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.") + raise typer.Exit(1) + + config["catalogs"] = catalogs + config_path.write_text( + _commands.yaml.safe_dump( + config, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ), + encoding="utf-8", + ) + + _commands.console.print(f"[green]✓[/green] Removed catalog '{safe_name}'") + if not catalogs: + _commands.console.print( + "\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]" + ) diff --git a/src/specify_cli/extensions/command_add.py b/src/specify_cli/extensions/command_add.py new file mode 100644 index 0000000000..9a5bd7cad2 --- /dev/null +++ b/src/specify_cli/extensions/command_add.py @@ -0,0 +1,292 @@ +"""Implementation of ``specify extension add``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import typer +from rich.markup import escape as _escape_markup +from rich.panel import Panel + +from .._console import console +from . import _commands + + +@_commands.extension_app.command("add") +def extension_add( + extension: str = typer.Argument(help="Extension name or path"), + dev: bool = typer.Option(False, "--dev", help="Install from local directory"), + from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install an extension.""" + from . import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND + + project_root = _commands._require_specify_project() + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + speckit_version = _commands.get_speckit_version() + + if force: + console.print("[yellow]--force:[/yellow] Will overwrite if already installed") + + # Prompt for URL-based installs BEFORE the spinner so the user can + # actually see and respond to the confirmation (the Rich status + # spinner overwrites the typer.confirm prompt line, making it appear + # as though the command is hung). + # Guard with ``not dev`` so that --dev + --from does not show a + # confusing confirmation for a URL that will be ignored. + if from_url and not dev: + from urllib.parse import urlparse + + try: + parsed = urlparse(from_url) + # Read .hostname inside the try: parsing a malformed authority -- or + # accessing .hostname on one, e.g. an invalid bracketed IPv6 host like + # "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the + # parse and the .hostname read inside the guard surfaces a clean + # "Invalid URL" message instead of leaking a raw traceback past the + # CLI. Reuse the value below. + hostname = parsed.hostname + parsed.port + except ValueError: + console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") + raise typer.Exit(1) + if not hostname: + console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}") + raise typer.Exit(1) + + if not _commands.is_https_or_localhost_http(from_url): + console.print("[red]Error:[/red] URL must use HTTPS for security.") + console.print("HTTP is only allowed for loopback URLs.") + raise typer.Exit(1) + + safe_url = _escape_markup(from_url) + + # Warn about untrusted sources — default-deny confirmation + console.print() + console.print(Panel( + f"[bold]You are installing an extension directly from an external URL,\n" + f"bypassing your trusted (install-allowed) extension catalogs.[/bold]\n\n" + f"URL: {safe_url}\n\n" + f"Only install extensions from sources you trust.", + title="[bold yellow]⚠ Untrusted Source[/bold yellow]", + border_style="yellow", + padding=(1, 2), + )) + console.print() + confirm = typer.confirm("Continue with installation?", default=False) + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + safe_extension = _escape_markup(extension) + + try: + with console.status(f"[cyan]Installing extension: {safe_extension}[/cyan]"): + if dev: + # Install from local directory + source_path = Path(extension).expanduser().resolve() + safe_source_path = _escape_markup(str(source_path)) + if not source_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {safe_source_path}") + raise typer.Exit(1) + + if not (source_path / "extension.yml").exists(): + console.print(f"[red]Error:[/red] No extension.yml found in {safe_source_path}") + raise typer.Exit(1) + + if force: + console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{safe_source_path}[/cyan] (will overwrite if already installed)...") + + manifest = manager.install_from_directory( + source_path, + speckit_version, + priority=priority, + link_commands=True, + force=force + ) + + elif from_url: + # Install from URL archive via the shared hardened downloader + # (HTTPS enforcement, authenticated redirect-guarded fetch, + # bounded read, archive-format detection, TOCTOU-safe transient + # archive). Same path used by ``specify init --extension ``. + console.print(f"Downloading from {safe_url}...") + manifest = _commands.install_extension_from_url( + manager, + project_root, + from_url, + speckit_version, + priority=priority, + force=force, + ) + + else: + # Try bundled extensions first (shipped with spec-kit) + bundled_path = _commands._locate_bundled_extension(extension) + if bundled_path is not None: + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) + else: + # Install from catalog (also resolves display names to IDs) + catalog = ExtensionCatalog(project_root) + + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _commands._resolve_catalog_extension( + extension, catalog, "add" + ) + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}") + raise typer.Exit(1) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{safe_extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + raise typer.Exit(1) + + # If catalog resolved a display name to an ID, check bundled again + resolved_id = ext_info['id'] + if resolved_id != extension: + bundled_path = _commands._locate_bundled_extension(resolved_id) + if bundled_path is not None: + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) + + if bundled_path is None: + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(ext_info['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) + + # Enforce install_allowed policy + if not ext_info.get("_install_allowed", True): + catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community"))) + resolved_id = _commands._command_safe_id(ext_info["id"]) + console.print( + f"[red]Error:[/red] '{safe_extension}' was found in the " + f"'{catalog_name}' catalog, which is discovery-only — a search " + f"surface, not an install source." + ) + console.print( + "\nDiscovery-only catalogs are intentionally not installable so " + "unvetted extensions can't be pulled in without review. Don't flip " + "such a catalog to install_allowed. Instead, once you've vetted this " + "extension:" + ) + console.print( + f" • install it directly from its archive URL:\n" + f" specify extension add {resolved_id} --from " + ) + console.print( + " • or add it to a catalog you curate and control " + "(install_allowed: true)." + ) + raise typer.Exit(1) + + # Download extension archive (use the resolved catalog ID). + extension_id = ext_info['id'] + console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...") + archive_path = catalog.download_extension(extension_id) + + try: + manifest = manager.install_from_zip( + archive_path, + speckit_version, + priority=priority, + force=force, + catalog_name=ext_info.get("_catalog_name"), + ) + finally: + archive_path.unlink(missing_ok=True) + + console.print("\n[green]✓[/green] Extension installed successfully!") + console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})") + console.print(f" {_escape_markup(str(manifest.description))}") + + # #1: regenerate native event config for installed event-capable + # integrations so the new extension's events take effect immediately. + _commands._refresh_events_and_warn(project_root) + + for warning in manifest.warnings: + console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}") + + selected_ai = _commands.load_init_options(project_root).get("ai") + is_cline = selected_ai == "cline" + is_forge = selected_ai == "forge" + + if is_cline: + from specify_cli.integrations.cline import format_cline_command_name + if is_forge: + from specify_cli.integrations.forge import format_forge_command_name + + console.print("\n[bold cyan]Provided commands:[/bold cyan]") + for cmd in manifest.commands: + cmd_name = cmd['name'] + if is_cline: + cmd_name = format_cline_command_name(cmd_name) + elif is_forge: + cmd_name = format_forge_command_name(cmd_name) + console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}") + + # Report agent skills registration + reg_meta = manager.registry.get(manifest.id) + reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] + # Normalize to guard against corrupted registry entries + if not isinstance(reg_skills, list): + reg_skills = [] + if reg_skills: + console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") + + # Scaffold config templates automatically + deployed, skipped, failed = manager.scaffold_config(manifest.id) + config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}" + if deployed: + console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") + for cfg in deployed: + console.print(f" • {config_home}/{_escape_markup(str(cfg))}") + if skipped: + console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") + if failed: + console.print( + f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " + f"{_escape_markup(', '.join(failed))}. " + "Verify the extension manifest and template files." + ) + + # Only warn when configuration is actually unresolved. Scaffolding that + # deployed or preserved every template has already answered this, and an + # extension without provides.config has nothing to configure; the blanket + # warning contradicted the output directly above it. + if failed or not (deployed or skipped): + console.print("\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f" Check: {config_home}/") + + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except CompatibilityError as e: + console.print(f"\n[red]Compatibility Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") + raise typer.Exit(1) diff --git a/src/specify_cli/extensions/command_disable.py b/src/specify_cli/extensions/command_disable.py new file mode 100644 index 0000000000..b9ee83134e --- /dev/null +++ b/src/specify_cli/extensions/command_disable.py @@ -0,0 +1,62 @@ +"""Implementation of ``specify extension disable``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands + + +@_commands.extension_app.command("disable") +def extension_disable( + extension: str = typer.Argument(help="Extension ID or name to disable"), +): + """Disable an extension without removing it.""" + from . import ExtensionManager, HookExecutor + + project_root = _commands._require_specify_project() + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _commands._resolve_installed_extension( + extension, installed, "disable" + ) + + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " + "not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already disabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": False}) + + # Disable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' disabled") + console.print("\nCommands will no longer be available. Hooks will not execute.") + console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}") + + # #1: regenerate native event config so the disabled extension's events + # are stripped from installed integrations. + _commands._refresh_events_and_warn(project_root) diff --git a/src/specify_cli/extensions/command_enable.py b/src/specify_cli/extensions/command_enable.py new file mode 100644 index 0000000000..c338245905 --- /dev/null +++ b/src/specify_cli/extensions/command_enable.py @@ -0,0 +1,84 @@ +"""Implementation of ``specify extension enable``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands + + +@_commands.extension_app.command("enable") +def extension_enable( + extension: str = typer.Argument(help="Extension ID or name to enable"), +): + """Enable a disabled extension.""" + from . import ExtensionManager, HookExecutor + + project_root = _commands._require_specify_project() + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _commands._resolve_installed_extension( + extension, installed, "enable" + ) + + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " + "not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Extension '{_escape_markup(str(display_name))}' is already enabled[/yellow]") + raise typer.Exit(0) + + manager.registry.update(extension_id, {"enabled": True}) + + # Enable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = True + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' enabled") + + # #1: regenerate native event config so the enabled extension's events + # are re-emitted in installed integrations. + _commands._refresh_events_and_warn(project_root) + + # Scaffold config templates on enable + try: + deployed, skipped, failed = manager.scaffold_config(extension_id) + except Exception as exc: + console.print( + f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension " + f"'{_escape_markup(str(display_name))}'." + ) + console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]") + deployed, skipped, failed = [], [], [] + config_home = f".specify/extensions/{_escape_markup(str(extension_id))}" + if deployed: + console.print("\n[bold cyan]Config scaffolded:[/bold cyan]") + for cfg in deployed: + console.print(f" • {config_home}/{_escape_markup(str(cfg))}") + if skipped: + console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]") + if failed: + console.print( + f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: " + f"{_escape_markup(', '.join(failed))}. " + "Verify the extension manifest and template files." + ) diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py new file mode 100644 index 0000000000..a4c68c7a9b --- /dev/null +++ b/src/specify_cli/extensions/command_info.py @@ -0,0 +1,261 @@ +"""Implementation and private helpers for ``specify extension info``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from . import _commands + + +@_commands.extension_app.command("info") +def extension_info( + extension: str = typer.Argument(help="Extension ID or name"), +): + """Show detailed information about an extension.""" + from . import ExtensionCatalog, ExtensionManager, normalize_priority + + project_root = _commands._require_specify_project() + catalog = ExtensionCatalog(project_root) + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + # Try to resolve from installed extensions first (by ID or name) + # Use allow_not_found=True since the extension may be catalog-only + resolved_installed_id, resolved_installed_name = _commands._resolve_installed_extension( + extension, installed, "info", allow_not_found=True + ) + + # Try catalog lookup (with error handling) + # If we resolved an installed extension by display name, use its ID for catalog lookup + # to ensure we get the correct catalog entry (not a different extension with same name) + lookup_key = resolved_installed_id if resolved_installed_id else extension + ext_info, catalog_error = _commands._resolve_catalog_extension( + lookup_key, catalog, "info" + ) + + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return + + # Case 2: Installed locally but catalog lookup failed or not in catalog + if resolved_installed_id: + # Get local manifest info + ext_manifest = manager.get_extension(resolved_installed_id) + metadata = manager.registry.get(resolved_installed_id) + metadata_is_dict = isinstance(metadata, dict) + if not metadata_is_dict: + _commands.console.print( + "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " + "some information may be unavailable." + ) + version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" + + _commands.console.print(f"\n[bold]{_escape_markup(str(resolved_installed_name))}[/bold] (v{_escape_markup(str(version))})") + _commands.console.print(f"ID: {_escape_markup(str(resolved_installed_id))}") + _commands.console.print() + + if ext_manifest: + _commands.console.print(f"{_escape_markup(str(ext_manifest.description))}") + _commands.console.print() + # Author is optional in extension.yml, safely retrieve it + author = ext_manifest.data.get("extension", {}).get("author") + if author: + _commands.console.print(f"[dim]Author:[/dim] {_escape_markup(str(author))}") + if ext_manifest.category: + _commands.console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_manifest.category))}") + if ext_manifest.effect: + _commands.console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_manifest.effect))}") + _commands.console.print() + + if ext_manifest.commands: + # Print each command the way the active agent registers it. + # Cline and Forge hyphenate command names (e.g. Forge invokes + # `/speckit-jira-sync`, not the manifest's dotted + # `speckit.jira.sync`), so mirror the same formatting used by + # `extension add`'s "Provided commands" listing — otherwise the + # names shown here don't match what the user actually types. + selected_ai = _commands.load_init_options(project_root).get("ai") + if selected_ai == "cline": + from specify_cli.integrations.cline import ( + format_cline_command_name as _format_command_name, + ) + elif selected_ai == "forge": + from specify_cli.integrations.forge import ( + format_forge_command_name as _format_command_name, + ) + else: + _format_command_name = None + + _commands.console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + cmd_name = cmd['name'] + if _format_command_name is not None: + cmd_name = _format_command_name(cmd_name) + _commands.console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}") + _commands.console.print() + + # Show catalog status + if catalog_error: + _commands.console.print(f"[yellow]Catalog unavailable:[/yellow] {_escape_markup(str(catalog_error))}") + _commands.console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") + else: + _commands.console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") + + _commands.console.print() + _commands.console.print("[green]✓ Installed[/green]") + priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) + _commands.console.print(f"[dim]Priority:[/dim] {priority}") + _commands.console.print(f"\nTo remove: specify extension remove {_escape_markup(str(resolved_installed_id))}") + return + + # Case 3: Not found anywhere + if catalog_error: + _commands.console.print(f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}") + _commands.console.print("\nTry again when online, or use the extension ID directly.") + else: + _commands.console.print(f"[red]Error:[/red] Extension '{_escape_markup(extension)}' not found") + _commands.console.print("\nTry: specify extension search") + raise typer.Exit(1) + + +def _print_extension_info(ext_info: dict, manager): + """Print formatted extension info from catalog data.""" + from . import normalize_priority + + # Header + verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" + _commands.console.print(f"\n[bold]{_escape_markup(str(ext_info['name']))}[/bold] (v{_escape_markup(str(ext_info['version']))}){verified_badge}") + _commands.console.print(f"ID: {_escape_markup(str(ext_info['id']))}") + _commands.console.print() + + # Description + _commands.console.print(f"{_escape_markup(str(ext_info['description']))}") + _commands.console.print() + + # Author and License + _commands.console.print(f"[dim]Author:[/dim] {_escape_markup(str(ext_info.get('author', 'Unknown')))}") + _commands.console.print(f"[dim]License:[/dim] {_escape_markup(str(ext_info.get('license', 'Unknown')))}") + + # Category and Effect + if ext_info.get('category'): + _commands.console.print(f"[dim]Category:[/dim] {_escape_markup(str(ext_info['category']))}") + if ext_info.get('effect'): + _commands.console.print(f"[dim]Effect:[/dim] {_escape_markup(str(ext_info['effect']))}") + + # Source catalog + if ext_info.get("_catalog_name"): + install_allowed = ext_info.get("_install_allowed", True) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + _commands.console.print(f"[dim]Source catalog:[/dim] {_escape_markup(str(ext_info['_catalog_name']))}{install_note}") + _commands.console.print() + + # Requirements + if ext_info.get('requires'): + _commands.console.print("[bold]Requirements:[/bold]") + reqs = ext_info['requires'] + if reqs.get('speckit_version'): + _commands.console.print(f" • Spec Kit: {_escape_markup(str(reqs['speckit_version']))}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = _escape_markup(str(tool['name'])) + tool_version = _escape_markup(str(tool.get('version', 'any'))) + required = " (required)" if tool.get('required') else " (optional)" + _commands.console.print(f" • {tool_name}: {tool_version}{required}") + _commands.console.print() + + # Provides + if ext_info.get('provides'): + _commands.console.print("[bold]Provides:[/bold]") + provides = ext_info['provides'] + if provides.get('commands'): + _commands.console.print(f" • Commands: {_escape_markup(str(provides['commands']))}") + if provides.get('hooks'): + _commands.console.print(f" • Hooks: {_escape_markup(str(provides['hooks']))}") + _commands.console.print() + + # Tags + info_tags = ext_info.get('tags', []) + if isinstance(info_tags, list) and info_tags: + tags_str = ", ".join(str(t) for t in info_tags) + _commands.console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}") + _commands.console.print() + + # Statistics + stats = [] + downloads = ext_info.get('downloads') + if downloads is not None: + # Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the + # JSON string "1500") would crash the ``:,`` format with "Cannot + # specify ',' with 's'". Only group-format numbers, and escape the + # fallback: the joined stats are rendered as Rich markup, so a value + # like "[/red]foo" would raise MarkupError (matching how every other + # catalog field here is escaped). + stats.append( + f"Downloads: {downloads:,}" + if isinstance(downloads, (int, float)) + else f"Downloads: {_escape_markup(str(downloads))}" + ) + stars = ext_info.get('stars') + if stars is not None: + # Same untrusted-value/Rich-markup hazard as `downloads` above, in the + # same joined string. + stats.append(f"Stars: {_escape_markup(str(stars))}") + if stats: + _commands.console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") + _commands.console.print() + + # Links + _commands.console.print("[bold]Links:[/bold]") + if ext_info.get('repository'): + _commands.console.print(f" • Repository: {_escape_markup(str(ext_info['repository']))}") + if ext_info.get('homepage'): + _commands.console.print(f" • Homepage: {_escape_markup(str(ext_info['homepage']))}") + if ext_info.get('documentation'): + _commands.console.print(f" • Documentation: {_escape_markup(str(ext_info['documentation']))}") + if ext_info.get('changelog'): + _commands.console.print(f" • Changelog: {_escape_markup(str(ext_info['changelog']))}") + _commands.console.print() + + # Installation status and command + is_installed = manager.registry.is_installed(ext_info['id']) + install_allowed = ext_info.get("_install_allowed", True) + safe_id = _escape_markup(str(ext_info['id'])) + cmd_id = _commands._command_safe_id(ext_info['id']) + if is_installed: + _commands.console.print("[green]✓ Installed[/green]") + metadata = manager.registry.get(ext_info['id']) + priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) + _commands.console.print(f"[dim]Priority:[/dim] {priority}") + _commands.console.print(f"\nTo remove: specify extension remove {cmd_id}") + elif install_allowed: + _commands.console.print("[yellow]Not installed[/yellow]") + _commands.console.print(f"\n[cyan]Install:[/cyan] specify extension add {cmd_id}") + else: + catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community"))) + _commands.console.print("[yellow]Not installed[/yellow]") + _commands.console.print( + f"\n[yellow]⚠[/yellow] '{safe_id}' is in the '{catalog_name}' catalog, which is " + f"discovery-only (a search surface, not an install source)." + ) + download_url = ext_info.get("download_url") + if download_url: + _commands.console.print( + f"Candidate archive (vet before installing): {_escape_markup(str(download_url))}" + ) + _commands.console.print( + f"Once vetted, install directly: specify extension add {cmd_id} --from " + ) + else: + _commands.console.print( + f"Once you've vetted its release archive, install directly: " + f"specify extension add {cmd_id} --from " + ) + _commands.console.print( + "Discovery-only catalogs are intentionally not install sources — don't set " + "install_allowed on them." + ) diff --git a/src/specify_cli/extensions/command_list.py b/src/specify_cli/extensions/command_list.py new file mode 100644 index 0000000000..90a536f9e4 --- /dev/null +++ b/src/specify_cli/extensions/command_list.py @@ -0,0 +1,75 @@ +"""Implementation of ``specify extension list``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +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 . import _commands + + +@_commands.extension_app.command("list", cls=InstalledListJSONCommand) +def extension_list( + available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), + all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), + json_output: bool = typer.Option(False, "--json", help="Output installed extensions as JSON"), +): + """List installed extensions.""" + from . import ExtensionManager, normalize_priority + + if json_output: + try: + project_root = resolve_specify_project_root() + manager = ExtensionManager(project_root) + installed = manager.list_installed() + installed = sorted( + installed, + key=lambda extension: ( + normalize_priority(extension.get("priority")), + str(extension.get("id", "")), + ), + ) + emit_json( + [installed_list_item(ext, include_hooks=True) for ext in installed] + ) + return + except Exception as error: + emit_json_error(error) + + project_root = _commands._require_specify_project() + manager = ExtensionManager(project_root) + installed = manager.list_installed() + + if not installed and not (available or all_extensions): + console.print("[yellow]No extensions installed.[/yellow]") + console.print("\nInstall an extension with:") + console.print(" specify extension add ") + return + + if installed: + console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + + for ext in installed: + status_icon = "✓" if ext["enabled"] else "✗" + status_color = "green" if ext["enabled"] else "red" + + console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{_escape_markup(ext['name'])}[/bold] (v{_escape_markup(str(ext['version']))})") + console.print(f" [dim]{_escape_markup(ext['id'])}[/dim]") + console.print(f" {_escape_markup(ext['description'])}") + console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") + console.print() + + if available or all_extensions: + console.print("\nInstall an extension:") + console.print(" [cyan]specify extension add [/cyan]") diff --git a/src/specify_cli/extensions/command_remove.py b/src/specify_cli/extensions/command_remove.py new file mode 100644 index 0000000000..c210dae96b --- /dev/null +++ b/src/specify_cli/extensions/command_remove.py @@ -0,0 +1,86 @@ +"""Implementation of ``specify extension remove``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands + + +@_commands.extension_app.command("remove") +def extension_remove( + extension: str = typer.Argument(help="Extension ID or name to remove"), + keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Uninstall an extension.""" + from . import ExtensionManager + + project_root = _commands._require_specify_project() + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _commands._resolve_installed_extension( + extension, installed, "remove" + ) + safe_extension_id = _escape_markup(str(extension_id)) + + # Get extension info for command and skill counts + ext_manifest = manager.get_extension(extension_id) + reg_meta = manager.registry.get(extension_id) + # Derive cmd_count from the registry's registered_commands (includes aliases) + # rather than from the manifest (primary commands only). Use max() across + # agents to get the per-agent count; sum() would double-count since users + # think in logical commands, not per-agent file counts. + # Use get() without a default so we can distinguish "key missing" (fall back + # to manifest) from "key present but empty dict" (zero commands registered). + registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None + if isinstance(registered_commands, dict): + cmd_count = max( + (len(v) for v in registered_commands.values() if isinstance(v, list)), + default=0, + ) + else: + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + raw_skills = reg_meta.get("registered_skills") if reg_meta else None + skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 + + # Confirm removal + if not force: + console.print("\n[yellow]⚠ This will remove:[/yellow]") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") + console.print(f" • Extension directory: .specify/extensions/{safe_extension_id}/") + if not keep_config: + console.print(" • Config files (will be backed up)") + console.print() + + confirm = typer.confirm("Continue?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + + # Remove extension + success = manager.remove(extension_id, keep_config=keep_config) + + if success: + console.print(f"\n[green]✓[/green] Extension '{_escape_markup(str(display_name))}' removed successfully") + if keep_config: + console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/") + else: + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/") + + # #1: regenerate native event config so the removed extension's events + # are stripped from installed integrations. + _commands._refresh_events_and_warn(project_root) + console.print(f"\nTo reinstall: specify extension add {safe_extension_id}") + else: + console.print("[red]Error:[/red] Failed to remove extension") + raise typer.Exit(1) diff --git a/src/specify_cli/extensions/command_search.py b/src/specify_cli/extensions/command_search.py new file mode 100644 index 0000000000..ad31662643 --- /dev/null +++ b/src/specify_cli/extensions/command_search.py @@ -0,0 +1,111 @@ +"""Implementation of ``specify extension search``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +from typing import Optional + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands + + +@_commands.extension_app.command("search") +def extension_search( + query: 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"), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +): + """Search for available extensions in catalog.""" + from . import ExtensionCatalog, ExtensionError + + project_root = _commands._require_specify_project() + catalog = ExtensionCatalog(project_root) + + try: + console.print("🔍 Searching extension catalog...") + results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + + if not results: + console.print("\n[yellow]No extensions found matching criteria[/yellow]") + if query or tag or author or verified: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify extension search (show all)") + raise typer.Exit(0) + + console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") + + for ext in results: + # Extension header + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f"[bold]{_escape_markup(str(ext['name']))}[/bold] (v{_escape_markup(str(ext['version']))}){verified_badge}") + console.print(f" {_escape_markup(str(ext['description']))}") + + # Metadata + console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}") + ext_tags = ext.get('tags', []) + if isinstance(ext_tags, list) and ext_tags: + tags_str = ", ".join(str(t) for t in ext_tags) + console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}") + + # Source catalog + catalog_name = _escape_markup(str(ext.get("_catalog_name", ""))) + install_allowed = ext.get("_install_allowed", True) + if catalog_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {catalog_name}") + else: + console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") + + # Stats + stats = [] + downloads = ext.get('downloads') + if downloads is not None: + # Catalog fields are untrusted; a non-numeric ``downloads`` + # (e.g. the JSON string "1500") would crash the ``:,`` format + # with "Cannot specify ',' with 's'". Only group-format numbers, + # and escape the fallback: the joined stats are rendered as Rich + # markup, so a value like "[/red]foo" would raise MarkupError + # (matching how every other catalog field here is escaped). + stats.append( + f"Downloads: {downloads:,}" + if isinstance(downloads, (int, float)) + else f"Downloads: {_escape_markup(str(downloads))}" + ) + stars = ext.get('stars') + if stars is not None: + # Same untrusted-value/Rich-markup hazard as `downloads` above, + # in the same joined string. + stats.append(f"Stars: {_escape_markup(str(stars))}") + if stats: + console.print(f" [dim]{' | '.join(stats)}[/dim]") + + # Links + if ext.get('repository'): + console.print(f" [dim]Repository:[/dim] {_escape_markup(str(ext['repository']))}") + + # Install command (show warning if not installable) + cmd_id = _commands._command_safe_id(ext['id']) + if install_allowed: + console.print(f"\n [cyan]Install:[/cyan] specify extension add {cmd_id}") + else: + console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}' (discovery-only).") + console.print( + f" Once vetted, install it directly: specify extension add {cmd_id} --from " + ) + console.print( + " Don't flip a discovery-only catalog to install_allowed — that's the vetting boundary." + ) + console.print() + + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {_escape_markup(str(e))}") + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) diff --git a/src/specify_cli/extensions/command_set_priority.py b/src/specify_cli/extensions/command_set_priority.py new file mode 100644 index 0000000000..3adbe73c9b --- /dev/null +++ b/src/specify_cli/extensions/command_set_priority.py @@ -0,0 +1,66 @@ +"""Implementation of ``specify extension set-priority``. + +Registered by ``_commands.register()``; shared command infrastructure lives in +``_commands.py``. +""" +from __future__ import annotations + +import typer +from rich.markup import escape as _escape_markup + +from .._console import console +from . import _commands + + +@_commands.extension_app.command("set-priority") +def extension_set_priority( + extension: str = typer.Argument(help="Extension ID or name"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed extension.""" + from . import ExtensionManager, normalize_priority + + project_root = _commands._require_specify_project() + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = ExtensionManager(project_root) + + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _commands._resolve_installed_extension( + extension, installed, "set-priority" + ) + + # Get current metadata + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(extension_id))}' " + "not found in registry (corrupted state)" + ) + raise typer.Exit(1) + + 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]Extension '{_escape_markup(str(display_name))}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(extension_id, {"priority": priority}) + + console.print(f"[green]✓[/green] Extension '{_escape_markup(str(display_name))}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") diff --git a/src/specify_cli/extensions/command_update.py b/src/specify_cli/extensions/command_update.py new file mode 100644 index 0000000000..d6cbbccf44 --- /dev/null +++ b/src/specify_cli/extensions/command_update.py @@ -0,0 +1,25 @@ +"""Implementation of ``specify extension update``. + +Registered by ``_commands.register()``; private update phases live in adjacent +``_command_update_*`` modules. +""" +from __future__ import annotations + +import typer + +from . import _commands +from ._command_update_artifacts import ( + _archive_extension_directory as _archive_extension_directory, +) +from ._command_update_discovery import ( + _bundled_update_source as _bundled_update_source, +) +from ._command_update_transaction import run_update_command + + +@_commands.extension_app.command("update") +def extension_update( + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), +): + """Update extension(s) to latest version.""" + return run_update_command(extension) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 2beb411a2a..21c3fffc5e 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -2002,77 +2002,9 @@ 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 - def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path): - project = self._make_project(tmp_path) - cfg_path = project / ".specify" / "extension-catalogs.yml" - cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") - - result = self._invoke([ - "extension", "catalog", "add", - "https://example.com/extension-catalog.yml", - "--name", "demo-extensions", - ], project) - assert result.exit_code == 1, result.output - output = _normalize_cli_output(result.output) - assert "Invalid catalog config .specify/extension-catalogs.yml" in output - assert "expected a YAML mapping at the root" in output - assert "AttributeError" not in output - - def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path): - project = self._make_project(tmp_path) - cfg_path = project / ".specify" / "extension-catalogs.yml" - cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") - result = self._invoke(["extension", "catalog", "remove", "demo"], project) - - assert result.exit_code == 1, result.output - output = _normalize_cli_output(result.output) - assert "Invalid catalog config .specify/extension-catalogs.yml" in output - assert "expected a YAML mapping at the root" in output - assert "AttributeError" not in output - - def test_extension_catalog_add_escapes_catalog_name_markup(self, tmp_path): - project = self._make_project(tmp_path) - catalog_name = "[red]demo[/red]" - - result = self._invoke([ - "extension", "catalog", "add", - "https://example.com/extension-catalog.yml", - "--name", catalog_name, - ], project) - assert result.exit_code == 0, result.output - output = _normalize_cli_output(result.output) - assert f"Added catalog '{catalog_name}'" in output - - def test_extension_catalog_remove_escapes_catalog_name_markup(self, tmp_path): - project = self._make_project(tmp_path) - catalog_name = "[red]demo[/red]" - cfg_path = project / ".specify" / "extension-catalogs.yml" - cfg_path.write_text( - yaml.safe_dump( - { - "catalogs": [ - { - "name": catalog_name, - "url": "https://example.com/extension-catalog.yml", - "priority": 10, - "install_allowed": False, - "description": "", - } - ] - }, - sort_keys=False, - ), - encoding="utf-8", - ) - - result = self._invoke(["extension", "catalog", "remove", catalog_name], project) - - assert result.exit_code == 0, result.output - output = _normalize_cli_output(result.output) - assert f"Removed catalog '{catalog_name}'" in output # -- search ------------------------------------------------------------ diff --git a/tests/specify_cli/__init__.py b/tests/specify_cli/__init__.py new file mode 100644 index 0000000000..ad53da6b34 --- /dev/null +++ b/tests/specify_cli/__init__.py @@ -0,0 +1 @@ +"""Tests mirroring the ``specify_cli`` package.""" diff --git a/tests/specify_cli/extensions/__init__.py b/tests/specify_cli/extensions/__init__.py new file mode 100644 index 0000000000..812b4e0c47 --- /dev/null +++ b/tests/specify_cli/extensions/__init__.py @@ -0,0 +1,6 @@ +"""Command-focused tests mirroring ``specify_cli.extensions``. + +Domain behavior remains in ``tests/test_extensions.py``; specialized security +and hardening suites stay beside the command tests they supplement. Tests for +private command phases use ``test_command__.py``. +""" diff --git a/tests/specify_cli/extensions/_helpers.py b/tests/specify_cli/extensions/_helpers.py new file mode 100644 index 0000000000..ef1bd845d2 --- /dev/null +++ b/tests/specify_cli/extensions/_helpers.py @@ -0,0 +1,46 @@ +"""Shared helpers for extension command tests.""" +from __future__ import annotations + +import os +from pathlib import Path + + +MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18 + + +def open_test_download_zip(project_root, download_dir, zip_filename): + """Create a transient download file using platform-appropriate semantics.""" + target = download_dir / zip_filename + o_temporary = getattr(os, "O_TEMPORARY", 0) + if o_temporary: + return os.open( + target, + os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, + 0o600, + ) + fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.unlink(target) + except OSError: + os.close(fd) + raise + return fd + + +def validate_safe_cache_dir(project_root): + """Create the expected extension download cache for command tests.""" + download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + return download_dir + + +def can_create_symlink(tmp_path: Path) -> bool: + """Return whether the current platform can create file symlinks.""" + target = tmp_path / "symlink-target.txt" + link = tmp_path / "symlink-link.txt" + target.write_text("ok", encoding="utf-8") + try: + os.symlink(target, link) + except OSError: + return False + return link.is_symlink() diff --git a/tests/specify_cli/extensions/catalog/__init__.py b/tests/specify_cli/extensions/catalog/__init__.py new file mode 100644 index 0000000000..0612feb46f --- /dev/null +++ b/tests/specify_cli/extensions/catalog/__init__.py @@ -0,0 +1 @@ +"""Tests mirroring the nested ``specify extension catalog`` command group.""" diff --git a/tests/specify_cli/extensions/catalog/test_command_add.py b/tests/specify_cli/extensions/catalog/test_command_add.py new file mode 100644 index 0000000000..3ae60be90e --- /dev/null +++ b/tests/specify_cli/extensions/catalog/test_command_add.py @@ -0,0 +1,196 @@ +"""Tests for ``specify extension catalog add``. + +Mirrors ``specify_cli.extensions.catalog.command_add``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + ExtensionCatalog, + ValidationError, +) +from tests.integrations.test_cli import _normalize_cli_output + + +class TestExtensionCatalogAddCLI: + """CLI tests for ``specify extension catalog add``.""" + + def test_catalog_add_escapes_url_markup(self, tmp_path): + """Catalog add should render user-supplied URLs literally.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + url = "https://example.com/[red]catalog[/red].json" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + url, + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert f"URL: {url}" in result.output + + def test_catalog_add_escapes_config_saved_path_markup(self, tmp_path): + """Catalog add's saved-path label should render literally under Rich.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + display_path = "project[red]/.specify/extension-catalogs.yml" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.extensions._commands._display_project_path", return_value=display_path): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert f"Config saved to {display_path}" in result.output + + def test_catalog_add_escapes_config_read_exception_markup(self, tmp_path): + """Catalog config parse errors can include user-controlled file content.""" + from typer.testing import CliRunner + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "extension-catalogs.yml").write_text("[red]bad[/red]", encoding="utf-8") + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch( + "specify_cli.extensions._commands.yaml.safe_load", + side_effect=yaml.YAMLError("bad [red]catalog[/red] yaml"), + ): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "bad [red]catalog[/red]" in result.output + assert "yaml" in result.output + + def test_catalog_add_escapes_url_validation_exception_markup(self, tmp_path): + """URL validation errors may include user-controlled URL text.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionCatalog, + "_validate_catalog_url", + side_effect=ValidationError("bad [red]url[/red]"), + ): + result = runner.invoke( + app, + [ + "extension", + "catalog", + "add", + "https://example.com/[red]catalog[/red].json", + "--name", + "community", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "bad [red]url[/red]" in result.output + +class TestExtensionCatalogAddIntegrationCLI: + """Integration coverage for the extension catalog command.""" + + def _make_project(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + return project + + def _invoke(self, argv, cwd): + + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(cwd) + return runner.invoke(app, argv, catch_exceptions=False) + finally: + os.chdir(old) + + def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path): + project = self._make_project(tmp_path) + cfg_path = project / ".specify" / "extension-catalogs.yml" + cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + result = self._invoke([ + "extension", "catalog", "add", + "https://example.com/extension-catalog.yml", + "--name", "demo-extensions", + ], project) + + assert result.exit_code == 1, result.output + output = _normalize_cli_output(result.output) + assert "Invalid catalog config .specify/extension-catalogs.yml" in output + assert "expected a YAML mapping at the root" in output + assert "AttributeError" not in output + + def test_extension_catalog_add_escapes_catalog_name_markup(self, tmp_path): + project = self._make_project(tmp_path) + catalog_name = "[red]demo[/red]" + + result = self._invoke([ + "extension", "catalog", "add", + "https://example.com/extension-catalog.yml", + "--name", catalog_name, + ], project) + + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + assert f"Added catalog '{catalog_name}'" in output diff --git a/tests/specify_cli/extensions/catalog/test_command_list.py b/tests/specify_cli/extensions/catalog/test_command_list.py new file mode 100644 index 0000000000..fc90a6e7e4 --- /dev/null +++ b/tests/specify_cli/extensions/catalog/test_command_list.py @@ -0,0 +1,129 @@ +"""Tests for ``specify extension catalog list``. + +Mirrors ``specify_cli.extensions.catalog.command_list``. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import yaml +from typer.testing import CliRunner + +from specify_cli import app + + +class TestExtensionCatalogListCLI: + """CLI tests for ``specify extension catalog list``.""" + + def test_catalog_list_escapes_config_path_markup(self, tmp_path): + """Catalog list's config-path label should render literally under Rich.""" + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "extension-catalogs.yml").write_text( + yaml.safe_dump( + { + "catalogs": [ + { + "name": "community", + "url": "https://example.com/catalog.json", + "priority": 10, + "install_allowed": False, + } + ] + } + ), + encoding="utf-8", + ) + + display_path = "project[red]/.specify/extension-catalogs.yml" + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.extensions._commands._display_project_path", return_value=display_path): + result = runner.invoke( + app, + ["extension", "catalog", "list"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert f"Config: {display_path}" in result.output + + def test_catalog_list_shows_discovery_only_guidance(self, tmp_path): + """A discovery-only catalog should trigger the trust-model guidance, + steering users to --from / their own catalog and away from flipping + install_allowed.""" + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "extension-catalogs.yml").write_text( + yaml.safe_dump( + { + "catalogs": [ + { + "name": "community", + "url": "https://example.com/catalog.json", + "priority": 10, + "install_allowed": False, + } + ] + } + ), + encoding="utf-8", + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "catalog", "list"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + output = " ".join(result.output.split()) + assert "not installable by design" in output + assert "--from " in output + assert "Don't flip a discovery-only catalog to install_allowed" in output + + def test_catalog_list_omits_guidance_when_all_installable(self, tmp_path): + """When every catalog is an install source, the discovery-only guidance + should not appear.""" + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "extension-catalogs.yml").write_text( + yaml.safe_dump( + { + "catalogs": [ + { + "name": "my-org", + "url": "https://example.com/catalog.json", + "priority": 10, + "install_allowed": True, + } + ] + } + ), + encoding="utf-8", + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "catalog", "list"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert "not installable by design" not in result.output diff --git a/tests/specify_cli/extensions/catalog/test_command_remove.py b/tests/specify_cli/extensions/catalog/test_command_remove.py new file mode 100644 index 0000000000..a66891a121 --- /dev/null +++ b/tests/specify_cli/extensions/catalog/test_command_remove.py @@ -0,0 +1,75 @@ +"""Tests for ``specify extension catalog remove``. + +Mirrors ``specify_cli.extensions.catalog.command_remove``. +""" + +from __future__ import annotations + +import os + +import yaml + +from tests.integrations.test_cli import _normalize_cli_output + + +class TestExtensionCatalogRemoveCLI: + """Integration coverage for the extension catalog command.""" + + def _make_project(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + return project + + 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_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path): + project = self._make_project(tmp_path) + cfg_path = project / ".specify" / "extension-catalogs.yml" + cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + result = self._invoke(["extension", "catalog", "remove", "demo"], project) + + assert result.exit_code == 1, result.output + output = _normalize_cli_output(result.output) + assert "Invalid catalog config .specify/extension-catalogs.yml" in output + assert "expected a YAML mapping at the root" in output + assert "AttributeError" not in output + + def test_extension_catalog_remove_escapes_catalog_name_markup(self, tmp_path): + project = self._make_project(tmp_path) + catalog_name = "[red]demo[/red]" + cfg_path = project / ".specify" / "extension-catalogs.yml" + cfg_path.write_text( + yaml.safe_dump( + { + "catalogs": [ + { + "name": catalog_name, + "url": "https://example.com/extension-catalog.yml", + "priority": 10, + "install_allowed": False, + "description": "", + } + ] + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + result = self._invoke(["extension", "catalog", "remove", catalog_name], project) + + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + assert f"Removed catalog '{catalog_name}'" in output diff --git a/tests/specify_cli/extensions/conftest.py b/tests/specify_cli/extensions/conftest.py new file mode 100644 index 0000000000..98b24fe60f --- /dev/null +++ b/tests/specify_cli/extensions/conftest.py @@ -0,0 +1,89 @@ +"""Shared fixtures for the mirrored extension command tests.""" +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_manifest_data(): + """Return valid extension manifest data.""" + return { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "description": "A test extension", + "author": "Test Author", + "repository": "https://github.com/test/test-ext", + "license": "MIT", + }, + "requires": { + "speckit_version": ">=0.1.0", + "commands": ["speckit.tasks"], + }, + "provides": { + "commands": [ + { + "name": "speckit.test-ext.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + "hooks": { + "after_tasks": { + "command": "speckit.test-ext.hello", + "optional": True, + "prompt": "Run test?", + } + }, + "tags": ["testing", "example"], + } + + +@pytest.fixture +def extension_dir(temp_dir, valid_manifest_data): + """Create a complete extension directory structure.""" + ext_dir = temp_dir / "test-ext" + ext_dir.mkdir() + + with open(ext_dir / "extension.yml", "w") as manifest_file: + yaml.dump(valid_manifest_data, manifest_file) + + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + (commands_dir / "hello.md").write_text( + """--- +description: "Test hello command" +--- + +# Test Hello Command + +$ARGUMENTS +""" + ) + + return ext_dir + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project directory.""" + project = temp_dir / "project" + project.mkdir() + (project / ".specify").mkdir() + return project diff --git a/tests/specify_cli/extensions/test_command_add.py b/tests/specify_cli/extensions/test_command_add.py new file mode 100644 index 0000000000..cddcd6bf21 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_add.py @@ -0,0 +1,1270 @@ +"""Tests for ``specify extension add``. + +Mirrors ``specify_cli.extensions.command_add``. +""" + +from __future__ import annotations + +import io +import json +import os +import stat +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + CompatibilityError, + ExtensionCatalog, + ExtensionError, + ExtensionManager, + ExtensionRegistry, + ValidationError, +) +from tests.conftest import strip_ansi +from tests.specify_cli.extensions._helpers import ( + MINIMAL_ZIP_BYTES as _MINIMAL_ZIP_BYTES, + can_create_symlink, + open_test_download_zip as _open_test_download_zip, + validate_safe_cache_dir as _validate_safe_cache_dir_test_stand_in, +) + + +class TestExtensionAddCLI: + """CLI tests for ``specify extension add``.""" + + def test_add_dev_links_copilot_agent_when_supported( + self, extension_dir, project_dir, temp_dir + ): + """extension add --dev should link generated agent files when possible.""" + from specify_cli import app + + (project_dir / ".github" / "agents").mkdir(parents=True) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + + agent_file = ( + project_dir + / ".github" + / "agents" + / "speckit.test-ext.hello.agent.md" + ) + assert agent_file.exists() + if can_create_symlink(temp_dir): + assert agent_file.is_symlink() + assert ".specify-dev" in agent_file.resolve().parts + else: + assert not agent_file.is_symlink() + + @pytest.mark.skipif( + os.name == "nt", reason="POSIX execute bits are not meaningful on Windows" + ) + def test_add_makes_shipped_scripts_executable(self, extension_dir, project_dir): + """extension add must restore execute bits on bundled POSIX scripts. + + Archives are unpacked with zipfile.extractall and --dev installs copy the + tree; neither restores a stripped Unix mode, so a shipped *.sh can land + non-executable and a documented `.specify/extensions//scripts/...` + invocation then fails with "Permission denied". init / migrate / + integration-install already call ensure_executable_scripts(); this guards + that `extension add` does too. + """ + + scripts_dir = extension_dir / "scripts" + scripts_dir.mkdir() + script = scripts_dir / "gate.sh" + script.write_text("#!/usr/bin/env bash\necho hi\n") + script.chmod(0o644) # non-executable, as an unpacked/copied script may be + assert not os.access(script, os.X_OK) + + 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, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + installed = ( + project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "gate.sh" + ) + assert installed.exists(), result.output + assert os.access(installed, os.X_OK), ( + f"installed script not executable: mode=" + f"{stat.S_IMODE(installed.stat().st_mode):o}" + ) + + def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir): + """Codex dev skills should be written as files so Codex can load them.""" + from specify_cli import app + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text( + json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + assert skill_file.exists() + assert not skill_file.is_symlink() + + content = skill_file.read_text(encoding="utf-8") + assert "name: speckit-test-ext-hello" in content + assert "metadata:" in content + assert "source: test-ext:commands/hello.md" in content + + def test_add_dev_replaces_existing_codex_skill_symlink( + self, extension_dir, project_dir, temp_dir + ): + """Codex dev installs should migrate expected dev symlinks to files.""" + if not can_create_symlink(temp_dir): + pytest.skip("Current platform/user cannot create symlinks") + + from specify_cli import app + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text( + json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" + ) + + skill_file = ( + project_dir + / ".agents" + / "skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + skill_file.parent.mkdir(parents=True) + cache_file = ( + extension_dir + / ".specify-dev" + / "extension-skills" + / "speckit-test-ext-hello" + / "SKILL.md" + ) + cache_file.parent.mkdir(parents=True) + cache_file.write_text("old linked content", encoding="utf-8") + os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert skill_file.exists() + assert not skill_file.is_symlink() + content = skill_file.read_text(encoding="utf-8") + assert "name: speckit-test-ext-hello" in content + assert "source: test-ext:commands/hello.md" in content + assert cache_file.read_text(encoding="utf-8") == "old linked content" + + def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable( + self, extension_dir, project_dir, monkeypatch + ): + """extension add --dev should work when Windows cannot create symlinks.""" + from specify_cli import app + + (project_dir / ".github" / "agents").mkdir(parents=True) + + def raise_windows_symlink_error(target, link): + raise OSError("A required privilege is not held by the client") + + monkeypatch.setattr( + "specify_cli.agents.os.symlink", raise_windows_symlink_error + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", str(extension_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + + agent_file = ( + project_dir + / ".github" + / "agents" + / "speckit.test-ext.hello.agent.md" + ) + assert agent_file.exists() + assert not agent_file.is_symlink() + assert "Extension: test-ext" in agent_file.read_text(encoding="utf-8") + assert ( + project_dir + / ".specify" + / "extensions" + / "test-ext" + / ".specify-dev" + / "agent-commands" + / "copilot" + / "speckit.test-ext.hello.agent.md" + ).exists() + + def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path): + """extension add by display name should use resolved ID for download_extension().""" + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns extension by display name + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = None # ID lookup fails + mock_catalog.search.return_value = [ + { + "id": "acme-jira-integration", + "name": "Jira Integration", + "version": "1.0.0", + "description": "Jira integration extension", + "_install_allowed": True, + } + ] + + # Track what ID was passed to download_extension + download_called_with = [] + def mock_download(extension_id): + download_called_with.append(extension_id) + # Return a path that will fail install (we just want to verify the ID) + raise ExtensionError("Mock download - checking ID was resolved") + + mock_catalog.download_extension.side_effect = mock_download + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "Jira Integration"], + catch_exceptions=True, + ) + + assert result.exit_code != 0, ( + f"Expected non-zero exit code since mock download raises, got {result.exit_code}" + ) + + # Verify download_extension was called with the resolved ID, not the display name + assert len(download_called_with) == 1 + assert download_called_with[0] == "acme-jira-integration", ( + f"Expected download_extension to be called with resolved ID 'acme-jira-integration', " + f"but was called with '{download_called_with[0]}'" + ) + + def test_catalog_add_forwards_catalog_name(self, tmp_path): + """The extension catalog branch passes resolved provenance to the manager.""" + from typer.testing import CliRunner + from specify_cli import app + + project_dir = tmp_path / "project" + (project_dir / ".specify").mkdir(parents=True) + archive = tmp_path / "extension.zip" + archive.write_bytes(b"archive") + captured = {} + + def fake_install_from_zip(self, _archive, _version, **kwargs): + captured.update(kwargs) + return SimpleNamespace( + id="catalog-extension", + name="Catalog Extension", + version="1.0.0", + description="catalog extension", + warnings=[], + commands=[], + ) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "catalog-extension", + "name": "Catalog Extension", + "version": "1.0.0", + "_install_allowed": True, + "_catalog_name": "extension-catalog", + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=archive), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ + patch("specify_cli.extensions._commands._refresh_events_and_warn"): + result = CliRunner().invoke(app, ["extension", "add", "catalog-extension"]) + + assert result.exit_code == 0, result.output + assert captured["catalog_name"] == "extension-catalog" + + def test_add_discovery_only_error_suggests_resolved_id(self, tmp_path): + """The not-installable error must suggest a copy-pasteable command using + the resolved catalog ID, not a display name that may contain spaces.""" + from specify_cli import app + + runner = CliRunner() + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = None # ID lookup fails + mock_catalog.search.return_value = [ + { + "id": "acme-jira-integration", + "name": "Jira Integration", + "version": "1.0.0", + "description": "Jira integration extension", + "_install_allowed": False, + "_catalog_name": "community", + } + ] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "Jira Integration"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + output = " ".join(result.output.split()) + # Suggested command uses the resolved ID and stays a single token. + assert "add acme-jira-integration --from" in output + # It must not emit the space-containing display name as the command target. + assert "add Jira Integration --from" not in output + + def test_add_discovery_only_error_neutralizes_unsafe_id(self, tmp_path): + """A catalog-controlled ID with shell metacharacters must never be + interpolated into the suggested command; it is replaced by a literal + placeholder so copying the command can't execute injected shell text.""" + from specify_cli import app + + runner = CliRunner() + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + malicious_id = "foo; rm -rf ~" + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": malicious_id, + "name": "Evil Ext", + "version": "1.0.0", + "description": "malicious", + "_install_allowed": False, + "_catalog_name": "community", + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", malicious_id], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + output = " ".join(result.output.split()) + # The runnable command uses a literal placeholder, never the raw ID. + assert "add --from" in output + # The malicious ID is never rendered as the target of an install command. + assert f"add {malicious_id} --from" not in output + assert "add foo; rm" not in output + + def test_command_safe_id_rejects_leading_hyphen(self): + """An ID like ``--force`` matches the manifest character rule but Typer + would parse it as an option, not the positional extension argument, so + the helper must fall back to the placeholder.""" + from specify_cli.extensions._commands import _command_safe_id + + assert _command_safe_id("--force") == "" + assert _command_safe_id("-x") == "" + # A normal slug is still returned verbatim. + assert _command_safe_id("acme-thing") == "acme-thing" + + def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): + """extension add should give a clear error when a bundled extension is not found locally.""" + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns a bundled extension without download_url + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "git", + "name": "Git Branching Workflow", + "version": "1.0.0", + "description": "Git branching extension", + "bundled": True, + "_install_allowed": True, + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli._locate_bundled_extension", return_value=None), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "git"], + catch_exceptions=True, + ) + + assert result.exit_code != 0 + assert "bundled with spec-kit" in result.output + assert "reinstall" in result.output.lower() + + def test_add_from_url_prompts_before_spinner(self, tmp_path): + """Confirm prompt for --from must fire before the console.status spinner. + + Regression test for #2783: typer.confirm() inside console.status() + was overwritten by the Rich spinner, making the command appear hung. + """ + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + call_order: list[str] = [] + + original_status = MagicMock() + + def record_status(*args, **kwargs): + call_order.append("spinner") + return original_status + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.console.status", side_effect=record_status), \ + patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]): + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"], + catch_exceptions=True, + ) + + assert "confirm" in call_order, "confirm prompt was never called" + # The confirm must fire BEFORE the spinner is entered + if "spinner" in call_order: + assert call_order.index("confirm") < call_order.index("spinner"), \ + f"confirm must precede spinner, got: {call_order}" + assert result.exit_code == 0 # user declined → clean exit + + def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path): + """A malformed IPv6 URL must produce a clean error, not a ValueError traceback.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + plain = strip_ansi(result.output) + assert "Invalid URL" in plain + + @pytest.mark.parametrize( + "url", + [ + "https:///ext.zip", + "https://example.com:99999/ext.zip", + ], + ) + def test_add_from_invalid_url_exits_before_prompt(self, tmp_path, url): + """Hostless URLs and invalid ports fail before prompting or downloading.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm") as confirm, \ + patch("specify_cli.authentication.http.open_url") as open_url: + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", url], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert "Invalid URL" in strip_ansi(result.output) + confirm.assert_not_called() + open_url.assert_not_called() + + def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path): + """A bracketed-but-invalid IPv6 host must produce a clean error, not a + ValueError traceback. "https://[not-an-ip]/ext.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 specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert result.exception is None or isinstance(result.exception, SystemExit) + plain = strip_ansi(result.output) + assert "Invalid URL" in plain + + def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch): + """Synthetic defensive coverage: monkeypatch urlparse() to return an + object whose .hostname raises ValueError lazily. This does not reproduce + any specific CPython behavior -- it just exercises the case where the + ValueError surfaces on the .hostname read rather than at parse time, so a + raw ValueError would leak if .hostname were read outside the try/except. + """ + import urllib.parse + from specify_cli import app + + real_urlparse = urllib.parse.urlparse + + class _LazyHostnameRaiser: + def __init__(self, parsed): + self._parsed = parsed + + @property + def hostname(self): + raise ValueError("simulated lazy IPv6 hostname failure") + + def __getattr__(self, name): + return getattr(self._parsed, name) + + def _fake_urlparse(url, *args, **kwargs): + return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs)) + + monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse) + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://example.com/ext.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) + + def test_add_status_escapes_extension_markup(self, tmp_path): + """User-controlled extension names must not be parsed as Rich markup.""" + from rich.markup import escape as escape_markup + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + status_messages: list[str] = [] + + def record_status(message, *args, **kwargs): + status_messages.append(message) + return MagicMock() + + extension_name = "[red]bad[/red]" + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.console.status", side_effect=record_status): + result = runner.invoke( + app, + ["extension", "add", extension_name, "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert status_messages == [ + f"[cyan]Installing extension: {escape_markup(extension_name)}[/cyan]" + ] + + def test_add_post_install_hint_escapes_manifest_id_markup(self, tmp_path): + """Extension IDs printed in Rich-rendered hints must stay literal.""" + from types import SimpleNamespace + from typer.testing import CliRunner + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manifest_id = "[red]bad[/red]" + + def fake_install_from_zip( + self_obj, + zip_path, + speckit_version, + priority=10, + force=False, + *, + archive_file=None, + ): + return SimpleNamespace( + id=manifest_id, + name="Bad Extension", + version="1.0.0", + description="Test extension", + warnings=[], + commands=[], + hooks=[], + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ + patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ + patch.object(ExtensionRegistry, "get", return_value={}): + result = runner.invoke( + app, + ["extension", "add", "bad", "--from", "https://example.com/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert ".specify/extensions/[red]bad[/red]/" in result.output + + def test_add_from_url_cancel_exits_cleanly(self, tmp_path): + """Declining the --from confirmation should exit with code 0.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=False): + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0 + assert "Cancelled" in result.output + + def test_add_from_url_escapes_download_exception_markup(self, tmp_path): + """Download errors can include user-controlled URL text.""" + import urllib.error + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ + patch( + "specify_cli.authentication.http.open_url", + side_effect=urllib.error.URLError("bad [red]download[/red]"), + ): + result = runner.invoke( + app, + [ + "extension", + "add", + "my-ext", + "--from", + "https://example.com/[red]ext[/red].zip", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "https://example.com/[red]ext[/red].zip" in result.output + assert "bad [red]download[/red]" in result.output + + def test_add_from_url_rejects_non_zip_login_page(self, tmp_path): + """An HTML login page (unauthenticated fetch) must fail clearly, not BadZipFile.""" + from typer.testing import CliRunner + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ + patch( + "specify_cli.authentication.http.open_url", + return_value=FakeResponse(b"Sign in"), + ), \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://raw.ghe.example/o/r/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "did not return a ZIP archive" in result.output + install.assert_not_called() + + def test_add_from_url_rejects_oversized_download_before_install( + self, tmp_path, monkeypatch + ): + """The direct URL path must use the same bounded reader as catalogs.""" + + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.extensions import _commands as extension_commands + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def reject_oversized(*_args, **_kwargs): + raise ExtensionError("extension URL download exceeds maximum size") + + monkeypatch.setattr( + extension_commands, + "read_response_limited", + reject_oversized, + raising=False, + ) + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ + patch( + "specify_cli.authentication.http.open_url", + return_value=FakeResponse(_MINIMAL_ZIP_BYTES), + ), \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + [ + "extension", + "add", + "my-ext", + "--from", + "https://example.com/ext.zip", + ], + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert "exceeds maximum size" in result.output + install.assert_not_called() + + def test_add_from_url_resolves_ghes_release_asset(self, tmp_path): + """A GHES release-download URL resolves to /api/v3 with octet-stream Accept.""" + from types import SimpleNamespace + from typer.testing import CliRunner + from specify_cli import app + import json + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + seen = {} + + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + if "/releases/tags/" in url: + body = json.dumps({ + "assets": [{ + "name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42", + }] + }).encode() + return FakeResponse(body) + seen["url"] = url + seen["headers"] = extra_headers + return FakeResponse(_MINIMAL_ZIP_BYTES) + + def fake_install( + self_obj, + zip_path, + speckit_version, + priority=10, + force=False, + *, + archive_file=None, + ): + return SimpleNamespace( + id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[] + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ + patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \ + patch.object(ExtensionManager, "install_from_zip", fake_install): + result = runner.invoke( + app, + ["extension", "add", "x", "--from", + "https://ghes.example/org/repo/releases/download/v1.0/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert "/api/v3/repos/org/repo/releases/assets/" in seen["url"] + assert seen["headers"] == {"Accept": "application/octet-stream"} + + @pytest.mark.parametrize( + ("exc_type", "label"), + [ + (ValidationError, "Validation Error"), + (CompatibilityError, "Compatibility Error"), + (ExtensionError, "Error"), + ], + ) + def test_add_exception_handlers_escape_markup(self, tmp_path, exc_type, label): + """Extension install exceptions can include manifest-controlled values.""" + from specify_cli import app + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + (ext_dir / "extension.yml").write_text("extension:\n id: test\n", encoding="utf-8") + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object( + ExtensionManager, + "install_from_directory", + side_effect=exc_type("bad [red]extension[/red]"), + ): + result = runner.invoke( + app, + ["extension", "add", str(ext_dir), "--dev"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert f"{label}:" in result.output + assert "bad [red]extension[/red]" in result.output + + def test_add_from_url_uses_cache_tempfile_for_untrusted_extension_name(self, tmp_path): + """The extension argument must not control the downloaded ZIP path.""" + from types import SimpleNamespace + from typer.testing import CliRunner + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads" + installed = {} + + def fake_install_from_zip( + self_obj, + zip_path, + speckit_version, + priority=10, + force=False, + *, + archive_file=None, + ): + captured_path = Path(zip_path) + installed["zip_path"] = captured_path + installed["zip_bytes"] = archive_file.read() + archive_file.seek(0) + return SimpleNamespace( + id="escape", + name="Escape Test", + version="1.0.0", + description="Test extension", + warnings=[], + commands=[], + hooks=[], + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ + patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): + result = runner.invoke( + app, + ["extension", "add", "../outside", "--from", "https://example.com/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0 + assert installed["zip_bytes"] == _MINIMAL_ZIP_BYTES + assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve()) + assert installed["zip_path"].name.startswith("extension-url-download-") + assert not installed["zip_path"].exists() + + +class TestExtensionAddPriorityCLI: + """Priority option coverage for ``extension add``.""" + + def test_add_with_priority_option(self, extension_dir, project_dir): + """Test extension add command with --priority option.""" + from specify_cli import app + + runner = CliRunner() + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, [ + "extension", "add", str(extension_dir), "--dev", "--priority", "3" + ]) + + assert result.exit_code == 0, result.output + + manager = ExtensionManager(project_dir) + metadata = manager.registry.get("test-ext") + assert metadata["priority"] == 3 + + +class TestClineExtensionHyphenation: + """Test that Cline integration uses hyphenated commands and frontmatter references.""" + + def _setup_mock_extension(self, tmp_path, ai_name): + import json + + # 1. Setup mock project + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text(json.dumps({"ai": ai_name}), encoding="utf-8") + + if ai_name == "cline": + commands_dest_dir = project_dir / ".clinerules" / "workflows" + else: + commands_dest_dir = project_dir / ".agents" / "commands" + commands_dest_dir.mkdir(parents=True, exist_ok=True) + + # 2. Setup mock extension directory + ext_dir = tmp_path / "mock-ext" + ext_dir.mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "mock-ext", + "name": "Mock Extension", + "version": "1.0.0", + "description": f"Mock extension for {ai_name} tests", + "author": "Tester", + "repository": "https://github.com/test/mock-ext", + "license": "MIT", + }, + "requires": { + "speckit_version": ">=0.1.0", + }, + "provides": { + "commands": [ + { + "name": "speckit.mock-ext.hello", + "file": "commands/hello.md", + "description": "Test hello command", + "aliases": ["speckit.mock-ext.greet"] + } + ] + } + } + + with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f: + yaml.dump(manifest_data, f) + + commands_dir = ext_dir / "commands" + commands_dir.mkdir() + + # Command file with dotted speckit references in frontmatter and body + cmd_content = """--- +description: "Test hello command" +agent: speckit.tasks +handoffs: + - agent: speckit.iterate.start + message: "Hand off to start" +--- + +# Test Hello Command + +Please refer to speckit.mock-ext.greet for instructions. +$ARGUMENTS +""" + (commands_dir / "hello.md").write_text(cmd_content, encoding="utf-8") + + return project_dir, ext_dir, commands_dest_dir + + def test_cline_extension_hyphenation(self, tmp_path): + from specify_cli import app + from specify_cli.agents import CommandRegistrar + + project_dir, ext_dir, cline_workflows_dir = self._setup_mock_extension(tmp_path, "cline") + + # 3. Run specify extension add + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False + ) + + # Verify CLI printed hyphenated commands + # Note: We assert that the primary command 'speckit-mock-ext-hello' is printed, + # but we do not assert that the alias 'speckit-mock-ext-greet' is printed in the console + # because manifest.commands only lists primary commands. + assert "speckit-mock-ext-hello" in result.output + assert "speckit.mock-ext.hello" not in result.output + + # Verify on-disk command names are hyphenated + hello_file = cline_workflows_dir / "speckit-mock-ext-hello.md" + greet_file = cline_workflows_dir / "speckit-mock-ext-greet.md" + + assert hello_file.exists() + assert greet_file.exists() + + # Verify frontmatter in the generated files is recursively hyphenated + hello_text = hello_file.read_text(encoding="utf-8") + hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text) + assert hello_fm["agent"] == "speckit-tasks" + assert hello_fm["handoffs"][0]["agent"] == "speckit-iterate-start" + + # Verify body references are hyphenated for Cline + assert "speckit-mock-ext-greet" in hello_body + assert "speckit.mock-ext.greet" not in hello_body + + def test_non_cline_extension_no_hyphenation(self, tmp_path): + from specify_cli import app + from specify_cli.agents import CommandRegistrar + + project_dir, ext_dir, agents_commands_dir = self._setup_mock_extension(tmp_path, "amp") + + # 3. Run specify extension add + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False + ) + + # Verify CLI printed dotted commands + # Note: We assert that the primary command 'speckit.mock-ext.hello' is printed, + # but we do not assert that the alias 'speckit.mock-ext.greet' is printed in the console + # because manifest.commands only lists primary commands. + assert "speckit.mock-ext.hello" in result.output + assert "speckit-mock-ext-hello" not in result.output + + # Verify on-disk command names are dotted + hello_file = agents_commands_dir / "speckit.mock-ext.hello.md" + greet_file = agents_commands_dir / "speckit.mock-ext.greet.md" + + assert hello_file.exists() + assert greet_file.exists() + + # Verify frontmatter references are still dotted + hello_text = hello_file.read_text(encoding="utf-8") + hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text) + assert hello_fm["agent"] == "speckit.tasks" + assert hello_fm["handoffs"][0]["agent"] == "speckit.iterate.start" + + # Verify body references are still dotted for non-Cline + assert "speckit.mock-ext.greet" in hello_body + assert "speckit-mock-ext-greet" not in hello_body + + +class TestExtensionForceCLI: + """CLI tests for `specify extension add --dev --force`.""" + + def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path: + """Create a minimal extension directory with manifest.""" + + ext_dir = Path(base_dir) / ext_id + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "commands").mkdir() + + manifest = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Test Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest)) + (ext_dir / "commands" / "hello.md").write_text( + "---\ndescription: Test\n---\n\nHello $ARGUMENTS\n" + ) + return ext_dir + + def test_add_dev_force_reinstall(self, tmp_path): + """extension add --dev --force should reinstall without error.""" + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + ext_src = self._create_minimal_extension(tmp_path) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + # First install + result1 = runner.invoke( + app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False + ) + assert result1.exit_code == 0, strip_ansi(result1.output) + assert "installed" in strip_ansi(result1.output) + + # Force reinstall + result2 = runner.invoke( + app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False + ) + assert result2.exit_code == 0, strip_ansi(result2.output) + assert "installed" in strip_ansi(result2.output) + + +def test_forge_extension_install_listing_hyphenates_command_names( + extension_dir, project_dir +): + """The post-install 'Provided commands' listing must show hyphenated + /speckit- command names for a Forge project (Forge registers + hyphenated names), mirroring the existing Cline handling.""" + import json + import os + + + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text(json.dumps({"ai": "forge", "script": "sh"})) + + old_cwd = os.getcwd() + try: + os.chdir(project_dir) + result = CliRunner().invoke( + app, ["extension", "add", str(extension_dir), "--dev"] + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + # Forge registers hyphenated command names, so the summary must match. + assert "speckit-test-ext-hello" in result.output + assert "speckit.test-ext.hello" not in result.output diff --git a/tests/test_extension_add_path_traversal.py b/tests/specify_cli/extensions/test_command_add_path_traversal.py similarity index 98% rename from tests/test_extension_add_path_traversal.py rename to tests/specify_cli/extensions/test_command_add_path_traversal.py index 53f7ac19ab..c2243ec9f0 100644 --- a/tests/test_extension_add_path_traversal.py +++ b/tests/specify_cli/extensions/test_command_add_path_traversal.py @@ -1,4 +1,7 @@ -"""Security tests for the extension URL download cache.""" +"""Path-traversal tests supplementing ``specify extension add`` coverage. + +The primary command tests live in ``test_command_add.py``. +""" from __future__ import annotations diff --git a/tests/specify_cli/extensions/test_command_disable.py b/tests/specify_cli/extensions/test_command_disable.py new file mode 100644 index 0000000000..c790c3b184 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_disable.py @@ -0,0 +1,55 @@ +"""Tests for ``specify extension disable``. + +Mirrors ``specify_cli.extensions.command_disable``. +""" + +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.extensions import ( + ExtensionManager, + ExtensionRegistry, + HookExecutor, +) + + +class TestExtensionDisableCLI: + """CLI tests for ``specify extension disable``.""" + + def test_disable_reenable_hint_escapes_extension_id_markup(self, tmp_path): + """Disable success hints should not parse extension IDs as markup.""" + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + extension_id = "[red]bad[/red]" + installed = [ + { + "id": extension_id, + "name": "Bad Extension", + "version": "1.0.0", + "description": "Test extension", + "enabled": True, + } + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionManager, "list_installed", return_value=installed), \ + patch.object(ExtensionRegistry, "get", return_value={"enabled": True}), \ + patch.object(ExtensionRegistry, "update", return_value=None), \ + patch.object(HookExecutor, "get_project_config", return_value={}): + result = runner.invoke( + app, + ["extension", "disable", extension_id], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert "specify extension enable [red]bad[/red]" in result.output diff --git a/tests/specify_cli/extensions/test_command_enable.py b/tests/specify_cli/extensions/test_command_enable.py new file mode 100644 index 0000000000..3543917af0 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_enable.py @@ -0,0 +1,52 @@ +"""Tests for ``specify extension enable``. + +Mirrors ``specify_cli.extensions.command_enable``. +""" + +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.extensions import ( + ExtensionManager, + ExtensionRegistry, +) + + +class TestExtensionEnableCLI: + """CLI tests for ``specify extension enable``.""" + + def test_enable_registry_error_escapes_extension_id_markup(self, tmp_path): + """Registry-corruption errors should render extension IDs literally.""" + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + extension_id = "[red]bad[/red]" + installed = [ + { + "id": extension_id, + "name": "Bad Extension", + "version": "1.0.0", + "description": "Test extension", + "enabled": False, + } + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionManager, "list_installed", return_value=installed), \ + patch.object(ExtensionRegistry, "get", return_value=None): + result = runner.invoke( + app, + ["extension", "enable", extension_id], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "Extension '[red]bad[/red]' not found in registry" in result.output diff --git a/tests/specify_cli/extensions/test_command_info.py b/tests/specify_cli/extensions/test_command_info.py new file mode 100644 index 0000000000..696461bbc2 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_info.py @@ -0,0 +1,241 @@ +"""Tests for ``specify extension info``. + +Mirrors ``specify_cli.extensions.command_info``. +""" + +from __future__ import annotations + +import io +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + ExtensionManager, +) + + +class TestExtensionInfoRendering: + """Rendering tests for ``specify extension info``.""" + + @pytest.mark.parametrize( + "downloads", + [ + "1500", # plain string: crashed the ``:,`` format + "[/red]foo", # unbalanced closing tag: raises MarkupError unescaped + "[bold]x[/bold]", # balanced tags: would silently restyle the output + ], + ) + def test_info_renders_non_numeric_downloads(self, downloads): + """A non-numeric ``downloads`` from an untrusted catalog must not crash the + info renderer — neither with 'Cannot specify ',' with 's'' (the ``:,`` + format) nor with a Rich MarkupError (the joined stats are markup).""" + from specify_cli.extensions._commands import _print_extension_info + + manager = MagicMock() + manager.registry.is_installed.return_value = False + ext_info = { + "name": "Jira", "id": "jira", "version": "1.0.0", + "description": "desc", "downloads": downloads, # from catalog JSON + } + # Must not raise ValueError or rich.errors.MarkupError. + _print_extension_info(ext_info, manager) + + def test_info_renders_markup_bearing_stars(self): + """``stars`` sits in the same joined stats string as ``downloads`` and is + equally catalog-controlled, so it must be escaped too.""" + from specify_cli.extensions._commands import _print_extension_info + + manager = MagicMock() + manager.registry.is_installed.return_value = False + ext_info = { + "name": "Jira", "id": "jira", "version": "1.0.0", + "description": "desc", "stars": "[/red]x", + } + _print_extension_info(ext_info, manager) # must not raise MarkupError + + +class TestExtensionInfoCLI: + """CLI tests for ``specify extension info``.""" + + def test_info_discovery_only_shows_candidate_archive_url(self, tmp_path): + """For a discovery-only entry that carries a ``download_url``, ``info`` + surfaces the candidate archive URL (flagged for vetting) and the vetted + ``--from`` install guidance, so users have a CLI path to the URL.""" + from unittest.mock import MagicMock + + runner = CliRunner() + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + archive_url = "https://example.com/acme-thing-1.0.0.zip" + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "acme-thing", + "name": "Acme Thing", + "version": "1.0.0", + "description": "A thing", + "download_url": archive_url, + "_install_allowed": False, + "_catalog_name": "community", + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli.extensions.ExtensionManager") as mock_mgr, \ + patch.object(Path, "cwd", return_value=project_dir): + mock_mgr.return_value.registry.is_installed.return_value = False + result = runner.invoke( + app, + ["extension", "info", "acme-thing"], + catch_exceptions=True, + ) + + output = " ".join(result.output.split()) + assert "discovery-only" in output + assert f"Candidate archive (vet before installing): {archive_url}" in output + assert "specify extension add acme-thing --from " in output + + def test_info_discovery_only_without_url_falls_back(self, tmp_path): + """A discovery-only entry lacking ``download_url`` still gets vetted + ``--from`` guidance, without claiming a candidate archive it doesn't + have.""" + from unittest.mock import MagicMock + + runner = CliRunner() + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "acme-thing", + "name": "Acme Thing", + "version": "1.0.0", + "description": "A thing", + "_install_allowed": False, + "_catalog_name": "community", + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli.extensions.ExtensionManager") as mock_mgr, \ + patch.object(Path, "cwd", return_value=project_dir): + mock_mgr.return_value.registry.is_installed.return_value = False + result = runner.invoke( + app, + ["extension", "info", "acme-thing"], + catch_exceptions=True, + ) + + output = " ".join(result.output.split()) + assert "Candidate archive" not in output + assert "vetted its release archive" in output + assert "specify extension add acme-thing --from " in output + + def test_info_by_name_tolerates_non_string_catalog_name(self, tmp_path): + """Display-name resolution must not crash on a non-string catalog name. + + Catalog JSON is user-editable, so ``catalog.search()`` may return an + entry whose ``name`` is a non-string (e.g. ``name: 123``). The + display-name filter calls ``.lower()`` on it; without coercion this + raises ``AttributeError`` and takes down ``extension info``/``add``. + The entry with the bad name must simply not match, yielding a clean + "not found" rather than a traceback. + """ + from unittest.mock import MagicMock + + runner = CliRunner() + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Catalog search returns an entry with a non-string name. + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = None # ID lookup fails + mock_catalog.search.return_value = [ + { + "id": "acme-thing", + "name": 123, + "version": "1.0.0", + "description": "A thing", + "_install_allowed": True, + } + ] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "info", "Some Name"], + catch_exceptions=True, + ) + + # Must not crash with AttributeError; the bad-named entry just doesn't + # match, so resolution ends as a clean not-found error exit. + assert not isinstance(result.exception, AttributeError), ( + f"non-string catalog name crashed resolution: {result.exception!r}" + ) + assert result.exit_code != 0 + + +def test_forge_extension_info_hyphenates_command_names( + extension_dir, project_dir, monkeypatch +): + """`extension info` for an installed extension must show hyphenated + /speckit- command names on a Forge project, matching the names Forge + actually registers — the same parity `extension add`'s listing already has. + """ + + from rich.console import Console + + from specify_cli.extensions import _commands + + init_options = project_dir / ".specify" / "init-options.json" + init_options.write_text(json.dumps({"ai": "forge", "script": "sh"})) + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "1.0.0", register_commands=False + ) + + # Force the "installed locally, not in catalog" branch (the one that prints + # the local manifest's Commands section) and avoid any network catalog + # lookup. + monkeypatch.setattr( + _commands, "_resolve_catalog_extension", lambda *a, **k: (None, None) + ) + + # Call the handler directly against a plain captured Console. (Driving it + # through CliRunner reformats output via Rich's live console, which + # recurses under pytest's captured stdout — unrelated to this code path.) + buf = io.StringIO() + original_console = _commands.console + _commands.console = Console(file=buf, force_terminal=False, width=200) + old_cwd = os.getcwd() + try: + os.chdir(project_dir) + _commands.extension_info("test-ext") + except SystemExit: + pass + finally: + os.chdir(old_cwd) + _commands.console = original_console + + output = buf.getvalue() + # The Commands section must render the hyphenated form Forge registers, + # not the manifest's dotted name. + assert "speckit-test-ext-hello" in output, output + assert "speckit.test-ext.hello" not in output, output diff --git a/tests/specify_cli/extensions/test_command_list.py b/tests/specify_cli/extensions/test_command_list.py new file mode 100644 index 0000000000..ebc253fbb7 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_list.py @@ -0,0 +1,110 @@ +"""Tests for ``specify extension list``. + +Mirrors ``specify_cli.extensions.command_list``. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +import specify_cli.extensions as extensions +from specify_cli.extensions import ( + ExtensionManager, +) +from tests.conftest import strip_ansi + + +class TestExtensionListCLI: + """Test extension list CLI output format.""" + + def test_list_shows_extension_id(self, extension_dir, project_dir): + """extension list should display the extension ID.""" + + runner = CliRunner() + + # Install the extension using the manager + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "list"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + # Verify the extension ID is shown in the output + assert "test-ext" in plain + # Verify name and version are also shown + assert "Test Extension" in plain + assert "1.0.0" in plain + + +class TestExtensionListPriorityCLI: + """Priority display coverage for ``extension list``.""" + + def test_list_shows_priority(self, extension_dir, project_dir): + """Test extension list shows priority.""" + + runner = CliRunner() + + # Install extension with priority + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "list"]) + + assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) + assert "Priority: 7" in plain + + +def test_list_resolves_package_symbols_at_invocation( + monkeypatch, project_dir +): + """Extracting the handler must preserve package-level patch seams.""" + calls = [] + + class PatchedManager: + def __init__(self, project_root): + assert project_root == project_dir + + def list_installed(self): + return [ + { + "id": "patched-ext", + "name": "Patched Extension", + "description": "Patched", + "version": "1.0.0", + "_json_author": {"name": "Test"}, + "priority": 23, + "enabled": True, + "_json_source": {"kind": "local"}, + "_json_provides": { + "commands": 0, + "templates": 0, + "scripts": 0, + "hooks": 0, + }, + } + ] + + def patched_normalize_priority(value): + calls.append(value) + return value + + monkeypatch.setattr(extensions, "ExtensionManager", PatchedManager) + monkeypatch.setattr( + extensions, "normalize_priority", patched_normalize_priority + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "list", "--json"]) + + assert result.exit_code == 0, result.output + assert '"id": "patched-ext"' in result.output + assert calls == [23] diff --git a/tests/specify_cli/extensions/test_command_remove.py b/tests/specify_cli/extensions/test_command_remove.py new file mode 100644 index 0000000000..a7a340b401 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_remove.py @@ -0,0 +1,102 @@ +"""Tests for ``specify extension remove``. + +Mirrors ``specify_cli.extensions.command_remove``. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + ExtensionManager, + ExtensionRegistry, +) + + +class TestExtensionRemoveCLI: + """CLI tests for `specify extension remove` confirmation prompt wording.""" + + def _install_ext(self, project_dir, ext_dir): + """Install extension and return the manager.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + return manager + + def test_remove_confirmation_singular_command(self, tmp_path, extension_dir): + """Confirmation prompt should say '1 command' (singular) when one command registered.""" + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 1 entry so cmd_count == 1 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "1 command" in result.output + assert "1 commands" not in result.output + + def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): + """Confirmation prompt should say '2 commands' (plural) when two commands registered.""" + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 2 entries so cmd_count == 2 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "2 commands" in result.output + + def test_remove_output_escapes_extension_id_markup(self, tmp_path): + """Removal paths and reinstall hints must not parse extension IDs as markup.""" + from typer.testing import CliRunner + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + extension_id = "[red]bad[/red]" + installed = [ + { + "id": extension_id, + "name": "Bad Extension", + "version": "1.0.0", + "description": "Test extension", + "enabled": True, + } + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionManager, "list_installed", return_value=installed), \ + patch.object(ExtensionManager, "get_extension", return_value=SimpleNamespace(commands=[])), \ + patch.object(ExtensionRegistry, "get", return_value={"registered_commands": {}, "registered_skills": []}), \ + patch.object(ExtensionManager, "remove", return_value=True): + result = runner.invoke( + app, + ["extension", "remove", extension_id, "--force"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert ".specify/extensions/.backup/[red]bad[/red]/" in result.output + assert "specify extension add [red]bad[/red]" in result.output diff --git a/tests/specify_cli/extensions/test_command_search.py b/tests/specify_cli/extensions/test_command_search.py new file mode 100644 index 0000000000..c2e8e949f9 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_search.py @@ -0,0 +1,102 @@ +"""Tests for ``specify extension search``. + +Mirrors ``specify_cli.extensions.command_search``. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + ExtensionCatalog, +) + + +class TestExtensionSearchCLI: + """CLI tests for ``specify extension search``.""" + + @pytest.mark.parametrize("downloads", ["1500", "[/red]foo"]) + def test_search_survives_non_numeric_downloads(self, temp_dir, downloads): + """`specify extension search` must not abort when a catalog entry's + ``downloads`` is a non-numeric string — not with a raw ValueError from the + ``:,`` format, nor with a Rich MarkupError from unescaped markup.""" + import yaml as yaml_module + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + config_path = project_dir / ".specify" / "extension-catalogs.yml" + with open(config_path, "w") as f: + yaml_module.dump( + {"catalogs": [{ + "name": "test-catalog", + "url": ExtensionCatalog.DEFAULT_CATALOG_URL, + "priority": 1, "install_allowed": True, + }]}, f, + ) + + catalog = ExtensionCatalog(project_dir) + catalog_data = { + "schema_version": "1.0", + "extensions": {"jira": { + "name": "Jira", "id": "jira", "version": "1.0.0", + "description": "Jira integration", "author": "x", + "tags": ["jira"], "verified": True, + "downloads": downloads, # non-numeric, straight from catalog JSON + }}, + } + catalog.cache_dir.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_text(json.dumps(catalog_data)) + catalog.cache_metadata_file.write_text(json.dumps({ + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": "http://test.com", + })) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "search"], catch_exceptions=True) + assert result.exit_code == 0, result.output + # Rendered literally (escaped), not interpreted as markup or dropped. + assert f"Downloads: {downloads}" in result.output + + def test_search_and_info_tolerate_non_list_tags(self, temp_dir): + """A scalar ``tags:`` value must not crash the search/info display. + + ``ExtensionCatalog.search`` guards its tag *filter* with + ``isinstance(raw_tags, list)``, but the ``extension search`` and + ``extension info`` display paths only tested truthiness before + iterating. ``tags: 5`` is truthy and not iterable, so both raised + ``TypeError: 'int' object is not iterable``. + """ + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + merged = [{ + "id": "jira", + "name": "Jira", + "version": "1.0.0", + "description": "Jira", + "tags": 5, + }] + + with patch.object(ExtensionCatalog, "_get_merged_extensions", return_value=merged), \ + patch("specify_cli.extensions._commands._require_specify_project", + return_value=project_dir): + searched = CliRunner().invoke(app, ["extension", "search", "Jira"]) + info = CliRunner().invoke(app, ["extension", "info", "jira"]) + + assert searched.exit_code == 0, searched.output + assert "Jira" in searched.output + assert "Tags:" not in searched.output + + assert info.exit_code == 0, info.output + assert "Tags:" not in info.output diff --git a/tests/specify_cli/extensions/test_command_set_priority.py b/tests/specify_cli/extensions/test_command_set_priority.py new file mode 100644 index 0000000000..e5cc56a3a4 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_set_priority.py @@ -0,0 +1,142 @@ +"""Tests for ``specify extension set-priority``. + +Mirrors ``specify_cli.extensions.command_set_priority``. +""" + +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.extensions import ( + ExtensionManager, +) +from tests.conftest import strip_ansi + + +class TestExtensionSetPriorityCLI: + """CLI tests for ``specify extension set-priority``.""" + + def test_set_priority_changes_priority(self, extension_dir, project_dir): + """Test set-priority command changes extension priority.""" + + runner = CliRunner() + + # Install extension with default priority + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Verify default priority + assert manager.registry.get("test-ext")["priority"] == 10 + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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 = ExtensionManager(project_dir) + assert manager2.registry.get("test-ext")["priority"] == 5 + + def test_set_priority_same_value_no_change(self, extension_dir, project_dir): + """Test set-priority with same value shows already set message.""" + + runner = CliRunner() + + # Install extension with priority 5 + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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, extension_dir, project_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). + """ + + runner = CliRunner() + + manager = ExtensionManager(project_dir) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False, priority=5 + ) + # Inject a corrupted boolean priority (True == 1). + manager.registry.update("test-ext", {"priority": True}) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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 = ExtensionManager(project_dir).registry.get("test-ext") + assert reloaded["priority"] == 1 + assert not isinstance(reloaded["priority"], bool) + + def test_set_priority_invalid_value(self, extension_dir, project_dir): + """Test set-priority rejects invalid priority values.""" + + runner = CliRunner() + + # Install extension + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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 extension.""" + + runner = CliRunner() + + # Ensure .specify exists + (project_dir / ".specify").mkdir(parents=True, exist_ok=True) + + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"]) + + assert result.exit_code == 1, result.output + assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower() + + def test_set_priority_by_display_name(self, extension_dir, project_dir): + """Test set-priority works with extension display name.""" + + runner = CliRunner() + + # Install extension + manager = ExtensionManager(project_dir) + manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) + + # Use display name "Test Extension" instead of ID "test-ext" + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"]) + + assert result.exit_code == 0, result.output + assert "priority changed" in result.output + + # Reload registry to see updated value + manager2 = ExtensionManager(project_dir) + assert manager2.registry.get("test-ext")["priority"] == 3 diff --git a/tests/specify_cli/extensions/test_command_update.py b/tests/specify_cli/extensions/test_command_update.py new file mode 100644 index 0000000000..c1d5f1743c --- /dev/null +++ b/tests/specify_cli/extensions/test_command_update.py @@ -0,0 +1,863 @@ +"""Tests for ``specify extension update``. + +Mirrors ``specify_cli.extensions.command_update``. +""" + +from __future__ import annotations + +import json +import shutil +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.extensions import ( + ExtensionCatalog, + ExtensionManager, + HookExecutor, +) + + +class TestExtensionUpdateCLI: + """CLI integration tests for extension update command.""" + + @staticmethod + def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path: + """Create a minimal extension source directory for install tests.""" + + ext_dir = base_dir / f"test-ext-{version}" + ext_dir.mkdir(parents=True, exist_ok=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.test-ext.hello", + "file": "commands/hello.md", + "description": "Test command", + } + ] + }, + "hooks": { + "after_tasks": { + "command": "speckit.test-ext.hello", + "optional": True, + } + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) + commands_dir = ext_dir / "commands" + commands_dir.mkdir(exist_ok=True) + (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") + if include_config: + (ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n") + return ext_dir + + @staticmethod + def _create_catalog_zip( + zip_path: Path, + version: str, + manifest_path: str = "extension.yml", + extra_manifest_path: str | None = None, + ): + """Create a minimal ZIP that passes extension_update ID validation.""" + import zipfile + + manifest = { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": version, + "description": "A test extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{"name": "speckit.test-ext.hello", "file": "commands/hello.md"}]}, + } + + with zipfile.ZipFile(zip_path, "w") as zf: + manifest_text = yaml.dump(manifest, sort_keys=False) + zf.writestr(manifest_path, manifest_text) + if extra_manifest_path is not None: + zf.writestr(extra_manifest_path, manifest_text) + + @pytest.mark.parametrize( + "manifest_path", + [ + "../extension.yml", + "/extension.yml", + "./extension.yml", + "C:/extension.yml", + ], + ) + def test_update_rejects_unsafe_manifest_path_before_removal( + self, tmp_path, manifest_path + ): + """Unsafe manifest paths fail before the installed extension is removed.""" + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory( + v1_dir, "0.1.0", catalog_name="previous-catalog" + ) + installed_extension_dir = manager.extensions_dir / "test-ext" + removed_paths = [] + real_rmtree = shutil.rmtree + + def track_rmtree(path, *args, **kwargs): + removed_paths.append(Path(path).resolve()) + return real_rmtree(path, *args, **kwargs) + + zip_path = tmp_path / "unsafe-manifest.zip" + self._create_catalog_zip( + zip_path, + "2.0.0", + manifest_path=manifest_path, + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object( + ExtensionCatalog, + "download_extension", + return_value=zip_path, + ), \ + patch.object(shutil, "rmtree", side_effect=track_rmtree), \ + patch.object(ExtensionManager, "remove") as remove, \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert "Unsafe path in ZIP archive" in result.output + remove.assert_not_called() + install.assert_not_called() + assert installed_extension_dir.resolve() not in removed_paths + assert not list( + (manager.extensions_dir / ".backup").glob( + "update-*-*" + ) + ) + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + @pytest.mark.parametrize( + ("first_path", "second_path"), + [ + ("repo/extension.yml", "repo\\extension.yml"), + ("repo/extension.yml", "repo/EXTENSION.YML"), + ("caf\u00e9/extension.yml", "cafe\u0301/extension.yml"), + ], + ) + def test_update_rejects_normalized_manifest_collision_before_removal( + self, tmp_path, first_path, second_path + ): + """Pre-scan and extraction must agree on the manifest identity.""" + import zipfile + + from typer.testing import CliRunner + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + valid_manifest = yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + }, + } + ) + injected_manifest = yaml.safe_dump( + { + "schema_version": "1.0", + "extension": { + "id": "injected", + "name": "Injected", + "version": "2.0.0", + }, + } + ) + zip_path = tmp_path / "manifest-collision.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr(first_path, valid_manifest) + zf.writestr(second_path, injected_manifest) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object( + ExtensionCatalog, + "download_extension", + return_value=zip_path, + ), \ + patch.object(ExtensionManager, "remove") as remove, \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert "multiple extension.yml" in result.output + remove.assert_not_called() + install.assert_not_called() + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_preflights_entry_count_before_opening_zip( + self, tmp_path + ): + """Manifest inspection must not bypass the bounded ZIP opener.""" + import struct + + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + zip_path = tmp_path / "too-many.zip" + zip_path.write_bytes( + struct.pack( + "<4s4H2LH", + b"PK\x05\x06", + 0, + 0, + 513, + 513, + 0, + 0, + 0, + ) + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object( + ExtensionCatalog, + "download_extension", + return_value=zip_path, + ), \ + patch( + "specify_cli._download_security.zipfile.ZipFile", + side_effect=AssertionError("ZipFile constructor was called"), + ), \ + patch.object(ExtensionManager, "remove") as remove, \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 1 + assert "too many entries" in result.output + remove.assert_not_called() + install.assert_not_called() + + @pytest.mark.parametrize( + ("manifest_path", "extra_manifest_path"), + [ + ("extension.yml", None), + ("repo/extension.yml", None), + ("extension.yml", "repo/extension.yml"), + ], + ) + def test_update_success_preserves_installed_at( + self, tmp_path, manifest_path, extra_manifest_path + ): + """Successful update should keep original installed_at and apply new version.""" + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True) + manager.install_from_directory(v1_dir, "0.1.0") + original_installed_at = manager.registry.get("test-ext")["installed_at"] + original_config_content = ( + project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" + ).read_text() + + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip( + zip_path, + "2.0.0", + manifest_path=manifest_path, + extra_manifest_path=extra_manifest_path, + ) + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + def fake_install_from_zip( + self_obj, _zip_path, speckit_version, *, catalog_name=None + ): + return self_obj.install_from_directory( + v2_dir, speckit_version, catalog_name=catalog_name + ) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + "_catalog_name": "updated-catalog", + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ + patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): + result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) + + assert result.exit_code == 0, result.output + + updated = ExtensionManager(project_dir).registry.get("test-ext") + assert updated["version"] == "2.0.0" + assert updated["installed_at"] == original_installed_at + assert updated["source"] == { + "kind": "catalog", + "catalog": "updated-catalog", + } + restored_config_content = ( + project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" + ).read_text() + assert restored_config_content == original_config_content + + def test_update_installs_bundled_extension_from_local_copy(self, tmp_path): + """A bundled extension (no download URL) updates from the copy shipped + with the running spec-kit release instead of failing at download (#4345).""" + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v2.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0" + + def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path): + """When the catalog advertises a newer version than the running release + bundles, the update is reported as requiring a spec-kit upgrade instead + of being offered and then failing.""" + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v1_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v1.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert "All extensions are up to date!" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_bundled_blocked_when_local_copy_is_intermediate_version(self, tmp_path): + """A bundled copy newer than the installation but older than the + catalog must be blocked, not installed: an intermediate version would + leave the project lagging the catalog while reporting success.""" + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "3.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v2_dir + ), \ + patch.object( + ExtensionCatalog, + "download_extension", + side_effect=AssertionError("blocked bundled update must not download"), + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "only ships v2.0.0" in flat + assert "upgrade spec-kit" in flat + assert "Update these extensions?" not in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_installs_bundled_copy_newer_than_catalog(self, tmp_path): + """A dev/source checkout can ship a copy newer than the fetched + catalog advertises; the local copy is offered and installed.""" + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + v3_dir = self._create_extension_source(tmp_path, "3.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=v3_dir + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "Updated to v3.0.0" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "3.0.0" + + def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): + """A bundled catalog entry with no locally shipped copy points at a + spec-kit upgrade instead of failing the update at download time.""" + from specify_cli import app + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "bundled": True, + "_install_allowed": True, + }), \ + patch( + "specify_cli._locate_bundled_extension", return_value=None + ): + result = runner.invoke( + app, ["extension", "update", "test-ext"], catch_exceptions=True + ) + + flat = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "does not ship a local copy" in flat + assert "upgrade spec-kit" in flat + assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" + + def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch): + """Failed update should restore original registry, hooks, and command files.""" + from specify_cli import app + import yaml + + # Isolate home directory so Hermes' global ~/.hermes/skills/ doesn't + # interfere — without a real skills dir, Hermes is skipped during + # command registration, keeping the test focused on Claude/Codex/etc. + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory( + v1_dir, "0.1.0", catalog_name="original-catalog" + ) + + backup_registry_entry = manager.registry.get("test-ext") + hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) + + registered_commands = backup_registry_entry.get("registered_commands", {}) + command_files = [] + from specify_cli.agents import CommandRegistrar as AgentRegistrar + agent_registrar = AgentRegistrar() + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in agent_registrar.AGENT_CONFIGS: + continue + agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name] + commands_dir = AgentRegistrar._resolve_agent_dir( + agent_name, agent_cfg, project_dir + ) + for cmd_name in cmd_names: + output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg) + cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}" + command_files.append(cmd_path) + + assert command_files, "Expected at least one registered command file" + for cmd_file in command_files: + assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}" + + zip_path = tmp_path / "test-ext-update.zip" + self._create_catalog_zip(zip_path, "2.0.0") + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ + patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")): + result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) + + assert result.exit_code == 1, result.output + + restored_entry = ExtensionManager(project_dir).registry.get("test-ext") + assert restored_entry == backup_registry_entry + + hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) + assert hooks_after == hooks_before + + for cmd_file in command_files: + assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" + + def test_update_failure_after_skill_registration_restores_old_skills( + self, tmp_path, monkeypatch + ): + """Rollback must not depend on a new registry entry to restore skills.""" + import zipfile + + from typer.testing import CliRunner + from unittest.mock import patch + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + project_dir = tmp_path / "project" + project_dir.mkdir() + specify_dir = project_dir / ".specify" + specify_dir.mkdir() + copilot_agents_dir = project_dir / ".github" / "agents" + copilot_agents_dir.mkdir(parents=True) + (specify_dir / "init-options.json").write_text( + json.dumps( + { + "ai": "claude", + "ai_skills": True, + "script": "sh", + } + ), + encoding="utf-8", + ) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory( + v1_dir, + "0.1.0", + register_commands=False, + ) + + old_registry_entry = manager.registry.get("test-ext") + skills_dir = project_dir / ".claude" / "skills" + old_skill = skills_dir / "speckit-test-ext-hello" + old_skill_content = (old_skill / "SKILL.md").read_text(encoding="utf-8") + assert old_registry_entry["registered_skills"] == [old_skill.name] + new_skill = skills_dir / "speckit-test-ext-new" + new_skill.mkdir() + user_skill_content = ( + "---\n" + "name: user-new-skill\n" + "description: User-owned skill\n" + "metadata:\n" + " source: user\n" + "---\n\nUSER SKILL\n" + ) + (new_skill / "SKILL.md").write_text( + user_skill_content, + encoding="utf-8", + ) + user_support_file = new_skill / "support.txt" + user_support_file.write_text("USER CONTENT", encoding="utf-8") + + v2_dir = self._create_extension_source(tmp_path, "2.0.0") + manifest_path = v2_dir / "extension.yml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["provides"]["commands"].append( + { + "name": "speckit.test-ext.new", + "file": "commands/new.md", + "description": "New command", + } + ) + manifest["provides"]["commands"].append( + { + "name": "speckit.test-ext.fresh", + "file": "commands/fresh.md", + "description": "Fresh command", + } + ) + manifest_path.write_text( + yaml.safe_dump(manifest, sort_keys=False), + encoding="utf-8", + ) + (v2_dir / "commands" / "hello.md").write_text( + "---\ndescription: New hello\n---\n\nNEW HELLO\n", + encoding="utf-8", + ) + (v2_dir / "commands" / "new.md").write_text( + "---\ndescription: New command\n---\n\nNEW COMMAND\n", + encoding="utf-8", + ) + (v2_dir / "commands" / "fresh.md").write_text( + "---\ndescription: Fresh command\n---\n\nFRESH COMMAND\n", + encoding="utf-8", + ) + + zip_path = tmp_path / "test-ext-update.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + for source_path in v2_dir.rglob("*"): + if source_path.is_file(): + archive.write( + source_path, + source_path.relative_to(v2_dir), + ) + + def fail_after_skill_registration(self, manifest): + raise RuntimeError("Hook registration failed") + + runner = CliRunner() + with ( + patch.object(Path, "cwd", return_value=project_dir), + patch.object( + ExtensionCatalog, + "get_extension_info", + return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }, + ), + patch.object( + ExtensionCatalog, + "download_extension", + return_value=zip_path, + ), + patch.object( + HookExecutor, + "register_hooks", + fail_after_skill_registration, + ), + ): + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "Hook registration failed" in result.output + assert "Rollback successful" in result.output + assert ExtensionManager(project_dir).registry.get("test-ext") == old_registry_entry + assert (old_skill / "SKILL.md").read_text(encoding="utf-8") == old_skill_content + assert user_support_file.read_text(encoding="utf-8") == "USER CONTENT" + assert ( + new_skill / "SKILL.md" + ).read_text(encoding="utf-8") == user_skill_content + assert not (skills_dir / "speckit-test-ext-fresh").exists() + for command_name in ("hello", "new", "fresh"): + qualified_name = f"speckit.test-ext.{command_name}" + assert not ( + copilot_agents_dir / f"{qualified_name}.agent.md" + ).exists() + assert not ( + project_dir + / ".github" + / "prompts" + / f"{qualified_name}.prompt.md" + ).exists() + + @pytest.mark.parametrize( + ("manifest_text", "expected_detail"), + [ + ("- not\n- a\n- mapping\n", "YAML mapping"), + ("extension: []\n", "'extension' mapping"), + ], + ) + def test_update_rejects_malformed_zip_manifest( + self, tmp_path, monkeypatch, manifest_text, expected_detail + ): + """Downloaded extension.yml shape must be valid before ID validation.""" + from specify_cli import app + import zipfile + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + + runner = CliRunner() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".claude" / "skills").mkdir(parents=True) + + manager = ExtensionManager(project_dir) + v1_dir = self._create_extension_source(tmp_path, "1.0.0") + manager.install_from_directory(v1_dir, "0.1.0") + original_registry_entry = manager.registry.get("test-ext") + + zip_path = tmp_path / "bad-manifest.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + zf.writestr("extension.yml", manifest_text) + + with patch.object(Path, "cwd", return_value=project_dir), \ + patch.object(ExtensionCatalog, "get_extension_info", return_value={ + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "_install_allowed": True, + }), \ + patch.object(ExtensionCatalog, "download_extension", return_value=zip_path): + result = runner.invoke( + app, + ["extension", "update", "test-ext"], + input="y\n", + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "Invalid extension manifest in downloaded archive" in result.output + assert expected_detail in result.output + assert "AttributeError" not in result.output + assert ExtensionManager(project_dir).registry.get("test-ext") == original_registry_entry diff --git a/tests/test_extension_content_staleness.py b/tests/specify_cli/extensions/test_command_update_artifacts.py similarity index 97% rename from tests/test_extension_content_staleness.py rename to tests/specify_cli/extensions/test_command_update_artifacts.py index f76e041869..fa3381dceb 100644 --- a/tests/test_extension_content_staleness.py +++ b/tests/specify_cli/extensions/test_command_update_artifacts.py @@ -1,10 +1,11 @@ -"""Tests for the bundled-extension local update route (#4345). +"""Tests for ``_command_update_artifacts`` and the bundled update route. Bundled extensions have no download URL, so `specify extension update` installs them from the copy shipped with the running spec-kit release, packaged by `_archive_extension_directory` into the same hardened archive pipeline that downloaded updates use. These tests pin that -packaging step and its round trip through the archive installer. +packaging step and its round trip through the archive installer. The +primary command tests live in ``test_command_update.py``. """ from __future__ import annotations diff --git a/tests/specify_cli/extensions/test_command_update_discovery.py b/tests/specify_cli/extensions/test_command_update_discovery.py new file mode 100644 index 0000000000..f9c3897ac2 --- /dev/null +++ b/tests/specify_cli/extensions/test_command_update_discovery.py @@ -0,0 +1,102 @@ +"""Tests for ``_command_update_discovery``.""" +from __future__ import annotations + +from pathlib import Path + +from packaging import version as pkg_version + +from specify_cli.extensions import _commands +from specify_cli.extensions._command_update_discovery import discover_updates + + +class _Registry: + def __init__(self, metadata): + self.metadata = metadata + + def get(self, extension_id): + return self.metadata.get(extension_id) + + +class _Manager: + def __init__(self, installed, metadata): + self._installed = installed + self.registry = _Registry(metadata) + + def list_installed(self): + return self._installed + + +class _Catalog: + def __init__(self, entries): + self.entries = entries + + def get_extension_info(self, extension_id): + return self.entries.get(extension_id) + + +def test_discovery_reports_empty_installation(): + updates, blocked, has_installed = discover_updates( + _Manager([], {}), + _Catalog({}), + None, + ) + + assert updates == [] + assert blocked == [] + assert has_installed is False + + +def test_discovery_returns_typed_candidate(): + manager = _Manager( + [{"id": "test-ext"}], + {"test-ext": {"version": "1.0.0"}}, + ) + catalog = _Catalog( + { + "test-ext": { + "id": "test-ext", + "name": "Test Extension", + "version": "2.0.0", + "download_url": "https://example.com/test-ext.zip", + "_catalog_name": "test", + } + } + ) + + updates, blocked, has_installed = discover_updates(manager, catalog, None) + + assert blocked == [] + assert has_installed is True + assert len(updates) == 1 + assert updates[0].extension_id == "test-ext" + assert updates[0].installed == "1.0.0" + assert updates[0].available == "2.0.0" + assert updates[0].catalog_name == "test" + + +def test_discovery_blocks_stale_bundled_source(monkeypatch, tmp_path: Path): + manager = _Manager( + [{"id": "test-ext"}], + {"test-ext": {"version": "1.0.0"}}, + ) + catalog = _Catalog( + { + "test-ext": { + "id": "test-ext", + "name": "Test Extension", + "version": "3.0.0", + "bundled": True, + } + } + ) + monkeypatch.setattr( + _commands, + "_bundled_update_source", + lambda extension_id: (tmp_path, pkg_version.Version("2.0.0")), + ) + + updates, blocked, has_installed = discover_updates(manager, catalog, None) + + assert updates == [] + assert blocked == ["test-ext"] + assert has_installed is True diff --git a/tests/test_extension_update_hardening.py b/tests/specify_cli/extensions/test_command_update_transaction.py similarity index 99% rename from tests/test_extension_update_hardening.py rename to tests/specify_cli/extensions/test_command_update_transaction.py index b00cb7130d..28efb46833 100644 --- a/tests/test_extension_update_hardening.py +++ b/tests/specify_cli/extensions/test_command_update_transaction.py @@ -1,3 +1,8 @@ +"""Security and rollback tests for ``_command_update_transaction``. + +The primary command tests live in ``test_command_update.py``. +""" + from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog from pathlib import Path import pytest diff --git a/tests/test_extensions.py b/tests/test_extensions.py index fb6da1803e..1b5adefd5d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -23,7 +23,6 @@ from datetime import datetime, timezone from unittest.mock import MagicMock -from tests.conftest import strip_ansi from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401 from specify_cli import extensions as _ext_module from specify_cli.extensions import ( @@ -5112,90 +5111,8 @@ def test_search_all_extensions(self, temp_dir): results = catalog.search() assert len(results) == 2 - @pytest.mark.parametrize( - "downloads", - [ - "1500", # plain string: crashed the ``:,`` format - "[/red]foo", # unbalanced closing tag: raises MarkupError unescaped - "[bold]x[/bold]", # balanced tags: would silently restyle the output - ], - ) - def test_info_renders_non_numeric_downloads(self, downloads): - """A non-numeric ``downloads`` from an untrusted catalog must not crash the - info renderer — neither with 'Cannot specify ',' with 's'' (the ``:,`` - format) nor with a Rich MarkupError (the joined stats are markup).""" - from unittest.mock import MagicMock - from specify_cli.extensions._commands import _print_extension_info - - manager = MagicMock() - manager.registry.is_installed.return_value = False - ext_info = { - "name": "Jira", "id": "jira", "version": "1.0.0", - "description": "desc", "downloads": downloads, # from catalog JSON - } - # Must not raise ValueError or rich.errors.MarkupError. - _print_extension_info(ext_info, manager) - - def test_info_renders_markup_bearing_stars(self): - """``stars`` sits in the same joined stats string as ``downloads`` and is - equally catalog-controlled, so it must be escaped too.""" - from unittest.mock import MagicMock - from specify_cli.extensions._commands import _print_extension_info - - manager = MagicMock() - manager.registry.is_installed.return_value = False - ext_info = { - "name": "Jira", "id": "jira", "version": "1.0.0", - "description": "desc", "stars": "[/red]x", - } - _print_extension_info(ext_info, manager) # must not raise MarkupError - - @pytest.mark.parametrize("downloads", ["1500", "[/red]foo"]) - def test_search_survives_non_numeric_downloads(self, temp_dir, downloads): - """`specify extension search` must not abort when a catalog entry's - ``downloads`` is a non-numeric string — not with a raw ValueError from the - ``:,`` format, nor with a Rich MarkupError from unescaped markup.""" - import yaml as yaml_module - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - project_dir = temp_dir / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - config_path = project_dir / ".specify" / "extension-catalogs.yml" - with open(config_path, "w") as f: - yaml_module.dump( - {"catalogs": [{ - "name": "test-catalog", - "url": ExtensionCatalog.DEFAULT_CATALOG_URL, - "priority": 1, "install_allowed": True, - }]}, f, - ) - catalog = ExtensionCatalog(project_dir) - catalog_data = { - "schema_version": "1.0", - "extensions": {"jira": { - "name": "Jira", "id": "jira", "version": "1.0.0", - "description": "Jira integration", "author": "x", - "tags": ["jira"], "verified": True, - "downloads": downloads, # non-numeric, straight from catalog JSON - }}, - } - catalog.cache_dir.mkdir(parents=True, exist_ok=True) - catalog.cache_file.write_text(json.dumps(catalog_data)) - catalog.cache_metadata_file.write_text(json.dumps({ - "cached_at": datetime.now(timezone.utc).isoformat(), - "catalog_url": "http://test.com", - })) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "search"], catch_exceptions=True) - assert result.exit_code == 0, result.output - # Rendered literally (escaped), not interpreted as markup or dropped. - assert f"Downloads: {downloads}" in result.output def test_search_by_query(self, temp_dir): """Test searching by query text.""" @@ -5397,43 +5314,6 @@ def test_search_tolerates_non_string_tags(self, temp_dir): results = catalog.search(query="jira") assert {r["id"] for r in results} == {"jira"} - def test_search_and_info_tolerate_non_list_tags(self, temp_dir): - """A scalar ``tags:`` value must not crash the search/info display. - - ``ExtensionCatalog.search`` guards its tag *filter* with - ``isinstance(raw_tags, list)``, but the ``extension search`` and - ``extension info`` display paths only tested truthiness before - iterating. ``tags: 5`` is truthy and not iterable, so both raised - ``TypeError: 'int' object is not iterable``. - """ - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = temp_dir / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - merged = [{ - "id": "jira", - "name": "Jira", - "version": "1.0.0", - "description": "Jira", - "tags": 5, - }] - - with patch.object(ExtensionCatalog, "_get_merged_extensions", return_value=merged), \ - patch("specify_cli.extensions._commands._require_specify_project", - return_value=project_dir): - searched = CliRunner().invoke(app, ["extension", "search", "Jira"]) - info = CliRunner().invoke(app, ["extension", "info", "jira"]) - - assert searched.exit_code == 0, searched.output - assert "Jira" in searched.output - assert "Tags:" not in searched.output - - assert info.exit_code == 0, info.output - assert "Tags:" not in info.output def test_search_tolerates_non_string_author_and_name(self, temp_dir): """Non-string catalog author/name must not crash author/query search. @@ -7565,2477 +7445,182 @@ def test_extensionignore_negation_pattern(self, temp_dir, valid_manifest_data): assert (dest / "docs" / "api.md").exists() -class TestExtensionAddCLI: - """CLI integration tests for extension add command.""" - def test_catalog_add_escapes_url_markup(self, tmp_path): - """Catalog add should render user-supplied URLs literally.""" - from typer.testing import CliRunner + +class TestDownloadExtensionBundled: + """Tests for download_extension handling of bundled extensions.""" + + def test_download_extension_raises_for_bundled(self, temp_dir): + """download_extension should raise a clear error for bundled extensions without a URL.""" from unittest.mock import patch - from specify_cli import app - project_dir = tmp_path / "test-project" + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() - url = "https://example.com/[red]catalog[/red].json" - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - [ - "extension", - "catalog", - "add", - url, - "--name", - "community", - ], - catch_exceptions=True, - ) + catalog = ExtensionCatalog(project_dir) + + bundled_ext_info = { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Git workflow", + "bundled": True, + } - assert result.exit_code == 0, result.output - assert f"URL: {url}" in result.output + with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info): + with pytest.raises(ExtensionError, match="bundled with spec-kit"): + catalog.download_extension("git") - def test_catalog_add_escapes_config_saved_path_markup(self, tmp_path): - """Catalog add's saved-path label should render literally under Rich.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + def test_download_extension_allows_bundled_with_url(self, temp_dir): + """download_extension should allow bundled extensions that have a download_url (newer version).""" + from unittest.mock import patch, MagicMock + import urllib.request - project_dir = tmp_path / "test-project" + project_dir = temp_dir / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() - display_path = "project[red]/.specify/extension-catalogs.yml" - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.extensions._commands._display_project_path", return_value=display_path): - result = runner.invoke( - app, - [ - "extension", - "catalog", - "add", - "https://example.com/catalog.json", - "--name", - "community", - ], - catch_exceptions=True, - ) + catalog = ExtensionCatalog(project_dir) + + bundled_with_url = { + "name": "Git Branching Workflow", + "id": "git", + "version": "2.0.0", + "description": "Git workflow", + "bundled": True, + "download_url": "https://example.com/git-2.0.0.zip", + } + + mock_response = MagicMock() + mock_response.read.side_effect = io.BytesIO(_MINIMAL_ZIP_BYTES).read + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://example.com/catalog.json" - assert result.exit_code == 0, result.output - assert f"Config saved to {display_path}" in result.output + with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \ + patch.object(urllib.request, "urlopen", return_value=mock_response): + result = catalog.download_extension("git") + assert result.name == "git-2.0.0.zip" - def test_catalog_list_escapes_config_path_markup(self, tmp_path): - """Catalog list's config-path label should render literally under Rich.""" - from typer.testing import CliRunner + def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): + """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" from unittest.mock import patch - from specify_cli import app - import yaml - project_dir = tmp_path / "test-project" + project_dir = temp_dir / "project" project_dir.mkdir() - specify_dir = project_dir / ".specify" - specify_dir.mkdir() - (specify_dir / "extension-catalogs.yml").write_text( - yaml.safe_dump( - { - "catalogs": [ - { - "name": "community", - "url": "https://example.com/catalog.json", - "priority": 10, - "install_allowed": False, - } - ] - } - ), - encoding="utf-8", - ) - - display_path = "project[red]/.specify/extension-catalogs.yml" + (project_dir / ".specify").mkdir() - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.extensions._commands._display_project_path", return_value=display_path): - result = runner.invoke( - app, - ["extension", "catalog", "list"], - catch_exceptions=True, - ) + catalog = ExtensionCatalog(project_dir) - assert result.exit_code == 0, result.output - assert f"Config: {display_path}" in result.output + non_bundled_ext_info = { + "name": "Some Extension", + "id": "some-ext", + "version": "1.0.0", + "description": "Test", + } - def test_catalog_list_shows_discovery_only_guidance(self, tmp_path): - """A discovery-only catalog should trigger the trust-model guidance, - steering users to --from / their own catalog and away from flipping - install_allowed.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - import yaml + with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info): + with pytest.raises(ExtensionError, match="has no download URL"): + catalog.download_extension("some-ext") - project_dir = tmp_path / "test-project" - project_dir.mkdir() - specify_dir = project_dir / ".specify" - specify_dir.mkdir() - (specify_dir / "extension-catalogs.yml").write_text( - yaml.safe_dump( - { - "catalogs": [ - { - "name": "community", - "url": "https://example.com/catalog.json", - "priority": 10, - "install_allowed": False, - } - ] - } - ), - encoding="utf-8", - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "catalog", "list"], - catch_exceptions=True, - ) - assert result.exit_code == 0, result.output - output = " ".join(result.output.split()) - assert "not installable by design" in output - assert "--from " in output - assert "Don't flip a discovery-only catalog to install_allowed" in output - def test_catalog_list_omits_guidance_when_all_installable(self, tmp_path): - """When every catalog is an install source, the discovery-only guidance - should not appear.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - import yaml - project_dir = tmp_path / "test-project" - project_dir.mkdir() - specify_dir = project_dir / ".specify" - specify_dir.mkdir() - (specify_dir / "extension-catalogs.yml").write_text( - yaml.safe_dump( - { - "catalogs": [ - { - "name": "my-org", - "url": "https://example.com/catalog.json", - "priority": 10, - "install_allowed": True, - } - ] - } - ), - encoding="utf-8", - ) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "catalog", "list"], - catch_exceptions=True, - ) +class TestExtensionPriority: + """Test extension priority-based resolution.""" - assert result.exit_code == 0, result.output - assert "not installable by design" not in result.output + def test_list_by_priority_empty(self, temp_dir): + """Test list_by_priority on empty registry.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() - def test_catalog_add_escapes_config_read_exception_markup(self, tmp_path): - """Catalog config parse errors can include user-controlled file content.""" - import yaml - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + registry = ExtensionRegistry(extensions_dir) + result = registry.list_by_priority() - project_dir = tmp_path / "test-project" - project_dir.mkdir() - specify_dir = project_dir / ".specify" - specify_dir.mkdir() - (specify_dir / "extension-catalogs.yml").write_text("[red]bad[/red]", encoding="utf-8") - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch( - "specify_cli.extensions._commands.yaml.safe_load", - side_effect=yaml.YAMLError("bad [red]catalog[/red] yaml"), - ): - result = runner.invoke( - app, - [ - "extension", - "catalog", - "add", - "https://example.com/catalog.json", - "--name", - "community", - ], - catch_exceptions=True, - ) + assert result == [] - assert result.exit_code == 1, result.output - assert "bad [red]catalog[/red]" in result.output - assert "yaml" in result.output + def test_list_by_priority_single(self, temp_dir): + """Test list_by_priority with single extension.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() - def test_catalog_add_escapes_url_validation_exception_markup(self, tmp_path): - """URL validation errors may include user-controlled URL text.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + registry = ExtensionRegistry(extensions_dir) + registry.add("test-ext", {"version": "1.0.0", "priority": 5}) - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() + result = registry.list_by_priority() - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object( - ExtensionCatalog, - "_validate_catalog_url", - side_effect=ValidationError("bad [red]url[/red]"), - ): - result = runner.invoke( - app, - [ - "extension", - "catalog", - "add", - "https://example.com/[red]catalog[/red].json", - "--name", - "community", - ], - catch_exceptions=True, - ) + assert len(result) == 1 + assert result[0][0] == "test-ext" + assert result[0][1]["priority"] == 5 - assert result.exit_code == 1, result.output - assert "bad [red]url[/red]" in result.output + def test_list_by_priority_ordering(self, temp_dir): + """Test list_by_priority returns extensions sorted by priority.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() - def test_add_dev_links_copilot_agent_when_supported( - self, extension_dir, project_dir, temp_dir - ): - """extension add --dev should link generated agent files when possible.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + registry = ExtensionRegistry(extensions_dir) + # Add in non-priority order + registry.add("ext-low", {"version": "1.0.0", "priority": 20}) + registry.add("ext-high", {"version": "1.0.0", "priority": 1}) + registry.add("ext-mid", {"version": "1.0.0", "priority": 10}) - (project_dir / ".github" / "agents").mkdir(parents=True) + result = registry.list_by_priority() - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", str(extension_dir), "--dev"], - catch_exceptions=True, - ) + assert len(result) == 3 + # Lower priority number = higher precedence (first) + assert result[0][0] == "ext-high" + assert result[1][0] == "ext-mid" + assert result[2][0] == "ext-low" - assert result.exit_code == 0, result.output + def test_list_by_priority_default(self, temp_dir): + """Test list_by_priority uses default priority of 10.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() - agent_file = ( - project_dir - / ".github" - / "agents" - / "speckit.test-ext.hello.agent.md" - ) - assert agent_file.exists() - if can_create_symlink(temp_dir): - assert agent_file.is_symlink() - assert ".specify-dev" in agent_file.resolve().parts - else: - assert not agent_file.is_symlink() + registry = ExtensionRegistry(extensions_dir) + # Add without explicit priority + registry.add("ext-default", {"version": "1.0.0"}) + registry.add("ext-high", {"version": "1.0.0", "priority": 1}) + registry.add("ext-low", {"version": "1.0.0", "priority": 20}) - @pytest.mark.skipif( - os.name == "nt", reason="POSIX execute bits are not meaningful on Windows" - ) - def test_add_makes_shipped_scripts_executable(self, extension_dir, project_dir): - """extension add must restore execute bits on bundled POSIX scripts. - - Archives are unpacked with zipfile.extractall and --dev installs copy the - tree; neither restores a stripped Unix mode, so a shipped *.sh can land - non-executable and a documented `.specify/extensions//scripts/...` - invocation then fails with "Permission denied". init / migrate / - integration-install already call ensure_executable_scripts(); this guards - that `extension add` does too. - """ - import stat + result = registry.list_by_priority() - scripts_dir = extension_dir / "scripts" - scripts_dir.mkdir() - script = scripts_dir / "gate.sh" - script.write_text("#!/usr/bin/env bash\necho hi\n") - script.chmod(0o644) # non-executable, as an unpacked/copied script may be - assert not os.access(script, os.X_OK) + assert len(result) == 3 + # ext-high (1), ext-default (10), ext-low (20) + assert result[0][0] == "ext-high" + assert result[1][0] == "ext-default" + assert result[2][0] == "ext-low" - 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, - ["extension", "add", str(extension_dir), "--dev"], - catch_exceptions=True, - ) + def test_list_by_priority_invalid_priority_defaults(self, temp_dir): + """Malformed priority values fall back to the default priority.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() - assert result.exit_code == 0, result.output - installed = ( - project_dir / ".specify" / "extensions" / "test-ext" / "scripts" / "gate.sh" - ) - assert installed.exists(), result.output - assert os.access(installed, os.X_OK), ( - f"installed script not executable: mode=" - f"{stat.S_IMODE(installed.stat().st_mode):o}" - ) + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-high", {"version": "1.0.0", "priority": 1}) + registry.data["extensions"]["ext-invalid"] = { + "version": "1.0.0", + "priority": "high", + } + registry._save() - def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir): - """Codex dev skills should be written as files so Codex can load them.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app + result = registry.list_by_priority() - init_options = project_dir / ".specify" / "init-options.json" - init_options.write_text( - json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" - ) + assert [item[0] for item in result] == ["ext-high", "ext-invalid"] + assert result[1][1]["priority"] == 10 - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", str(extension_dir), "--dev"], - catch_exceptions=True, - ) + def test_list_by_priority_excludes_disabled(self, temp_dir): + """Test that list_by_priority excludes disabled extensions by default.""" + extensions_dir = temp_dir / "extensions" + extensions_dir.mkdir() - assert result.exit_code == 0, result.output - - skill_file = ( - project_dir - / ".agents" - / "skills" - / "speckit-test-ext-hello" - / "SKILL.md" - ) - assert skill_file.exists() - assert not skill_file.is_symlink() - - content = skill_file.read_text(encoding="utf-8") - assert "name: speckit-test-ext-hello" in content - assert "metadata:" in content - assert "source: test-ext:commands/hello.md" in content - - def test_add_dev_replaces_existing_codex_skill_symlink( - self, extension_dir, project_dir, temp_dir - ): - """Codex dev installs should migrate expected dev symlinks to files.""" - if not can_create_symlink(temp_dir): - pytest.skip("Current platform/user cannot create symlinks") - - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - init_options = project_dir / ".specify" / "init-options.json" - init_options.write_text( - json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8" - ) - - skill_file = ( - project_dir - / ".agents" - / "skills" - / "speckit-test-ext-hello" - / "SKILL.md" - ) - skill_file.parent.mkdir(parents=True) - cache_file = ( - extension_dir - / ".specify-dev" - / "extension-skills" - / "speckit-test-ext-hello" - / "SKILL.md" - ) - cache_file.parent.mkdir(parents=True) - cache_file.write_text("old linked content", encoding="utf-8") - os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", str(extension_dir), "--dev"], - catch_exceptions=True, - ) - - assert result.exit_code == 0, result.output - assert skill_file.exists() - assert not skill_file.is_symlink() - content = skill_file.read_text(encoding="utf-8") - assert "name: speckit-test-ext-hello" in content - assert "source: test-ext:commands/hello.md" in content - assert cache_file.read_text(encoding="utf-8") == "old linked content" - - def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable( - self, extension_dir, project_dir, monkeypatch - ): - """extension add --dev should work when Windows cannot create symlinks.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - (project_dir / ".github" / "agents").mkdir(parents=True) - - def raise_windows_symlink_error(target, link): - raise OSError("A required privilege is not held by the client") - - monkeypatch.setattr( - "specify_cli.agents.os.symlink", raise_windows_symlink_error - ) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", str(extension_dir), "--dev"], - catch_exceptions=True, - ) - - assert result.exit_code == 0, result.output - - agent_file = ( - project_dir - / ".github" - / "agents" - / "speckit.test-ext.hello.agent.md" - ) - assert agent_file.exists() - assert not agent_file.is_symlink() - assert "Extension: test-ext" in agent_file.read_text(encoding="utf-8") - assert ( - project_dir - / ".specify" - / "extensions" - / "test-ext" - / ".specify-dev" - / "agent-commands" - / "copilot" - / "speckit.test-ext.hello.agent.md" - ).exists() - - def test_add_by_display_name_uses_resolved_id_for_download(self, tmp_path): - """extension add by display name should use resolved ID for download_extension().""" - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - # Create project structure - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - # Mock catalog that returns extension by display name - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = None # ID lookup fails - mock_catalog.search.return_value = [ - { - "id": "acme-jira-integration", - "name": "Jira Integration", - "version": "1.0.0", - "description": "Jira integration extension", - "_install_allowed": True, - } - ] - - # Track what ID was passed to download_extension - download_called_with = [] - def mock_download(extension_id): - download_called_with.append(extension_id) - # Return a path that will fail install (we just want to verify the ID) - raise ExtensionError("Mock download - checking ID was resolved") - - mock_catalog.download_extension.side_effect = mock_download - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", "Jira Integration"], - catch_exceptions=True, - ) - - assert result.exit_code != 0, ( - f"Expected non-zero exit code since mock download raises, got {result.exit_code}" - ) - - # Verify download_extension was called with the resolved ID, not the display name - assert len(download_called_with) == 1 - assert download_called_with[0] == "acme-jira-integration", ( - f"Expected download_extension to be called with resolved ID 'acme-jira-integration', " - f"but was called with '{download_called_with[0]}'" - ) - - def test_catalog_add_forwards_catalog_name(self, tmp_path): - """The extension catalog branch passes resolved provenance to the manager.""" - from types import SimpleNamespace - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - (project_dir / ".specify").mkdir(parents=True) - archive = tmp_path / "extension.zip" - archive.write_bytes(b"archive") - captured = {} - - def fake_install_from_zip(self, _archive, _version, **kwargs): - captured.update(kwargs) - return SimpleNamespace( - id="catalog-extension", - name="Catalog Extension", - version="1.0.0", - description="catalog extension", - warnings=[], - commands=[], - ) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "catalog-extension", - "name": "Catalog Extension", - "version": "1.0.0", - "_install_allowed": True, - "_catalog_name": "extension-catalog", - }), \ - patch.object(ExtensionCatalog, "download_extension", return_value=archive), \ - patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ - patch("specify_cli.extensions._commands._refresh_events_and_warn"): - result = CliRunner().invoke(app, ["extension", "add", "catalog-extension"]) - - assert result.exit_code == 0, result.output - assert captured["catalog_name"] == "extension-catalog" - - def test_add_discovery_only_error_suggests_resolved_id(self, tmp_path): - """The not-installable error must suggest a copy-pasteable command using - the resolved catalog ID, not a display name that may contain spaces.""" - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = None # ID lookup fails - mock_catalog.search.return_value = [ - { - "id": "acme-jira-integration", - "name": "Jira Integration", - "version": "1.0.0", - "description": "Jira integration extension", - "_install_allowed": False, - "_catalog_name": "community", - } - ] - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", "Jira Integration"], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - output = " ".join(result.output.split()) - # Suggested command uses the resolved ID and stays a single token. - assert "add acme-jira-integration --from" in output - # It must not emit the space-containing display name as the command target. - assert "add Jira Integration --from" not in output - - def test_add_discovery_only_error_neutralizes_unsafe_id(self, tmp_path): - """A catalog-controlled ID with shell metacharacters must never be - interpolated into the suggested command; it is replaced by a literal - placeholder so copying the command can't execute injected shell text.""" - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - malicious_id = "foo; rm -rf ~" - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = { - "id": malicious_id, - "name": "Evil Ext", - "version": "1.0.0", - "description": "malicious", - "_install_allowed": False, - "_catalog_name": "community", - } - mock_catalog.search.return_value = [] - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", malicious_id], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - output = " ".join(result.output.split()) - # The runnable command uses a literal placeholder, never the raw ID. - assert "add --from" in output - # The malicious ID is never rendered as the target of an install command. - assert f"add {malicious_id} --from" not in output - assert "add foo; rm" not in output - - def test_command_safe_id_rejects_leading_hyphen(self): - """An ID like ``--force`` matches the manifest character rule but Typer - would parse it as an option, not the positional extension argument, so - the helper must fall back to the placeholder.""" - from specify_cli.extensions._commands import _command_safe_id - - assert _command_safe_id("--force") == "" - assert _command_safe_id("-x") == "" - # A normal slug is still returned verbatim. - assert _command_safe_id("acme-thing") == "acme-thing" - - def test_info_discovery_only_shows_candidate_archive_url(self, tmp_path): - """For a discovery-only entry that carries a ``download_url``, ``info`` - surfaces the candidate archive URL (flagged for vetting) and the vetted - ``--from`` install guidance, so users have a CLI path to the URL.""" - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - archive_url = "https://example.com/acme-thing-1.0.0.zip" - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = { - "id": "acme-thing", - "name": "Acme Thing", - "version": "1.0.0", - "description": "A thing", - "download_url": archive_url, - "_install_allowed": False, - "_catalog_name": "community", - } - mock_catalog.search.return_value = [] - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch("specify_cli.extensions.ExtensionManager") as mock_mgr, \ - patch.object(Path, "cwd", return_value=project_dir): - mock_mgr.return_value.registry.is_installed.return_value = False - result = runner.invoke( - app, - ["extension", "info", "acme-thing"], - catch_exceptions=True, - ) - - output = " ".join(result.output.split()) - assert "discovery-only" in output - assert f"Candidate archive (vet before installing): {archive_url}" in output - assert "specify extension add acme-thing --from " in output - - def test_info_discovery_only_without_url_falls_back(self, tmp_path): - """A discovery-only entry lacking ``download_url`` still gets vetted - ``--from`` guidance, without claiming a candidate archive it doesn't - have.""" - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = { - "id": "acme-thing", - "name": "Acme Thing", - "version": "1.0.0", - "description": "A thing", - "_install_allowed": False, - "_catalog_name": "community", - } - mock_catalog.search.return_value = [] - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch("specify_cli.extensions.ExtensionManager") as mock_mgr, \ - patch.object(Path, "cwd", return_value=project_dir): - mock_mgr.return_value.registry.is_installed.return_value = False - result = runner.invoke( - app, - ["extension", "info", "acme-thing"], - catch_exceptions=True, - ) - - output = " ".join(result.output.split()) - assert "Candidate archive" not in output - assert "vetted its release archive" in output - assert "specify extension add acme-thing --from " in output - - def test_info_by_name_tolerates_non_string_catalog_name(self, tmp_path): - """Display-name resolution must not crash on a non-string catalog name. - - Catalog JSON is user-editable, so ``catalog.search()`` may return an - entry whose ``name`` is a non-string (e.g. ``name: 123``). The - display-name filter calls ``.lower()`` on it; without coercion this - raises ``AttributeError`` and takes down ``extension info``/``add``. - The entry with the bad name must simply not match, yielding a clean - "not found" rather than a traceback. - """ - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - # Catalog search returns an entry with a non-string name. - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = None # ID lookup fails - mock_catalog.search.return_value = [ - { - "id": "acme-thing", - "name": 123, - "version": "1.0.0", - "description": "A thing", - "_install_allowed": True, - } - ] - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "info", "Some Name"], - catch_exceptions=True, - ) - - # Must not crash with AttributeError; the bad-named entry just doesn't - # match, so resolution ends as a clean not-found error exit. - assert not isinstance(result.exception, AttributeError), ( - f"non-string catalog name crashed resolution: {result.exception!r}" - ) - assert result.exit_code != 0 - - def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): - """extension add should give a clear error when a bundled extension is not found locally.""" - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - runner = CliRunner() - - # Create project structure - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".specify" / "extensions").mkdir(parents=True) - - # Mock catalog that returns a bundled extension without download_url - mock_catalog = MagicMock() - mock_catalog.get_extension_info.return_value = { - "id": "git", - "name": "Git Branching Workflow", - "version": "1.0.0", - "description": "Git branching extension", - "bundled": True, - "_install_allowed": True, - } - mock_catalog.search.return_value = [] - - with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ - patch("specify_cli._locate_bundled_extension", return_value=None), \ - patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", "git"], - catch_exceptions=True, - ) - - assert result.exit_code != 0 - assert "bundled with spec-kit" in result.output - assert "reinstall" in result.output.lower() - - def test_add_from_url_prompts_before_spinner(self, tmp_path): - """Confirm prompt for --from must fire before the console.status spinner. - - Regression test for #2783: typer.confirm() inside console.status() - was overwritten by the Rich spinner, making the command appear hung. - """ - from typer.testing import CliRunner - from unittest.mock import patch, MagicMock - from specify_cli import app - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - call_order: list[str] = [] - - original_status = MagicMock() - - def record_status(*args, **kwargs): - call_order.append("spinner") - return original_status - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.console.status", side_effect=record_status), \ - patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]): - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"], - catch_exceptions=True, - ) - - assert "confirm" in call_order, "confirm prompt was never called" - # The confirm must fire BEFORE the spinner is entered - if "spinner" in call_order: - assert call_order.index("confirm") < call_order.index("spinner"), \ - f"confirm must precede spinner, got: {call_order}" - assert result.exit_code == 0 # user declined → clean exit - - def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path): - """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 - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - plain = strip_ansi(result.output) - assert "Invalid URL" in plain - - @pytest.mark.parametrize( - "url", - [ - "https:///ext.zip", - "https://example.com:99999/ext.zip", - ], - ) - def test_add_from_invalid_url_exits_before_prompt(self, tmp_path, url): - """Hostless URLs and invalid ports fail before prompting or downloading.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm") as confirm, \ - patch("specify_cli.authentication.http.open_url") as open_url: - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", url], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert "Invalid URL" in strip_ansi(result.output) - confirm.assert_not_called() - open_url.assert_not_called() - - def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path): - """A bracketed-but-invalid IPv6 host must produce a clean error, not a - ValueError traceback. "https://[not-an-ip]/ext.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 - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert result.exception is None or isinstance(result.exception, SystemExit) - plain = strip_ansi(result.output) - assert "Invalid URL" in plain - - def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch): - """Synthetic defensive coverage: monkeypatch urlparse() to return an - object whose .hostname raises ValueError lazily. This does not reproduce - any specific CPython behavior -- it just exercises the case where the - ValueError surfaces on the .hostname read rather than at parse time, so a - raw ValueError would leak if .hostname were read outside the try/except. - """ - import urllib.parse - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - real_urlparse = urllib.parse.urlparse - - class _LazyHostnameRaiser: - def __init__(self, parsed): - self._parsed = parsed - - @property - def hostname(self): - raise ValueError("simulated lazy IPv6 hostname failure") - - def __getattr__(self, name): - return getattr(self._parsed, name) - - def _fake_urlparse(url, *args, **kwargs): - return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs)) - - monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse) - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", "https://example.com/ext.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) - - def test_add_status_escapes_extension_markup(self, tmp_path): - """User-controlled extension names must not be parsed as Rich markup.""" - from rich.markup import escape as escape_markup - from typer.testing import CliRunner - from unittest.mock import MagicMock, patch - from specify_cli import app - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - status_messages: list[str] = [] - - def record_status(message, *args, **kwargs): - status_messages.append(message) - return MagicMock() - - extension_name = "[red]bad[/red]" - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("specify_cli.console.status", side_effect=record_status): - result = runner.invoke( - app, - ["extension", "add", extension_name, "--dev"], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert status_messages == [ - f"[cyan]Installing extension: {escape_markup(extension_name)}[/cyan]" - ] - - def test_add_post_install_hint_escapes_manifest_id_markup(self, tmp_path): - """Extension IDs printed in Rich-rendered hints must stay literal.""" - import io - from types import SimpleNamespace - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - manifest_id = "[red]bad[/red]" - - def fake_install_from_zip( - self_obj, - zip_path, - speckit_version, - priority=10, - force=False, - *, - archive_file=None, - ): - return SimpleNamespace( - id=manifest_id, - name="Bad Extension", - version="1.0.0", - description="Test extension", - warnings=[], - commands=[], - hooks=[], - ) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=True), \ - patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ - patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ - patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \ - patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ - patch.object(ExtensionRegistry, "get", return_value={}): - result = runner.invoke( - app, - ["extension", "add", "bad", "--from", "https://example.com/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 0, result.output - assert ".specify/extensions/[red]bad[/red]/" in result.output - - def test_add_from_url_cancel_exits_cleanly(self, tmp_path): - """Declining the --from confirmation should exit with code 0.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=False): - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 0 - assert "Cancelled" in result.output - - def test_add_from_url_escapes_download_exception_markup(self, tmp_path): - """Download errors can include user-controlled URL text.""" - import urllib.error - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=True), \ - patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ - patch( - "specify_cli.authentication.http.open_url", - side_effect=urllib.error.URLError("bad [red]download[/red]"), - ): - result = runner.invoke( - app, - [ - "extension", - "add", - "my-ext", - "--from", - "https://example.com/[red]ext[/red].zip", - ], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert "https://example.com/[red]ext[/red].zip" in result.output - assert "bad [red]download[/red]" in result.output - - def test_add_from_url_rejects_non_zip_login_page(self, tmp_path): - """An HTML login page (unauthenticated fetch) must fail clearly, not BadZipFile.""" - import io - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=True), \ - patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ - patch( - "specify_cli.authentication.http.open_url", - return_value=FakeResponse(b"Sign in"), - ), \ - patch.object(ExtensionManager, "install_from_zip") as install: - result = runner.invoke( - app, - ["extension", "add", "my-ext", "--from", "https://raw.ghe.example/o/r/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert "did not return a ZIP archive" in result.output - install.assert_not_called() - - def test_add_from_url_rejects_oversized_download_before_install( - self, tmp_path, monkeypatch - ): - """The direct URL path must use the same bounded reader as catalogs.""" - import io - - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - from specify_cli.extensions import _commands as extension_commands - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def reject_oversized(*_args, **_kwargs): - raise ExtensionError("extension URL download exceeds maximum size") - - monkeypatch.setattr( - extension_commands, - "read_response_limited", - reject_oversized, - raising=False, - ) - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=True), \ - patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ - patch( - "specify_cli.authentication.http.open_url", - return_value=FakeResponse(_MINIMAL_ZIP_BYTES), - ), \ - patch.object(ExtensionManager, "install_from_zip") as install: - result = runner.invoke( - app, - [ - "extension", - "add", - "my-ext", - "--from", - "https://example.com/ext.zip", - ], - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert "exceeds maximum size" in result.output - install.assert_not_called() - - def test_add_from_url_resolves_ghes_release_asset(self, tmp_path): - """A GHES release-download URL resolves to /api/v3 with octet-stream Accept.""" - import io - from types import SimpleNamespace - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - import json - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - seen = {} - - def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): - if "/releases/tags/" in url: - body = json.dumps({ - "assets": [{ - "name": "ext.zip", - "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42", - }] - }).encode() - return FakeResponse(body) - seen["url"] = url - seen["headers"] = extra_headers - return FakeResponse(_MINIMAL_ZIP_BYTES) - - def fake_install( - self_obj, - zip_path, - speckit_version, - priority=10, - force=False, - *, - archive_file=None, - ): - return SimpleNamespace( - id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[] - ) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=True), \ - patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ - patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \ - patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ - patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \ - patch.object(ExtensionManager, "install_from_zip", fake_install): - result = runner.invoke( - app, - ["extension", "add", "x", "--from", - "https://ghes.example/org/repo/releases/download/v1.0/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 0, result.output - assert "/api/v3/repos/org/repo/releases/assets/" in seen["url"] - assert seen["headers"] == {"Accept": "application/octet-stream"} - - @pytest.mark.parametrize( - ("exc_type", "label"), - [ - (ValidationError, "Validation Error"), - (CompatibilityError, "Compatibility Error"), - (ExtensionError, "Error"), - ], - ) - def test_add_exception_handlers_escape_markup(self, tmp_path, exc_type, label): - """Extension install exceptions can include manifest-controlled values.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - ext_dir = tmp_path / "ext" - ext_dir.mkdir() - (ext_dir / "extension.yml").write_text("extension:\n id: test\n", encoding="utf-8") - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object( - ExtensionManager, - "install_from_directory", - side_effect=exc_type("bad [red]extension[/red]"), - ): - result = runner.invoke( - app, - ["extension", "add", str(ext_dir), "--dev"], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert f"{label}:" in result.output - assert "bad [red]extension[/red]" in result.output - - def test_add_from_url_uses_cache_tempfile_for_untrusted_extension_name(self, tmp_path): - """The extension argument must not control the downloaded ZIP path.""" - import io - from types import SimpleNamespace - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - class FakeResponse(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - project_dir = tmp_path / "test-project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads" - installed = {} - - def fake_install_from_zip( - self_obj, - zip_path, - speckit_version, - priority=10, - force=False, - *, - archive_file=None, - ): - captured_path = Path(zip_path) - installed["zip_path"] = captured_path - installed["zip_bytes"] = archive_file.read() - archive_file.seek(0) - return SimpleNamespace( - id="escape", - name="Escape Test", - version="1.0.0", - description="Test extension", - warnings=[], - commands=[], - hooks=[], - ) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch("typer.confirm", return_value=True), \ - patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \ - patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ - patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \ - patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): - result = runner.invoke( - app, - ["extension", "add", "../outside", "--from", "https://example.com/ext.zip"], - catch_exceptions=True, - ) - - assert result.exit_code == 0 - assert installed["zip_bytes"] == _MINIMAL_ZIP_BYTES - assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve()) - assert installed["zip_path"].name.startswith("extension-url-download-") - assert not installed["zip_path"].exists() - - -class TestDownloadExtensionBundled: - """Tests for download_extension handling of bundled extensions.""" - - def test_download_extension_raises_for_bundled(self, temp_dir): - """download_extension should raise a clear error for bundled extensions without a URL.""" - from unittest.mock import patch - - project_dir = temp_dir / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - catalog = ExtensionCatalog(project_dir) - - bundled_ext_info = { - "name": "Git Branching Workflow", - "id": "git", - "version": "1.0.0", - "description": "Git workflow", - "bundled": True, - } - - with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info): - with pytest.raises(ExtensionError, match="bundled with spec-kit"): - catalog.download_extension("git") - - def test_download_extension_allows_bundled_with_url(self, temp_dir): - """download_extension should allow bundled extensions that have a download_url (newer version).""" - from unittest.mock import patch, MagicMock - import urllib.request - - project_dir = temp_dir / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - catalog = ExtensionCatalog(project_dir) - - bundled_with_url = { - "name": "Git Branching Workflow", - "id": "git", - "version": "2.0.0", - "description": "Git workflow", - "bundled": True, - "download_url": "https://example.com/git-2.0.0.zip", - } - - mock_response = MagicMock() - mock_response.read.side_effect = io.BytesIO(_MINIMAL_ZIP_BYTES).read - mock_response.__enter__ = lambda s: s - mock_response.__exit__ = MagicMock(return_value=False) - mock_response.geturl.return_value = "https://example.com/catalog.json" - - with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \ - patch.object(urllib.request, "urlopen", return_value=mock_response): - result = catalog.download_extension("git") - assert result.name == "git-2.0.0.zip" - - def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): - """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" - from unittest.mock import patch - - project_dir = temp_dir / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - catalog = ExtensionCatalog(project_dir) - - non_bundled_ext_info = { - "name": "Some Extension", - "id": "some-ext", - "version": "1.0.0", - "description": "Test", - } - - with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info): - with pytest.raises(ExtensionError, match="has no download URL"): - catalog.download_extension("some-ext") - - -class TestExtensionUpdateCLI: - """CLI integration tests for extension update command.""" - - @staticmethod - def _create_extension_source(base_dir: Path, version: str, include_config: bool = False) -> Path: - """Create a minimal extension source directory for install tests.""" - import yaml - - ext_dir = base_dir / f"test-ext-{version}" - ext_dir.mkdir(parents=True, exist_ok=True) - - manifest = { - "schema_version": "1.0", - "extension": { - "id": "test-ext", - "name": "Test Extension", - "version": version, - "description": "A test extension", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": "speckit.test-ext.hello", - "file": "commands/hello.md", - "description": "Test command", - } - ] - }, - "hooks": { - "after_tasks": { - "command": "speckit.test-ext.hello", - "optional": True, - } - }, - } - - (ext_dir / "extension.yml").write_text(yaml.dump(manifest, sort_keys=False)) - commands_dir = ext_dir / "commands" - commands_dir.mkdir(exist_ok=True) - (commands_dir / "hello.md").write_text("---\ndescription: Test\n---\n\n$ARGUMENTS\n") - if include_config: - (ext_dir / "linear-config.yml").write_text("custom: true\nvalue: original\n") - return ext_dir - - @staticmethod - def _create_catalog_zip( - zip_path: Path, - version: str, - manifest_path: str = "extension.yml", - extra_manifest_path: str | None = None, - ): - """Create a minimal ZIP that passes extension_update ID validation.""" - import zipfile - import yaml - - manifest = { - "schema_version": "1.0", - "extension": { - "id": "test-ext", - "name": "Test Extension", - "version": version, - "description": "A test extension", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"commands": [{"name": "speckit.test-ext.hello", "file": "commands/hello.md"}]}, - } - - with zipfile.ZipFile(zip_path, "w") as zf: - manifest_text = yaml.dump(manifest, sort_keys=False) - zf.writestr(manifest_path, manifest_text) - if extra_manifest_path is not None: - zf.writestr(extra_manifest_path, manifest_text) - - @pytest.mark.parametrize( - "manifest_path", - [ - "../extension.yml", - "/extension.yml", - "./extension.yml", - "C:/extension.yml", - ], - ) - def test_update_rejects_unsafe_manifest_path_before_removal( - self, tmp_path, manifest_path - ): - """Unsafe manifest paths fail before the installed extension is removed.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory( - v1_dir, "0.1.0", catalog_name="previous-catalog" - ) - installed_extension_dir = manager.extensions_dir / "test-ext" - removed_paths = [] - real_rmtree = shutil.rmtree - - def track_rmtree(path, *args, **kwargs): - removed_paths.append(Path(path).resolve()) - return real_rmtree(path, *args, **kwargs) - - zip_path = tmp_path / "unsafe-manifest.zip" - self._create_catalog_zip( - zip_path, - "2.0.0", - manifest_path=manifest_path, - ) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - }), \ - patch.object( - ExtensionCatalog, - "download_extension", - return_value=zip_path, - ), \ - patch.object(shutil, "rmtree", side_effect=track_rmtree), \ - patch.object(ExtensionManager, "remove") as remove, \ - patch.object(ExtensionManager, "install_from_zip") as install: - result = runner.invoke( - app, - ["extension", "update", "test-ext"], - input="y\n", - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert "Unsafe path in ZIP archive" in result.output - remove.assert_not_called() - install.assert_not_called() - assert installed_extension_dir.resolve() not in removed_paths - assert not list( - (manager.extensions_dir / ".backup").glob( - "update-*-*" - ) - ) - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" - - @pytest.mark.parametrize( - ("first_path", "second_path"), - [ - ("repo/extension.yml", "repo\\extension.yml"), - ("repo/extension.yml", "repo/EXTENSION.YML"), - ("caf\u00e9/extension.yml", "cafe\u0301/extension.yml"), - ], - ) - def test_update_rejects_normalized_manifest_collision_before_removal( - self, tmp_path, first_path, second_path - ): - """Pre-scan and extraction must agree on the manifest identity.""" - import yaml - import zipfile - - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - - valid_manifest = yaml.safe_dump( - { - "schema_version": "1.0", - "extension": { - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - }, - } - ) - injected_manifest = yaml.safe_dump( - { - "schema_version": "1.0", - "extension": { - "id": "injected", - "name": "Injected", - "version": "2.0.0", - }, - } - ) - zip_path = tmp_path / "manifest-collision.zip" - with zipfile.ZipFile(zip_path, "w") as zf: - zf.writestr(first_path, valid_manifest) - zf.writestr(second_path, injected_manifest) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - }), \ - patch.object( - ExtensionCatalog, - "download_extension", - return_value=zip_path, - ), \ - patch.object(ExtensionManager, "remove") as remove, \ - patch.object(ExtensionManager, "install_from_zip") as install: - result = runner.invoke( - app, - ["extension", "update", "test-ext"], - input="y\n", - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert "multiple extension.yml" in result.output - remove.assert_not_called() - install.assert_not_called() - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" - - def test_update_preflights_entry_count_before_opening_zip( - self, tmp_path - ): - """Manifest inspection must not bypass the bounded ZIP opener.""" - import struct - - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - - zip_path = tmp_path / "too-many.zip" - zip_path.write_bytes( - struct.pack( - "<4s4H2LH", - b"PK\x05\x06", - 0, - 0, - 513, - 513, - 0, - 0, - 0, - ) - ) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - }), \ - patch.object( - ExtensionCatalog, - "download_extension", - return_value=zip_path, - ), \ - patch( - "specify_cli._download_security.zipfile.ZipFile", - side_effect=AssertionError("ZipFile constructor was called"), - ), \ - patch.object(ExtensionManager, "remove") as remove, \ - patch.object(ExtensionManager, "install_from_zip") as install: - result = runner.invoke( - app, - ["extension", "update", "test-ext"], - input="y\n", - catch_exceptions=True, - ) - - assert result.exit_code == 1 - assert "too many entries" in result.output - remove.assert_not_called() - install.assert_not_called() - - @pytest.mark.parametrize( - ("manifest_path", "extra_manifest_path"), - [ - ("extension.yml", None), - ("repo/extension.yml", None), - ("extension.yml", "repo/extension.yml"), - ], - ) - def test_update_success_preserves_installed_at( - self, tmp_path, manifest_path, extra_manifest_path - ): - """Successful update should keep original installed_at and apply new version.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True) - manager.install_from_directory(v1_dir, "0.1.0") - original_installed_at = manager.registry.get("test-ext")["installed_at"] - original_config_content = ( - project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" - ).read_text() - - zip_path = tmp_path / "test-ext-update.zip" - self._create_catalog_zip( - zip_path, - "2.0.0", - manifest_path=manifest_path, - extra_manifest_path=extra_manifest_path, - ) - v2_dir = self._create_extension_source(tmp_path, "2.0.0") - - def fake_install_from_zip( - self_obj, _zip_path, speckit_version, *, catalog_name=None - ): - return self_obj.install_from_directory( - v2_dir, speckit_version, catalog_name=catalog_name - ) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - "_catalog_name": "updated-catalog", - }), \ - patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ - patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): - result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) - - assert result.exit_code == 0, result.output - - updated = ExtensionManager(project_dir).registry.get("test-ext") - assert updated["version"] == "2.0.0" - assert updated["installed_at"] == original_installed_at - assert updated["source"] == { - "kind": "catalog", - "catalog": "updated-catalog", - } - restored_config_content = ( - project_dir / ".specify" / "extensions" / "test-ext" / "linear-config.yml" - ).read_text() - assert restored_config_content == original_config_content - - def test_update_installs_bundled_extension_from_local_copy(self, tmp_path): - """A bundled extension (no download URL) updates from the copy shipped - with the running spec-kit release instead of failing at download (#4345).""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - v2_dir = self._create_extension_source(tmp_path, "2.0.0") - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "bundled": True, - "_install_allowed": True, - }), \ - patch( - "specify_cli._locate_bundled_extension", return_value=v2_dir - ), \ - patch.object( - ExtensionCatalog, - "download_extension", - side_effect=AssertionError("bundled update must not download"), - ): - result = runner.invoke( - app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True - ) - - flat = " ".join(result.output.split()) - assert result.exit_code == 0, result.output - assert "Updated to v2.0.0" in flat - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "2.0.0" - - def test_update_bundled_blocked_when_local_copy_lags_catalog(self, tmp_path): - """When the catalog advertises a newer version than the running release - bundles, the update is reported as requiring a spec-kit upgrade instead - of being offered and then failing.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "bundled": True, - "_install_allowed": True, - }), \ - patch( - "specify_cli._locate_bundled_extension", return_value=v1_dir - ): - result = runner.invoke( - app, ["extension", "update", "test-ext"], catch_exceptions=True - ) - - flat = " ".join(result.output.split()) - assert result.exit_code == 0, result.output - assert "only ships v1.0.0" in flat - assert "upgrade spec-kit" in flat - assert "Update these extensions?" not in flat - assert "All extensions are up to date!" not in flat - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" - - def test_update_bundled_blocked_when_local_copy_is_intermediate_version(self, tmp_path): - """A bundled copy newer than the installation but older than the - catalog must be blocked, not installed: an intermediate version would - leave the project lagging the catalog while reporting success.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - v2_dir = self._create_extension_source(tmp_path, "2.0.0") - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "3.0.0", - "bundled": True, - "_install_allowed": True, - }), \ - patch( - "specify_cli._locate_bundled_extension", return_value=v2_dir - ), \ - patch.object( - ExtensionCatalog, - "download_extension", - side_effect=AssertionError("blocked bundled update must not download"), - ): - result = runner.invoke( - app, ["extension", "update", "test-ext"], catch_exceptions=True - ) - - flat = " ".join(result.output.split()) - assert result.exit_code == 0, result.output - assert "only ships v2.0.0" in flat - assert "upgrade spec-kit" in flat - assert "Update these extensions?" not in flat - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" - - def test_update_installs_bundled_copy_newer_than_catalog(self, tmp_path): - """A dev/source checkout can ship a copy newer than the fetched - catalog advertises; the local copy is offered and installed.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - v3_dir = self._create_extension_source(tmp_path, "3.0.0") - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "bundled": True, - "_install_allowed": True, - }), \ - patch( - "specify_cli._locate_bundled_extension", return_value=v3_dir - ): - result = runner.invoke( - app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True - ) - - flat = " ".join(result.output.split()) - assert result.exit_code == 0, result.output - assert "Updated to v3.0.0" in flat - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "3.0.0" - - def test_update_bundled_blocked_when_no_local_copy_exists(self, tmp_path): - """A bundled catalog entry with no locally shipped copy points at a - spec-kit upgrade instead of failing the update at download time.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "bundled": True, - "_install_allowed": True, - }), \ - patch( - "specify_cli._locate_bundled_extension", return_value=None - ): - result = runner.invoke( - app, ["extension", "update", "test-ext"], catch_exceptions=True - ) - - flat = " ".join(result.output.split()) - assert result.exit_code == 0, result.output - assert "does not ship a local copy" in flat - assert "upgrade spec-kit" in flat - assert ExtensionManager(project_dir).registry.get("test-ext")["version"] == "1.0.0" - - def test_update_failure_rolls_back_registry_hooks_and_commands(self, tmp_path, monkeypatch): - """Failed update should restore original registry, hooks, and command files.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - import yaml - - # Isolate home directory so Hermes' global ~/.hermes/skills/ doesn't - # interfere — without a real skills dir, Hermes is skipped during - # command registration, keeping the test focused on Claude/Codex/etc. - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setattr(Path, "home", lambda: fake_home) - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory( - v1_dir, "0.1.0", catalog_name="original-catalog" - ) - - backup_registry_entry = manager.registry.get("test-ext") - hooks_before = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) - - registered_commands = backup_registry_entry.get("registered_commands", {}) - command_files = [] - from specify_cli.agents import CommandRegistrar as AgentRegistrar - agent_registrar = AgentRegistrar() - for agent_name, cmd_names in registered_commands.items(): - if agent_name not in agent_registrar.AGENT_CONFIGS: - continue - agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name] - commands_dir = AgentRegistrar._resolve_agent_dir( - agent_name, agent_cfg, project_dir - ) - for cmd_name in cmd_names: - output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg) - cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}" - command_files.append(cmd_path) - - assert command_files, "Expected at least one registered command file" - for cmd_file in command_files: - assert cmd_file.exists(), f"Expected command file to exist before update: {cmd_file}" - - zip_path = tmp_path / "test-ext-update.zip" - self._create_catalog_zip(zip_path, "2.0.0") - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - }), \ - patch.object(ExtensionCatalog, "download_extension", return_value=zip_path), \ - patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("install failed")): - result = runner.invoke(app, ["extension", "update", "test-ext"], input="y\n", catch_exceptions=True) - - assert result.exit_code == 1, result.output - - restored_entry = ExtensionManager(project_dir).registry.get("test-ext") - assert restored_entry == backup_registry_entry - - hooks_after = yaml.safe_load((project_dir / ".specify" / "extensions.yml").read_text()) - assert hooks_after == hooks_before - - for cmd_file in command_files: - assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" - - def test_update_failure_after_skill_registration_restores_old_skills( - self, tmp_path, monkeypatch - ): - """Rollback must not depend on a new registry entry to restore skills.""" - import zipfile - import yaml - - from specify_cli import app - from typer.testing import CliRunner - from unittest.mock import patch - - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setattr(Path, "home", lambda: fake_home) - - project_dir = tmp_path / "project" - project_dir.mkdir() - specify_dir = project_dir / ".specify" - specify_dir.mkdir() - copilot_agents_dir = project_dir / ".github" / "agents" - copilot_agents_dir.mkdir(parents=True) - (specify_dir / "init-options.json").write_text( - json.dumps( - { - "ai": "claude", - "ai_skills": True, - "script": "sh", - } - ), - encoding="utf-8", - ) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory( - v1_dir, - "0.1.0", - register_commands=False, - ) - - old_registry_entry = manager.registry.get("test-ext") - skills_dir = project_dir / ".claude" / "skills" - old_skill = skills_dir / "speckit-test-ext-hello" - old_skill_content = (old_skill / "SKILL.md").read_text(encoding="utf-8") - assert old_registry_entry["registered_skills"] == [old_skill.name] - new_skill = skills_dir / "speckit-test-ext-new" - new_skill.mkdir() - user_skill_content = ( - "---\n" - "name: user-new-skill\n" - "description: User-owned skill\n" - "metadata:\n" - " source: user\n" - "---\n\nUSER SKILL\n" - ) - (new_skill / "SKILL.md").write_text( - user_skill_content, - encoding="utf-8", - ) - user_support_file = new_skill / "support.txt" - user_support_file.write_text("USER CONTENT", encoding="utf-8") - - v2_dir = self._create_extension_source(tmp_path, "2.0.0") - manifest_path = v2_dir / "extension.yml" - manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) - manifest["provides"]["commands"].append( - { - "name": "speckit.test-ext.new", - "file": "commands/new.md", - "description": "New command", - } - ) - manifest["provides"]["commands"].append( - { - "name": "speckit.test-ext.fresh", - "file": "commands/fresh.md", - "description": "Fresh command", - } - ) - manifest_path.write_text( - yaml.safe_dump(manifest, sort_keys=False), - encoding="utf-8", - ) - (v2_dir / "commands" / "hello.md").write_text( - "---\ndescription: New hello\n---\n\nNEW HELLO\n", - encoding="utf-8", - ) - (v2_dir / "commands" / "new.md").write_text( - "---\ndescription: New command\n---\n\nNEW COMMAND\n", - encoding="utf-8", - ) - (v2_dir / "commands" / "fresh.md").write_text( - "---\ndescription: Fresh command\n---\n\nFRESH COMMAND\n", - encoding="utf-8", - ) - - zip_path = tmp_path / "test-ext-update.zip" - with zipfile.ZipFile(zip_path, "w") as archive: - for source_path in v2_dir.rglob("*"): - if source_path.is_file(): - archive.write( - source_path, - source_path.relative_to(v2_dir), - ) - - def fail_after_skill_registration(self, manifest): - raise RuntimeError("Hook registration failed") - - runner = CliRunner() - with ( - patch.object(Path, "cwd", return_value=project_dir), - patch.object( - ExtensionCatalog, - "get_extension_info", - return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - }, - ), - patch.object( - ExtensionCatalog, - "download_extension", - return_value=zip_path, - ), - patch.object( - HookExecutor, - "register_hooks", - fail_after_skill_registration, - ), - ): - result = runner.invoke( - app, - ["extension", "update", "test-ext"], - input="y\n", - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert "Hook registration failed" in result.output - assert "Rollback successful" in result.output - assert ExtensionManager(project_dir).registry.get("test-ext") == old_registry_entry - assert (old_skill / "SKILL.md").read_text(encoding="utf-8") == old_skill_content - assert user_support_file.read_text(encoding="utf-8") == "USER CONTENT" - assert ( - new_skill / "SKILL.md" - ).read_text(encoding="utf-8") == user_skill_content - assert not (skills_dir / "speckit-test-ext-fresh").exists() - for command_name in ("hello", "new", "fresh"): - qualified_name = f"speckit.test-ext.{command_name}" - assert not ( - copilot_agents_dir / f"{qualified_name}.agent.md" - ).exists() - assert not ( - project_dir - / ".github" - / "prompts" - / f"{qualified_name}.prompt.md" - ).exists() - - @pytest.mark.parametrize( - ("manifest_text", "expected_detail"), - [ - ("- not\n- a\n- mapping\n", "YAML mapping"), - ("extension: []\n", "'extension' mapping"), - ], - ) - def test_update_rejects_malformed_zip_manifest( - self, tmp_path, monkeypatch, manifest_text, expected_detail - ): - """Downloaded extension.yml shape must be valid before ID validation.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - import zipfile - - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setattr(Path, "home", lambda: fake_home) - - runner = CliRunner() - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "skills").mkdir(parents=True) - - manager = ExtensionManager(project_dir) - v1_dir = self._create_extension_source(tmp_path, "1.0.0") - manager.install_from_directory(v1_dir, "0.1.0") - original_registry_entry = manager.registry.get("test-ext") - - zip_path = tmp_path / "bad-manifest.zip" - with zipfile.ZipFile(zip_path, "w") as zf: - zf.writestr("extension.yml", manifest_text) - - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionCatalog, "get_extension_info", return_value={ - "id": "test-ext", - "name": "Test Extension", - "version": "2.0.0", - "_install_allowed": True, - }), \ - patch.object(ExtensionCatalog, "download_extension", return_value=zip_path): - result = runner.invoke( - app, - ["extension", "update", "test-ext"], - input="y\n", - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert "Invalid extension manifest in downloaded archive" in result.output - assert expected_detail in result.output - assert "AttributeError" not in result.output - assert ExtensionManager(project_dir).registry.get("test-ext") == original_registry_entry - - -class TestExtensionListCLI: - """Test extension list CLI output format.""" - - def test_list_shows_extension_id(self, extension_dir, project_dir): - """extension list should display the extension ID.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install the extension using the manager - manager = ExtensionManager(project_dir) - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "list"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - # Verify the extension ID is shown in the output - assert "test-ext" in plain - # Verify name and version are also shown - assert "Test Extension" in plain - assert "1.0.0" in plain - - -class TestExtensionPriority: - """Test extension priority-based resolution.""" - - def test_list_by_priority_empty(self, temp_dir): - """Test list_by_priority on empty registry.""" - extensions_dir = temp_dir / "extensions" - extensions_dir.mkdir() - - registry = ExtensionRegistry(extensions_dir) - result = registry.list_by_priority() - - assert result == [] - - def test_list_by_priority_single(self, temp_dir): - """Test list_by_priority with single extension.""" - extensions_dir = temp_dir / "extensions" - extensions_dir.mkdir() - - registry = ExtensionRegistry(extensions_dir) - registry.add("test-ext", {"version": "1.0.0", "priority": 5}) - - result = registry.list_by_priority() - - assert len(result) == 1 - assert result[0][0] == "test-ext" - assert result[0][1]["priority"] == 5 - - def test_list_by_priority_ordering(self, temp_dir): - """Test list_by_priority returns extensions sorted by priority.""" - extensions_dir = temp_dir / "extensions" - extensions_dir.mkdir() - - registry = ExtensionRegistry(extensions_dir) - # Add in non-priority order - registry.add("ext-low", {"version": "1.0.0", "priority": 20}) - registry.add("ext-high", {"version": "1.0.0", "priority": 1}) - registry.add("ext-mid", {"version": "1.0.0", "priority": 10}) - - result = registry.list_by_priority() - - assert len(result) == 3 - # Lower priority number = higher precedence (first) - assert result[0][0] == "ext-high" - assert result[1][0] == "ext-mid" - assert result[2][0] == "ext-low" - - def test_list_by_priority_default(self, temp_dir): - """Test list_by_priority uses default priority of 10.""" - extensions_dir = temp_dir / "extensions" - extensions_dir.mkdir() - - registry = ExtensionRegistry(extensions_dir) - # Add without explicit priority - registry.add("ext-default", {"version": "1.0.0"}) - registry.add("ext-high", {"version": "1.0.0", "priority": 1}) - registry.add("ext-low", {"version": "1.0.0", "priority": 20}) - - result = registry.list_by_priority() - - assert len(result) == 3 - # ext-high (1), ext-default (10), ext-low (20) - assert result[0][0] == "ext-high" - assert result[1][0] == "ext-default" - assert result[2][0] == "ext-low" - - def test_list_by_priority_invalid_priority_defaults(self, temp_dir): - """Malformed priority values fall back to the default priority.""" - extensions_dir = temp_dir / "extensions" - extensions_dir.mkdir() - - registry = ExtensionRegistry(extensions_dir) - registry.add("ext-high", {"version": "1.0.0", "priority": 1}) - registry.data["extensions"]["ext-invalid"] = { - "version": "1.0.0", - "priority": "high", - } - registry._save() - - result = registry.list_by_priority() - - assert [item[0] for item in result] == ["ext-high", "ext-invalid"] - assert result[1][1]["priority"] == 10 - - def test_list_by_priority_excludes_disabled(self, temp_dir): - """Test that list_by_priority excludes disabled extensions by default.""" - extensions_dir = temp_dir / "extensions" - extensions_dir.mkdir() - - registry = ExtensionRegistry(extensions_dir) - registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) - registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) - registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True + registry = ExtensionRegistry(extensions_dir) + registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5}) + registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1}) + registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True # Default: exclude disabled by_priority = registry.list_by_priority() @@ -10133,185 +7718,6 @@ def test_corrupted_extension_entry_not_picked_up_as_unregistered(self, project_d assert "Valid" in valid_resolved.read_text() -class TestExtensionPriorityCLI: - """Test extension priority CLI integration.""" - - def test_add_with_priority_option(self, extension_dir, project_dir): - """Test extension add command with --priority option.""" - 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, [ - "extension", "add", str(extension_dir), "--dev", "--priority", "3" - ]) - - assert result.exit_code == 0, result.output - - manager = ExtensionManager(project_dir) - metadata = manager.registry.get("test-ext") - assert metadata["priority"] == 3 - - def test_list_shows_priority(self, extension_dir, project_dir): - """Test extension list shows priority.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install extension with priority - manager = ExtensionManager(project_dir) - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "list"]) - - assert result.exit_code == 0, result.output - plain = strip_ansi(result.output) - assert "Priority: 7" in plain - - def test_set_priority_changes_priority(self, extension_dir, project_dir): - """Test set-priority command changes extension priority.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install extension with default priority - manager = ExtensionManager(project_dir) - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) - - # Verify default priority - assert manager.registry.get("test-ext")["priority"] == 10 - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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 = ExtensionManager(project_dir) - assert manager2.registry.get("test-ext")["priority"] == 5 - - def test_set_priority_same_value_no_change(self, extension_dir, project_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 extension with priority 5 - manager = ExtensionManager(project_dir) - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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, extension_dir, project_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 = ExtensionManager(project_dir) - manager.install_from_directory( - extension_dir, "0.1.0", register_commands=False, priority=5 - ) - # Inject a corrupted boolean priority (True == 1). - manager.registry.update("test-ext", {"priority": True}) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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 = ExtensionManager(project_dir).registry.get("test-ext") - assert reloaded["priority"] == 1 - assert not isinstance(reloaded["priority"], bool) - - def test_set_priority_invalid_value(self, extension_dir, project_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 extension - manager = ExtensionManager(project_dir) - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "set-priority", "test-ext", "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 extension.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Ensure .specify exists - (project_dir / ".specify").mkdir(parents=True, exist_ok=True) - - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"]) - - assert result.exit_code == 1, result.output - assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower() - - def test_set_priority_by_display_name(self, extension_dir, project_dir): - """Test set-priority works with extension display name.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - - # Install extension - manager = ExtensionManager(project_dir) - manager.install_from_directory(extension_dir, "0.1.0", register_commands=False) - - # Use display name "Test Extension" instead of ID "test-ext" - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"]) - - assert result.exit_code == 0, result.output - assert "priority changed" in result.output - - # Reload registry to see updated value - manager2 = ExtensionManager(project_dir) - assert manager2.registry.get("test-ext")["priority"] == 3 class TestExtensionPriorityBackwardsCompatibility: @@ -11028,391 +8434,12 @@ def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir): assert "EXECUTE_COMMAND_INVOCATION: /" in message -class TestExtensionRemoveCLI: - """CLI tests for `specify extension remove` confirmation prompt wording.""" - - def _install_ext(self, project_dir, ext_dir): - """Install extension and return the manager.""" - manager = ExtensionManager(project_dir) - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - return manager - - def test_remove_confirmation_singular_command(self, tmp_path, extension_dir): - """Confirmation prompt should say '1 command' (singular) when one command registered.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - manager = self._install_ext(project_dir, extension_dir) - # Inject registered_commands with 1 entry so cmd_count == 1 - manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}}) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False - ) - - assert "1 command" in result.output - assert "1 commands" not in result.output - - def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): - """Confirmation prompt should say '2 commands' (plural) when two commands registered.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - manager = self._install_ext(project_dir, extension_dir) - # Inject registered_commands with 2 entries so cmd_count == 2 - manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}}) - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False - ) - - assert "2 commands" in result.output - - def test_remove_output_escapes_extension_id_markup(self, tmp_path): - """Removal paths and reinstall hints must not parse extension IDs as markup.""" - from types import SimpleNamespace - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - extension_id = "[red]bad[/red]" - installed = [ - { - "id": extension_id, - "name": "Bad Extension", - "version": "1.0.0", - "description": "Test extension", - "enabled": True, - } - ] - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionManager, "list_installed", return_value=installed), \ - patch.object(ExtensionManager, "get_extension", return_value=SimpleNamespace(commands=[])), \ - patch.object(ExtensionRegistry, "get", return_value={"registered_commands": {}, "registered_skills": []}), \ - patch.object(ExtensionManager, "remove", return_value=True): - result = runner.invoke( - app, - ["extension", "remove", extension_id, "--force"], - catch_exceptions=True, - ) - - assert result.exit_code == 0, result.output - assert ".specify/extensions/.backup/[red]bad[/red]/" in result.output - assert "specify extension add [red]bad[/red]" in result.output - - -class TestExtensionStateCLI: - """CLI tests for installed extension state commands.""" - - def test_enable_registry_error_escapes_extension_id_markup(self, tmp_path): - """Registry-corruption errors should render extension IDs literally.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - extension_id = "[red]bad[/red]" - installed = [ - { - "id": extension_id, - "name": "Bad Extension", - "version": "1.0.0", - "description": "Test extension", - "enabled": False, - } - ] - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionManager, "list_installed", return_value=installed), \ - patch.object(ExtensionRegistry, "get", return_value=None): - result = runner.invoke( - app, - ["extension", "enable", extension_id], - catch_exceptions=True, - ) - - assert result.exit_code == 1, result.output - assert "Extension '[red]bad[/red]' not found in registry" in result.output - - def test_disable_reenable_hint_escapes_extension_id_markup(self, tmp_path): - """Disable success hints should not parse extension IDs as markup.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - extension_id = "[red]bad[/red]" - installed = [ - { - "id": extension_id, - "name": "Bad Extension", - "version": "1.0.0", - "description": "Test extension", - "enabled": True, - } - ] - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir), \ - patch.object(ExtensionManager, "list_installed", return_value=installed), \ - patch.object(ExtensionRegistry, "get", return_value={"enabled": True}), \ - patch.object(ExtensionRegistry, "update", return_value=None), \ - patch.object(HookExecutor, "get_project_config", return_value={}): - result = runner.invoke( - app, - ["extension", "disable", extension_id], - catch_exceptions=True, - ) - - assert result.exit_code == 0, result.output - assert "specify extension enable [red]bad[/red]" in result.output - - -class TestClineExtensionHyphenation: - """Test that Cline integration uses hyphenated commands and frontmatter references.""" - - def _setup_mock_extension(self, tmp_path, ai_name): - import yaml - import json - - # 1. Setup mock project - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - - init_options = project_dir / ".specify" / "init-options.json" - init_options.write_text(json.dumps({"ai": ai_name}), encoding="utf-8") - - if ai_name == "cline": - commands_dest_dir = project_dir / ".clinerules" / "workflows" - else: - commands_dest_dir = project_dir / ".agents" / "commands" - commands_dest_dir.mkdir(parents=True, exist_ok=True) - - # 2. Setup mock extension directory - ext_dir = tmp_path / "mock-ext" - ext_dir.mkdir() - - manifest_data = { - "schema_version": "1.0", - "extension": { - "id": "mock-ext", - "name": "Mock Extension", - "version": "1.0.0", - "description": f"Mock extension for {ai_name} tests", - "author": "Tester", - "repository": "https://github.com/test/mock-ext", - "license": "MIT", - }, - "requires": { - "speckit_version": ">=0.1.0", - }, - "provides": { - "commands": [ - { - "name": "speckit.mock-ext.hello", - "file": "commands/hello.md", - "description": "Test hello command", - "aliases": ["speckit.mock-ext.greet"] - } - ] - } - } - - with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f: - yaml.dump(manifest_data, f) - - commands_dir = ext_dir / "commands" - commands_dir.mkdir() - - # Command file with dotted speckit references in frontmatter and body - cmd_content = """--- -description: "Test hello command" -agent: speckit.tasks -handoffs: - - agent: speckit.iterate.start - message: "Hand off to start" ---- - -# Test Hello Command - -Please refer to speckit.mock-ext.greet for instructions. -$ARGUMENTS -""" - (commands_dir / "hello.md").write_text(cmd_content, encoding="utf-8") - - return project_dir, ext_dir, commands_dest_dir - - def test_cline_extension_hyphenation(self, tmp_path): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - from specify_cli.agents import CommandRegistrar - - project_dir, ext_dir, cline_workflows_dir = self._setup_mock_extension(tmp_path, "cline") - - # 3. Run specify extension add - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False - ) - - # Verify CLI printed hyphenated commands - # Note: We assert that the primary command 'speckit-mock-ext-hello' is printed, - # but we do not assert that the alias 'speckit-mock-ext-greet' is printed in the console - # because manifest.commands only lists primary commands. - assert "speckit-mock-ext-hello" in result.output - assert "speckit.mock-ext.hello" not in result.output - - # Verify on-disk command names are hyphenated - hello_file = cline_workflows_dir / "speckit-mock-ext-hello.md" - greet_file = cline_workflows_dir / "speckit-mock-ext-greet.md" - - assert hello_file.exists() - assert greet_file.exists() - - # Verify frontmatter in the generated files is recursively hyphenated - hello_text = hello_file.read_text(encoding="utf-8") - hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text) - assert hello_fm["agent"] == "speckit-tasks" - assert hello_fm["handoffs"][0]["agent"] == "speckit-iterate-start" - - # Verify body references are hyphenated for Cline - assert "speckit-mock-ext-greet" in hello_body - assert "speckit.mock-ext.greet" not in hello_body - - def test_non_cline_extension_no_hyphenation(self, tmp_path): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - from specify_cli.agents import CommandRegistrar - - project_dir, ext_dir, agents_commands_dir = self._setup_mock_extension(tmp_path, "amp") - - # 3. Run specify extension add - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - result = runner.invoke( - app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False - ) - - # Verify CLI printed dotted commands - # Note: We assert that the primary command 'speckit.mock-ext.hello' is printed, - # but we do not assert that the alias 'speckit.mock-ext.greet' is printed in the console - # because manifest.commands only lists primary commands. - assert "speckit.mock-ext.hello" in result.output - assert "speckit-mock-ext-hello" not in result.output - - # Verify on-disk command names are dotted - hello_file = agents_commands_dir / "speckit.mock-ext.hello.md" - greet_file = agents_commands_dir / "speckit.mock-ext.greet.md" - - assert hello_file.exists() - assert greet_file.exists() - - # Verify frontmatter references are still dotted - hello_text = hello_file.read_text(encoding="utf-8") - hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text) - assert hello_fm["agent"] == "speckit.tasks" - assert hello_fm["handoffs"][0]["agent"] == "speckit.iterate.start" - - # Verify body references are still dotted for non-Cline - assert "speckit.mock-ext.greet" in hello_body - assert "speckit-mock-ext-greet" not in hello_body - - -class TestExtensionForceCLI: - """CLI tests for `specify extension add --dev --force`.""" - - def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path: - """Create a minimal extension directory with manifest.""" - import yaml - - ext_dir = Path(base_dir) / ext_id - ext_dir.mkdir(parents=True, exist_ok=True) - (ext_dir / "commands").mkdir() - - manifest = { - "schema_version": "1.0", - "extension": { - "id": ext_id, - "name": "Test Extension", - "version": "1.0.0", - "description": "Test", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": { - "commands": [ - { - "name": f"speckit.{ext_id}.hello", - "file": "commands/hello.md", - "description": "Test command", - } - ] - }, - } - (ext_dir / "extension.yml").write_text(yaml.dump(manifest)) - (ext_dir / "commands" / "hello.md").write_text( - "---\ndescription: Test\n---\n\nHello $ARGUMENTS\n" - ) - return ext_dir - def test_add_dev_force_reinstall(self, tmp_path): - """extension add --dev --force should reinstall without error.""" - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - project_dir = tmp_path / "project" - project_dir.mkdir() - (project_dir / ".specify").mkdir() - ext_src = self._create_minimal_extension(tmp_path) - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - # First install - result1 = runner.invoke( - app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False - ) - assert result1.exit_code == 0, strip_ansi(result1.output) - assert "installed" in strip_ansi(result1.output) - # Force reinstall - result2 = runner.invoke( - app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False - ) - assert result2.exit_code == 0, strip_ansi(result2.output) - assert "installed" in strip_ansi(result2.output) def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): @@ -11682,88 +8709,8 @@ def test_non_utf8_registry_does_not_crash(self, tmp_path, monkeypatch): assert cfg == {"url": "v"} -def test_forge_extension_install_listing_hyphenates_command_names( - extension_dir, project_dir -): - """The post-install 'Provided commands' listing must show hyphenated - /speckit- command names for a Forge project (Forge registers - hyphenated names), mirroring the existing Cline handling.""" - import json - import os - - from typer.testing import CliRunner - from specify_cli import app - init_options = project_dir / ".specify" / "init-options.json" - init_options.write_text(json.dumps({"ai": "forge", "script": "sh"})) - - old_cwd = os.getcwd() - try: - os.chdir(project_dir) - result = CliRunner().invoke( - app, ["extension", "add", str(extension_dir), "--dev"] - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0, result.output - # Forge registers hyphenated command names, so the summary must match. - assert "speckit-test-ext-hello" in result.output - assert "speckit.test-ext.hello" not in result.output - - -def test_forge_extension_info_hyphenates_command_names( - extension_dir, project_dir, monkeypatch -): - """`extension info` for an installed extension must show hyphenated - /speckit- command names on a Forge project, matching the names Forge - actually registers — the same parity `extension add`'s listing already has. - """ - import io - import json - import os - - from rich.console import Console - - from specify_cli.extensions import _commands - - init_options = project_dir / ".specify" / "init-options.json" - init_options.write_text(json.dumps({"ai": "forge", "script": "sh"})) - - manager = ExtensionManager(project_dir) - manager.install_from_directory( - extension_dir, "1.0.0", register_commands=False - ) - - # Force the "installed locally, not in catalog" branch (the one that prints - # the local manifest's Commands section) and avoid any network catalog - # lookup. - monkeypatch.setattr( - _commands, "_resolve_catalog_extension", lambda *a, **k: (None, None) - ) - - # Call the handler directly against a plain captured Console. (Driving it - # through CliRunner reformats output via Rich's live console, which - # recurses under pytest's captured stdout — unrelated to this code path.) - buf = io.StringIO() - original_console = _commands.console - _commands.console = Console(file=buf, force_terminal=False, width=200) - old_cwd = os.getcwd() - try: - os.chdir(project_dir) - _commands.extension_info("test-ext") - except SystemExit: - pass - finally: - os.chdir(old_cwd) - _commands.console = original_console - - output = buf.getvalue() - # The Commands section must render the hyphenated form Forge registers, - # not the manifest's dotted name. - assert "speckit-test-ext-hello" in output, output - assert "speckit.test-ext.hello" not in output, output # ===== Extension Config Scaffolding Tests =====