diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 99b312621f..1d2b7ea6e0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -57,7 +57,7 @@ ) from ._assets import ( _locate_bundled_extension, - _locate_bundled_preset, + _locate_bundled_preset as _locate_bundled_preset, _locate_bundled_workflow as _locate_bundled_workflow, _locate_core_pack, _repo_root, @@ -586,20 +586,6 @@ def version( ) extension_app.add_typer(catalog_app, name="catalog") -preset_app = typer.Typer( - name="preset", - help="Manage spec-kit presets", - add_completion=False, -) -app.add_typer(preset_app, name="preset") - -preset_catalog_app = typer.Typer( - name="catalog", - help="Manage preset catalogs", - add_completion=False, -) -preset_app.add_typer(preset_catalog_app, name="catalog") - # ===== Integration Commands ===== @@ -627,620 +613,9 @@ def _require_specify_project() -> Path: # ===== Preset Commands ===== - -@preset_app.command("list") -def preset_list(): - """List installed presets.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - installed = manager.list_installed() - - if not installed: - console.print("[yellow]No presets installed.[/yellow]") - console.print("\nInstall a preset with:") - console.print(" [cyan]specify preset add [/cyan]") - return - - console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") - for pack in installed: - status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" - pri = pack.get('priority', 10) - console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") - console.print(f" {pack['description']}") - if pack.get("tags"): - tags_str = ", ".join(pack["tags"]) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print(f" [dim]Templates: {pack['template_count']}[/dim]") - console.print() - - -@preset_app.command("add") -def preset_add( - preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), - from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), - dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install a preset.""" - from .presets import ( - PresetManager, - PresetCatalog, - PresetError, - PresetValidationError, - PresetCompatibilityError, - ) - - 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 = PresetManager(project_root) - speckit_version = get_speckit_version() - - try: - if dev: - dev_path = Path(dev).resolve() - if not dev_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {dev}") - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") - manifest = manager.install_from_directory(dev_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif from_url: - # Validate URL scheme before downloading - from urllib.parse import urlparse as _urlparse - _parsed = _urlparse(from_url) - _is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1") - if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost): - console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.") - raise typer.Exit(1) - - console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") - import urllib.error - import tempfile - - with tempfile.TemporaryDirectory() as tmpdir: - zip_path = Path(tmpdir) / "preset.zip" - try: - from specify_cli.authentication.http import open_url as _open_url - from specify_cli._github_http import resolve_github_release_asset_api_url - - _preset_extra_headers = None - _resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url) - if _resolved_from_url: - from_url = _resolved_from_url - _preset_extra_headers = {"Accept": "application/octet-stream"} - - with _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) as response: - zip_path.write_bytes(response.read()) - except urllib.error.URLError as e: - console.print(f"[red]Error:[/red] Failed to download: {e}") - raise typer.Exit(1) - - manifest = manager.install_from_zip(zip_path, speckit_version, priority) - - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - - elif preset_id: - # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(preset_id) - if bundled_path: - console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") - manifest = manager.install_from_directory(bundled_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - else: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(preset_id) - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") - raise typer.Exit(1) - - # Bundled presets should have been caught above; if we reach - # here the bundled files are missing from the installation. - if pack_info.get("bundled") and not pack_info.get("download_url"): - from .extensions import REINSTALL_COMMAND - console.print( - f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "\nThis usually means the spec-kit installation is incomplete or corrupted." - ) - console.print("Try reinstalling spec-kit:") - console.print(f" {REINSTALL_COMMAND}") - raise typer.Exit(1) - - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") - console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") - raise typer.Exit(1) - - console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") - - try: - zip_path = catalog.download_pack(preset_id) - manifest = manager.install_from_zip(zip_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - finally: - if 'zip_path' in locals() and zip_path.exists(): - zip_path.unlink(missing_ok=True) - else: - console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") - raise typer.Exit(1) - - except PresetCompatibilityError as e: - console.print(f"[red]Compatibility Error:[/red] {e}") - raise typer.Exit(1) - except PresetValidationError as e: - console.print(f"[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except PresetError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - -@preset_app.command("remove") -def preset_remove( - preset_id: str = typer.Argument(..., help="Preset ID to remove"), -): - """Remove an installed preset.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - if manager.remove(preset_id): - console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") - else: - console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") - raise typer.Exit(1) - - -@preset_app.command("search") -def preset_search( - query: str = typer.Argument(None, help="Search query"), - tag: str = typer.Option(None, "--tag", help="Filter by tag"), - author: str = typer.Option(None, "--author", help="Filter by author"), -): - """Search for presets in the catalog.""" - from .presets import PresetCatalog, PresetError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - results = catalog.search(query=query, tag=tag, author=author) - except PresetError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - if not results: - console.print("[yellow]No presets found matching your criteria.[/yellow]") - return - - console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") - for pack in results: - console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") - console.print(f" {pack.get('description', '')}") - if pack.get("tags"): - tags_str = ", ".join(pack["tags"]) - console.print(f" [dim]Tags: {tags_str}[/dim]") - console.print() - - -@preset_app.command("resolve") -def preset_resolve( - template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), -): - """Show which template will be resolved for a given name.""" - from .presets import PresetResolver - - project_root = _require_specify_project() - resolver = PresetResolver(project_root) - layers = resolver.collect_all_layers(template_name) - - if layers: - # Use the highest-priority layer for display because the final output - # may be composed and may not map to resolve_with_source()'s single path. - display_layer = layers[0] - console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") - console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") - - has_composition = ( - layers[0]["strategy"] != "replace" - and any(layer["strategy"] != "replace" for layer in layers) - ) - if has_composition: - # Verify composition is actually possible - try: - composed = resolver.resolve_content(template_name) - except Exception as exc: - composed = None - console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") - if composed is None: - console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") - else: - console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") - console.print("\n [bold]Composition chain:[/bold]") - # Compute the effective base: first replace layer scanning from - # highest priority (matching resolve_content top-down logic). - # Only show layers from the base upward (lower layers are ignored). - effective_base_idx = None - for idx, lyr in enumerate(layers): - if lyr["strategy"] == "replace": - effective_base_idx = idx - break - # Show only contributing layers (base and above) - if effective_base_idx is not None: - contributing = layers[:effective_base_idx + 1] - else: - contributing = layers - for i, layer in enumerate(reversed(contributing)): - strategy_label = layer["strategy"] - if strategy_label == "replace" and i == 0: - strategy_label = "base" - console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") - else: - # No layers found — fall back to resolve_with_source for non-composition cases - result = resolver.resolve_with_source(template_name) - if result: - console.print(f" [bold]{template_name}[/bold]: {result['path']}") - console.print(f" [dim](from: {result['source']})[/dim]") - else: - console.print(f" [yellow]{template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") - - -@preset_app.command("info") -def preset_info( - preset_id: str = typer.Argument(..., help="Preset ID to get info about"), -): - """Show detailed information about a preset.""" - from .extensions import normalize_priority - from .presets import PresetCatalog, PresetManager, PresetError - - project_root = _require_specify_project() - # Check if installed locally first - manager = PresetManager(project_root) - local_pack = manager.get_pack(preset_id) - - if local_pack: - console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") - console.print(f" ID: {local_pack.id}") - console.print(f" Version: {local_pack.version}") - console.print(f" Description: {local_pack.description}") - if local_pack.author: - console.print(f" Author: {local_pack.author}") - if local_pack.tags: - console.print(f" Tags: {', '.join(local_pack.tags)}") - console.print(f" Templates: {len(local_pack.templates)}") - for tmpl in local_pack.templates: - console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") - repo = local_pack.data.get("preset", {}).get("repository") - if repo: - console.print(f" Repository: {repo}") - license_val = local_pack.data.get("preset", {}).get("license") - if license_val: - console.print(f" License: {license_val}") - console.print("\n [green]Status: installed[/green]") - # Get priority from registry - pack_metadata = manager.registry.get(preset_id) - priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) - console.print(f" [dim]Priority:[/dim] {priority}") - console.print() - return - - # Fall back to catalog - catalog = PresetCatalog(project_root) - try: - pack_info = catalog.get_pack_info(preset_id) - except PresetError: - pack_info = None - - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") - raise typer.Exit(1) - - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") - console.print(f" ID: {pack_info['id']}") - console.print(f" Version: {pack_info.get('version', '?')}") - console.print(f" Description: {pack_info.get('description', '')}") - if pack_info.get("author"): - console.print(f" Author: {pack_info['author']}") - if pack_info.get("tags"): - console.print(f" Tags: {', '.join(pack_info['tags'])}") - if pack_info.get("repository"): - console.print(f" Repository: {pack_info['repository']}") - if pack_info.get("license"): - console.print(f" License: {pack_info['license']}") - console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") - console.print() - - -@preset_app.command("set-priority") -def preset_set_priority( - preset_id: str = typer.Argument(help="Preset ID"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), -): - """Set the resolution priority of an installed preset.""" - from .presets import PresetManager - - 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 = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - from .extensions import normalize_priority - raw_priority = metadata.get("priority") - # Only skip if the stored value is already a valid int equal to requested priority - # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) - if isinstance(raw_priority, int) and raw_priority == priority: - console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(preset_id, {"priority": priority}) - - console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") - - -@preset_app.command("enable") -def preset_enable( - preset_id: str = typer.Argument(help="Preset ID to enable"), -): - """Enable a disabled preset.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") - raise typer.Exit(0) - - # Enable the preset - manager.registry.update(preset_id, {"enabled": True}) - - console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") - console.print("\nTemplates from this preset will now be included in resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") - - -@preset_app.command("disable") -def preset_disable( - preset_id: str = typer.Argument(help="Preset ID to disable"), -): - """Disable a preset without removing it.""" - from .presets import PresetManager - - project_root = _require_specify_project() - manager = PresetManager(project_root) - - # Check if preset is installed - if not manager.registry.is_installed(preset_id): - console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") - raise typer.Exit(1) - - # Get current metadata - metadata = manager.registry.get(preset_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") - raise typer.Exit(1) - - if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") - raise typer.Exit(0) - - # Disable the preset - manager.registry.update(preset_id, {"enabled": False}) - - console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") - console.print("\nTemplates from this preset will be skipped during resolution.") - console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") - console.print(f"To re-enable: specify preset enable {preset_id}") - - -# ===== Preset Catalog Commands ===== - - -@preset_catalog_app.command("list") -def preset_catalog_list(): - """List all active preset catalogs.""" - from .presets import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - catalog = PresetCatalog(project_root) - - try: - active_catalogs = catalog.get_active_catalogs() - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") - for entry in active_catalogs: - install_str = ( - "[green]install allowed[/green]" - if entry.install_allowed - else "[yellow]discovery only[/yellow]" - ) - console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") - if entry.description: - console.print(f" {entry.description}") - console.print(f" URL: {entry.url}") - console.print(f" Install: {install_str}") - console.print() - - config_path = project_root / ".specify" / "preset-catalogs.yml" - user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" - if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") - else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except PresetValidationError: - proj_loaded = False - if proj_loaded: - console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") - else: - try: - user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None - except PresetValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" - ) - - -@preset_catalog_app.command("add") -def preset_catalog_add( - url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), - name: str = typer.Option(..., "--name", help="Catalog name"), - priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), - install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", - help="Allow presets from this catalog to be installed", - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), -): - """Add a catalog to .specify/preset-catalogs.yml.""" - from .presets import PresetCatalog, PresetValidationError - - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - # Validate URL - tmp_catalog = PresetCatalog(project_root) - try: - tmp_catalog._validate_catalog_url(url) - except PresetValidationError as e: - console.print(f"[red]Error:[/red] {e}") - raise typer.Exit(1) - - config_path = specify_dir / "preset-catalogs.yml" - - # Load existing config - if config_path.exists(): - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception as e: - config_label = _display_project_path(project_root, config_path) - console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") - raise typer.Exit(1) - else: - config = {} - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - - # 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 '{name}' already exists.") - console.print("Use 'specify preset catalog remove' first, or choose a different name.") - raise typer.Exit(1) - - catalogs.append({ - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - }) - - config["catalogs"] = catalogs - config_path.write_text(yaml.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]{name}[/bold]' ({install_label})") - console.print(f" URL: {url}") - console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") - - -@preset_catalog_app.command("remove") -def preset_catalog_remove( - name: str = typer.Argument(help="Catalog name to remove"), -): - """Remove a catalog from .specify/preset-catalogs.yml.""" - project_root = _require_specify_project() - specify_dir = project_root / ".specify" - - config_path = specify_dir / "preset-catalogs.yml" - if not config_path.exists(): - console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") - raise typer.Exit(1) - - try: - config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - except Exception: - console.print("[red]Error:[/red] Failed to read preset catalog config.") - raise typer.Exit(1) - - catalogs = config.get("catalogs", []) - if not isinstance(catalogs, list): - console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") - raise typer.Exit(1) - 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 '{name}' not found.") - raise typer.Exit(1) - - config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - - console.print(f"[green]✓[/green] Removed catalog '{name}'") - if not catalogs: - console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") +# Moved to presets/_commands.py — registered here to preserve CLI surface. +from .presets._commands import register as _register_preset_cmds # noqa: E402 +_register_preset_cmds(app) # ===== Extension Commands ===== diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets/__init__.py similarity index 98% rename from src/specify_cli/presets.py rename to src/specify_cli/presets/__init__.py index ebb99e7fb3..0de4afecda 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets/__init__.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, Optional, Dict, List, Any if TYPE_CHECKING: - from .agents import CommandRegistrar + from ..agents import CommandRegistrar from datetime import datetime, timezone import re @@ -27,9 +27,9 @@ from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier -from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority -from .integrations.base import IntegrationBase -from ._init_options import is_ai_skills_enabled +from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority +from .._init_options import is_ai_skills_enabled +from ..integrations.base import IntegrationBase def _substitute_core_template( @@ -676,7 +676,7 @@ def _register_commands( commands_to_register.append(cmd) try: - from .agents import CommandRegistrar + from ..agents import CommandRegistrar except ImportError: return {} @@ -692,7 +692,7 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registered_commands: Dict mapping agent names to command name lists """ try: - from .agents import CommandRegistrar + from ..agents import CommandRegistrar except ImportError: return @@ -715,7 +715,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: return try: - from .agents import CommandRegistrar + from ..agents import CommandRegistrar except ImportError: return @@ -767,7 +767,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None: ext_manifest_path = ext_dir / "extension.yml" if ext_manifest_path.exists(): try: - from .extensions import ExtensionManifest + from ..extensions import ExtensionManifest ext_manifest = ExtensionManifest(ext_manifest_path) # Filter to only the command being reconciled matching_cmds = [ @@ -891,7 +891,7 @@ def _register_command_from_path( # Load aliases from extension manifest when the winning layer is an extension if source_id and not source_id.startswith("preset:"): try: - from .extensions import ExtensionManifest + from ..extensions import ExtensionManifest for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): if not ext_dir.is_dir(): continue @@ -1042,8 +1042,8 @@ def _reconcile_skills(self, command_names: List[str]) -> None: skill_subdir.mkdir(parents=True, exist_ok=True) skill_file = skill_subdir / "SKILL.md" try: - from .agents import CommandRegistrar - from . import SKILL_DESCRIPTIONS, load_init_options + from ..agents import CommandRegistrar + from .. import SKILL_DESCRIPTIONS, load_init_options registrar = CommandRegistrar() content = top_layer["path"].read_text(encoding="utf-8") fm, body = registrar.parse_frontmatter(content) @@ -1075,7 +1075,7 @@ def _reconcile_skills(self, command_names: List[str]) -> None: f"# Speckit {skill_title} Skill\n\n{body}\n" ) # Apply integration post-processing (e.g. Claude flags) - from .integrations import get_integration + from ..integrations import get_integration integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None if integration is not None and hasattr(integration, "post_process_skill_content"): skill_content = integration.post_process_skill_content(skill_content) @@ -1110,7 +1110,7 @@ def _get_skills_dir(self) -> Optional[Path]: be created due to symlink, containment, or permission issues so that callers can fall back gracefully. """ - from . import resolve_active_skills_dir, _print_cli_warning + from .. import resolve_active_skills_dir, _print_cli_warning try: return resolve_active_skills_dir(self.project_root) except (ValueError, OSError) as exc: @@ -1158,7 +1158,7 @@ def _resolve_skill_command_refs( def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: """Index extension-backed skill restore data by skill directory name.""" - from .extensions import ExtensionManifest, ValidationError + from ..extensions import ExtensionManifest, ValidationError resolver = PresetResolver(self.project_root) extensions_dir = self.project_root / ".specify" / "extensions" @@ -1253,9 +1253,9 @@ def _register_skills( if not skills_dir: return [] - from . import SKILL_DESCRIPTIONS, load_init_options - from .agents import CommandRegistrar - from .integrations import get_integration + from .. import SKILL_DESCRIPTIONS, load_init_options + from ..agents import CommandRegistrar + from ..integrations import get_integration init_opts = load_init_options(self.project_root) if not isinstance(init_opts, dict): @@ -1382,9 +1382,9 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: if not skills_dir: return - from . import SKILL_DESCRIPTIONS, load_init_options - from .agents import CommandRegistrar - from .integrations import get_integration + from .. import SKILL_DESCRIPTIONS, load_init_options + from ..agents import CommandRegistrar + from ..integrations import get_integration # Locate core command templates from the project's installed templates core_templates_dir = self.project_root / ".specify" / "templates" / "commands" @@ -1712,7 +1712,7 @@ def remove(self, pack_id: str) -> bool: if registered_skills: self._unregister_skills(registered_skills, pack_dir) try: - from .agents import CommandRegistrar + from ..agents import CommandRegistrar except ImportError: CommandRegistrar = None if CommandRegistrar is not None: @@ -2308,7 +2308,7 @@ def download_pack( # Bundled presets without a download URL must be installed locally if pack_info.get("bundled") and not pack_info.get("download_url"): - from .extensions import REINSTALL_COMMAND + from ..extensions import REINSTALL_COMMAND raise PresetError( f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. " f"It should be installed from the local package. " @@ -2627,7 +2627,7 @@ def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path if not self.extensions_dir.exists(): return None - from .extensions import ExtensionManifest, ValidationError + from ..extensions import ExtensionManifest, ValidationError for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): ext_dir = self.extensions_dir / ext_id @@ -2853,7 +2853,7 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]: ext_manifest_path = ext_dir / "extension.yml" if ext_manifest_path.exists(): try: - from .extensions import ExtensionManifest, ValidationError as ExtValidationError + from ..extensions import ExtensionManifest, ValidationError as ExtValidationError ext_manifest = ExtensionManifest(ext_manifest_path) for cmd in ext_manifest.commands: if cmd.get("name") == template_name: diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py new file mode 100644 index 0000000000..e7ff8e476b --- /dev/null +++ b/src/specify_cli/presets/_commands.py @@ -0,0 +1,693 @@ +"""specify preset * command handlers — app objects and register() entry point. + +Moved out of __init__.py (PR-6/8). Handlers reference helpers that remain in +the package root (`_require_specify_project`, `get_speckit_version`, +`_locate_bundled_preset`, `_display_project_path`) via lazy `from .. import` +calls inside each function so test monkeypatching of `specify_cli.` +keeps working. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import typer +import yaml + +from .._console import console + +preset_app = typer.Typer( + name="preset", + help="Manage spec-kit presets", + add_completion=False, +) + +preset_catalog_app = typer.Typer( + name="catalog", + help="Manage preset catalogs", + add_completion=False, +) +preset_app.add_typer(preset_catalog_app, name="catalog") + + +# ===== Preset Commands ===== + + +@preset_app.command("list") +def preset_list(): + """List installed presets.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + installed = manager.list_installed() + + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return + + console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") + for pack in installed: + status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" + pri = pack.get('priority', 10) + console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}") + console.print(f" {pack['description']}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print(f" [dim]Templates: {pack['template_count']}[/dim]") + console.print() + + +@preset_app.command("add") +def preset_add( + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), + dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), +): + """Install a preset.""" + from .. import _locate_bundled_preset, _require_specify_project, get_speckit_version + from . import ( + PresetManager, + PresetCatalog, + PresetError, + PresetValidationError, + PresetCompatibilityError, + ) + + 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 = PresetManager(project_root) + speckit_version = get_speckit_version() + + try: + if dev: + dev_path = Path(dev).resolve() + if not dev_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {dev}") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{dev_path}[/cyan]...") + manifest = manager.install_from_directory(dev_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif from_url: + # Validate URL scheme before downloading + from ipaddress import ip_address + from urllib.parse import urlparse as _urlparse + + _parsed = _urlparse(from_url) + + def _is_allowed_download_url(parsed_url): + host = parsed_url.hostname or "" + is_loopback = host == "localhost" + if not is_loopback: + try: + is_loopback = ip_address(host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback) + + if not _is_allowed_download_url(_parsed): + console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost/loopback.") + raise typer.Exit(1) + + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") + import urllib.error + import tempfile + import shutil + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = Path(tmpdir) / "preset.zip" + try: + from specify_cli.authentication.http import open_url as _open_url + from specify_cli._github_http import resolve_github_release_asset_api_url + + _preset_extra_headers = None + _resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url) + if _resolved_from_url: + from_url = _resolved_from_url + _preset_extra_headers = {"Accept": "application/octet-stream"} + + if _preset_extra_headers: + response_context = _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) + else: + response_context = _open_url(from_url, timeout=60) + + with response_context as response: + final_url = response.geturl() if hasattr(response, "geturl") else from_url + if not _is_allowed_download_url(_urlparse(final_url)): + console.print(f"[red]Error:[/red] Preset URL redirected to non-HTTPS URL: {final_url}") + raise typer.Exit(1) + with zip_path.open("wb") as output: + try: + shutil.copyfileobj(response, output) + except TypeError: + output.write(response.read()) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download: {e}") + raise typer.Exit(1) + + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + + elif preset_id: + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(preset_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") + manifest = manager.install_from_directory(bundled_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(preset_id) + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") + raise typer.Exit(1) + + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from ..extensions import REINSTALL_COMMAND + console.print( + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) + + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(preset_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) + else: + console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") + raise typer.Exit(1) + + except PresetCompatibilityError as e: + console.print(f"[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except PresetValidationError as e: + console.print(f"[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + +@preset_app.command("remove") +def preset_remove( + preset_id: str = typer.Argument(..., help="Preset ID to remove"), +): + """Remove an installed preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") + else: + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") + raise typer.Exit(1) + + +@preset_app.command("search") +def preset_search( + query: str = typer.Argument(None, help="Search query"), + tag: str = typer.Option(None, "--tag", help="Filter by tag"), + author: str = typer.Option(None, "--author", help="Filter by author"), +): + """Search for presets in the catalog.""" + from .. import _require_specify_project + from . import PresetCatalog, PresetError + + project_root = _require_specify_project() + catalog = PresetCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No presets found matching your criteria.[/yellow]") + return + + console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") + for pack in results: + console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") + console.print(f" {pack.get('description', '')}") + if pack.get("tags"): + tags_str = ", ".join(pack["tags"]) + console.print(f" [dim]Tags: {tags_str}[/dim]") + console.print() + + +@preset_app.command("resolve") +def preset_resolve( + template_name: str = typer.Argument(..., help="Template name to resolve (e.g., spec-template)"), +): + """Show which template will be resolved for a given name.""" + from .. import _require_specify_project + from . import PresetResolver + + project_root = _require_specify_project() + resolver = PresetResolver(project_root) + layers = resolver.collect_all_layers(template_name) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") + console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") + + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name) + except Exception as exc: + composed = None + console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") + if composed is None: + console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") + else: + console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[:effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") + else: + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name) + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + + +@preset_app.command("info") +def preset_info( + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), +): + """Show detailed information about a preset.""" + from .. import _require_specify_project + from ..extensions import normalize_priority + from . import PresetCatalog, PresetManager, PresetError + + project_root = _require_specify_project() + # Check if installed locally first + manager = PresetManager(project_root) + local_pack = manager.get_pack(preset_id) + + if local_pack: + console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") + console.print(f" ID: {local_pack.id}") + console.print(f" Version: {local_pack.version}") + console.print(f" Description: {local_pack.description}") + if local_pack.author: + console.print(f" Author: {local_pack.author}") + if local_pack.tags: + console.print(f" Tags: {', '.join(local_pack.tags)}") + console.print(f" Templates: {len(local_pack.templates)}") + for tmpl in local_pack.templates: + console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}") + repo = local_pack.data.get("preset", {}).get("repository") + if repo: + console.print(f" Repository: {repo}") + license_val = local_pack.data.get("preset", {}).get("license") + if license_val: + console.print(f" License: {license_val}") + console.print("\n [green]Status: installed[/green]") + # Get priority from registry + pack_metadata = manager.registry.get(preset_id) + priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) + console.print(f" [dim]Priority:[/dim] {priority}") + console.print() + return + + # Fall back to catalog + catalog = PresetCatalog(project_root) + try: + pack_info = catalog.get_pack_info(preset_id) + except PresetError: + pack_info = None + + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") + raise typer.Exit(1) + + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") + console.print(f" ID: {pack_info['id']}") + console.print(f" Version: {pack_info.get('version', '?')}") + console.print(f" Description: {pack_info.get('description', '')}") + if pack_info.get("author"): + console.print(f" Author: {pack_info['author']}") + if pack_info.get("tags"): + console.print(f" Tags: {', '.join(pack_info['tags'])}") + if pack_info.get("repository"): + console.print(f" Repository: {pack_info['repository']}") + if pack_info.get("license"): + console.print(f" License: {pack_info['license']}") + console.print("\n [yellow]Status: not installed[/yellow]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") + console.print() + + +@preset_app.command("set-priority") +def preset_set_priority( + preset_id: str = typer.Argument(help="Preset ID"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + from ..extensions import normalize_priority + raw_priority = metadata.get("priority") + # Only skip if the stored value is already a valid int equal to requested priority + # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) + if isinstance(raw_priority, int) and raw_priority == priority: + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") + raise typer.Exit(0) + + old_priority = normalize_priority(raw_priority) + + # Update priority + manager.registry.update(preset_id, {"priority": priority}) + + console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + + +@preset_app.command("enable") +def preset_enable( + preset_id: str = typer.Argument(help="Preset ID to enable"), +): + """Enable a disabled preset.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") + raise typer.Exit(0) + + # Enable the preset + manager.registry.update(preset_id, {"enabled": True}) + + console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") + console.print("\nTemplates from this preset will now be included in resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") + + +@preset_app.command("disable") +def preset_disable( + preset_id: str = typer.Argument(help="Preset ID to disable"), +): + """Disable a preset without removing it.""" + from .. import _require_specify_project + from . import PresetManager + + project_root = _require_specify_project() + manager = PresetManager(project_root) + + # Check if preset is installed + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") + raise typer.Exit(1) + + # Get current metadata + metadata = manager.registry.get(preset_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + + if not metadata.get("enabled", True): + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") + raise typer.Exit(0) + + # Disable the preset + manager.registry.update(preset_id, {"enabled": False}) + + console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") + console.print("\nTemplates from this preset will be skipped during resolution.") + console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") + console.print(f"To re-enable: specify preset enable {preset_id}") + + +# ===== Preset Catalog Commands ===== + + +@preset_catalog_app.command("list") +def preset_catalog_list(): + """List all active preset catalogs.""" + from .. import _display_project_path, _require_specify_project + from . import PresetCatalog, PresetValidationError + + project_root = _require_specify_project() + catalog = PresetCatalog(project_root) + + try: + active_catalogs = catalog.get_active_catalogs() + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})") + if entry.description: + console.print(f" {entry.description}") + console.print(f" URL: {entry.url}") + console.print(f" Install: {install_str}") + console.print() + + config_path = project_root / ".specify" / "preset-catalogs.yml" + user_config_path = Path.home() / ".specify" / "preset-catalogs.yml" + if os.environ.get("SPECKIT_PRESET_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]") + else: + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except PresetValidationError: + proj_loaded = False + if proj_loaded: + console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") + else: + try: + user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None + except PresetValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/preset-catalogs.yml to customize.[/dim]" + ) + + +@preset_catalog_app.command("add") +def preset_catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow presets from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/preset-catalogs.yml.""" + from .. import _display_project_path, _require_specify_project + from . import PresetCatalog, PresetValidationError + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + # Validate URL + tmp_catalog = PresetCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except PresetValidationError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) + + config_path = specify_dir / "preset-catalogs.yml" + + # Load existing config + if config_path.exists(): + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception as e: + config_label = _display_project_path(project_root, config_path) + console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") + raise typer.Exit(1) + else: + config = {} + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + + # 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 '{name}' already exists.") + console.print("Use 'specify preset catalog remove' first, or choose a different name.") + raise typer.Exit(1) + + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) + + config["catalogs"] = catalogs + config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + + install_label = "install allowed" if install_allowed else "discovery only" + console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") + console.print(f" URL: {url}") + console.print(f" Priority: {priority}") + console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") + + +@preset_catalog_app.command("remove") +def preset_catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/preset-catalogs.yml.""" + from .. import _require_specify_project + + project_root = _require_specify_project() + specify_dir = project_root / ".specify" + + config_path = specify_dir / "preset-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No preset catalog config found. Nothing to remove.") + raise typer.Exit(1) + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read preset catalog config.") + raise typer.Exit(1) + + catalogs = config.get("catalogs", []) + if not isinstance(catalogs, list): + console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.") + raise typer.Exit(1) + 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 '{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 '{name}'") + if not catalogs: + console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") + + +def register(app: typer.Typer) -> None: + """Attach the preset command group to the root Typer app.""" + app.add_typer(preset_app, name="preset") diff --git a/tests/test_presets.py b/tests/test_presets.py index ecd21a7b6f..cb5c7c9620 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -11,6 +11,7 @@ """ import pytest +import io import json import tempfile import shutil @@ -18,6 +19,7 @@ import zipfile from pathlib import Path from datetime import datetime, timezone +from types import SimpleNamespace import yaml @@ -3842,6 +3844,85 @@ def test_bundled_preset_add_via_cli(self, project_dir): assert "Lean Workflow" in result.output assert "installed" in result.output.lower() + def test_preset_add_from_url_rejects_insecure_redirect(self, project_dir, monkeypatch): + """URL installs reject redirects from HTTPS to non-loopback HTTP.""" + import typer + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "http://example.com/preset.zip" + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr("specify_cli.authentication.http.open_url", lambda url, timeout: FakeResponse(b"zip")) + + installed = False + + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + nonlocal installed + installed = True + + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + with pytest.raises(typer.Exit) as exc_info: + preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10) + + assert exc_info.value.exit_code == 1 + assert installed is False + + def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch): + """URL installs stream response bytes to disk before installing the ZIP.""" + from specify_cli.presets._commands import preset_add + + class FakeResponse(io.BytesIO): + def __init__(self, data): + super().__init__(data) + self.read_sizes = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def geturl(self): + return "https://example.com/preset.zip" + + def read(self, size=-1): + assert size not in (-1, None) + self.read_sizes.append(size) + return super().read(size) + + response = FakeResponse(b"zip-bytes") + installed = {} + + def fake_install_from_zip(self, zip_path, speckit_version, priority=10): + installed["zip_bytes"] = Path(zip_path).read_bytes() + installed["speckit_version"] = speckit_version + installed["priority"] = priority + return SimpleNamespace(name="Test Preset", version="1.0.0") + + monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir) + monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0") + monkeypatch.setattr("specify_cli.authentication.http.open_url", lambda url, timeout: response) + monkeypatch.setattr(PresetManager, "install_from_zip", fake_install_from_zip) + + preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=7) + + assert response.read_sizes + assert installed == { + "zip_bytes": b"zip-bytes", + "speckit_version": "0.6.0", + "priority": 7, + } + def test_bundled_preset_in_catalog(self): """Verify the lean preset is listed in catalog.json with bundled marker.""" catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"