From f94409850223c431a3ec9499ab1d4ca54df75fb0 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 3 Jun 2026 10:40:18 +0800 Subject: [PATCH 1/3] refactor(presets): convert presets.py module to presets/ package Pure structural move to mirror integrations/. presets.py becomes presets/__init__.py with relative imports rebased one level deeper. No behavior change; public import surface (from .presets import ...) preserved. Prepares for co-locating preset command handlers in PR-6/8. --- .../{presets.py => presets/__init__.py} | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) rename src/specify_cli/{presets.py => presets/__init__.py} (98%) 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: From 4bdb40afee09fced12506941d20c1f5479a2df47 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 3 Jun 2026 10:46:33 +0800 Subject: [PATCH 2/3] refactor: move preset command handlers to presets/_commands.py (PR-6/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut the preset_app / preset_catalog_app Typer groups and all 12 command handlers out of __init__.py into presets/_commands.py, exposing register(app) — mirrors the integration co-location from PR-5. __init__.py now registers via _register_preset_cmds(app), dropping ~620 lines (3282 -> 2663). Handlers lazy-import root helpers (_require_specify_project, get_speckit_version, _locate_bundled_preset, _display_project_path) via 'from .. import' so test monkeypatching of specify_cli. keeps working. _locate_bundled_preset kept as an explicit re-export in __init__.py for that resolution path. CLI surface and public imports unchanged. Full suite: 3162 passed, 40 skipped. --- src/specify_cli/__init__.py | 633 +------------------------ src/specify_cli/presets/_commands.py | 660 +++++++++++++++++++++++++++ 2 files changed, 664 insertions(+), 629 deletions(-) create mode 100644 src/specify_cli/presets/_commands.py 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/_commands.py b/src/specify_cli/presets/_commands.py new file mode 100644 index 0000000000..d351600cb2 --- /dev/null +++ b/src/specify_cli/presets/_commands.py @@ -0,0 +1,660 @@ +"""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 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.request + 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 + + with _open_url(from_url, timeout=60) 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 .. 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.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.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") From 844e31680f6c3654a94201d66e2b58a00efcf669 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Thu, 4 Jun 2026 00:09:28 +0800 Subject: [PATCH 3/3] Harden preset URL installs against unsafe redirects Preset URL installs already rejected non-HTTPS source URLs, but the authenticated opener follows redirects. Validate the final response URL before writing the ZIP, preserve GitHub release asset URL resolution after the preset command module split, stream the response to disk, and keep catalog config serialization on safe YAML output. Constraint: open_url follows redirects, so source URL validation alone does not constrain the downloaded target Rejected: Keep response.read() for simplicity | large preset downloads should not be buffered entirely in memory Confidence: high Scope-risk: narrow Directive: Keep preset URL policy aligned with workflow installer redirect validation Tested: uvx ruff check src/specify_cli/__init__.py src/specify_cli/presets/__init__.py src/specify_cli/presets/_commands.py tests/test_presets.py Tested: uv run pytest tests/test_presets.py -q Not-tested: Real network redirect integration against a live HTTP server Co-authored-by: OmX --- src/specify_cli/presets/_commands.py | 51 ++++++++++++++---- tests/test_presets.py | 81 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index d351600cb2..e7ff8e476b 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -101,25 +101,58 @@ def preset_add( elif from_url: # Validate URL scheme before downloading + from ipaddress import ip_address 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.") + + 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.request 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 - - with _open_url(from_url, timeout=60) as response: - zip_path.write_bytes(response.read()) + 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) @@ -606,7 +639,7 @@ def preset_catalog_add( }) config["catalogs"] = catalogs - config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") + 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})") @@ -648,7 +681,7 @@ def preset_catalog_remove( 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") + 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: 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"