"""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 if not host: return False 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) def _validate_download_redirect(old_url, new_url): if not _is_allowed_download_url(_urlparse(new_url)): import urllib.error raise urllib.error.URLError( "redirect target must use HTTPS with a hostname, " "or HTTP for localhost/loopback" ) if not _is_allowed_download_url(_parsed): console.print( "[red]Error:[/red] URL must use HTTPS with a hostname, " "or HTTP 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"} with _open_url( from_url, timeout=60, extra_headers=_preset_extra_headers, redirect_validator=_validate_download_redirect, ) as response: final_url = response.geturl() if hasattr(response, "geturl") else from_url if not _is_allowed_download_url(_urlparse(final_url)): console.print( "[red]Error:[/red] Preset URL redirected to a disallowed URL: " f"{final_url}. Redirect targets must use HTTPS with a hostname, " "or HTTP for localhost/loopback." ) 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")