diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e2d0bfb0b9..2c72395c92 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 as _locate_bundled_preset, + _locate_bundled_preset, _locate_bundled_workflow as _locate_bundled_workflow, _locate_core_pack, _repo_root, @@ -442,6 +442,16 @@ def _print_cli_warning( from .commands import init as _init_cmd # noqa: E402 _init_cmd.register(app) +# Workflow commands are defined in-module below (see the +# ``workflow_app = typer.Typer(...)`` block near the end of this file). +# An earlier draft of #2661 also tried to register the +# ``src/specify_cli/commands/workflow.py`` module, which defined a second +# ``workflow`` Typer group with the same name. Typer raises on duplicate +# command names at startup, so the redundant registration has been +# removed here and ``commands/workflow.py`` deleted. The in-module +# commands (``specify workflow run``, ``... resume``, ``... status``, +# ``... list``, ``... add``, etc.) are the single source of truth. + @app.command() def check(): @@ -560,6 +570,14 @@ def version( app.add_typer(_self_app, name="self") +# NOTE: ``specify spec`` / ``specify plan`` were intentionally NOT added +# to this CLI. The ``specify`` CLI is scaffolding + workflow orchestration +# only; the per-stage surface (``/speckit.specify``, ``/speckit.plan``, +# \u2026) belongs to the agent, not the CLI. Adding a CLI shortcut would +# duplicate that surface with a weaker, second invocation path. See +# review #4624465842 from @mnriem on PR #2704. + + # ===== Extension Commands ===== extension_app = typer.Typer( @@ -576,6 +594,20 @@ 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 ===== @@ -603,249 +635,587 @@ def _require_specify_project() -> Path: # ===== Preset Commands ===== -# 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) +@preset_app.command("list") +def preset_list(): + """List installed presets.""" + from .presets import PresetManager -# ===== Extension Commands ===== - + project_root = _require_specify_project() + manager = PresetManager(project_root) + installed = manager.list_installed() -def _resolve_installed_extension( - argument: str, - installed_extensions: list, - command_name: str = "command", - allow_not_found: bool = False, -) -> tuple[Optional[str], Optional[str]]: - """Resolve an extension argument (ID or display name) to an installed extension. + if not installed: + console.print("[yellow]No presets installed.[/yellow]") + console.print("\nInstall a preset with:") + console.print(" [cyan]specify preset add [/cyan]") + return - Args: - argument: Extension ID or display name provided by user - installed_extensions: List of installed extension dicts from manager.list_installed() - command_name: Name of the command for error messages (e.g., "enable", "disable") - allow_not_found: If True, return (None, None) when not found instead of raising + 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() - Returns: - Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found - Raises: - typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous - """ - from rich.table import Table +@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, + ) - # First, try exact ID match - for ext in installed_extensions: - if ext["id"] == argument: - return (ext["id"], ext["name"]) + 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) - # If not found by ID, try display name match - name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] + manager = PresetManager(project_root) + speckit_version = get_speckit_version() - if len(name_matches) == 1: - # Unique display-name match - return (name_matches[0]["id"], name_matches[0]["name"]) - elif len(name_matches) > 1: - # Ambiguous display-name match - console.print( - f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " - "Multiple installed extensions share this name:" - ) - table = Table(title="Matching extensions") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name", style="white") - table.add_column("Version", style="green") - for ext in name_matches: - table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) - console.print(table) - console.print("\nPlease rerun using the extension ID:") - console.print(f" [bold]specify extension {command_name} [/bold]") - raise typer.Exit(1) - else: - # No match by ID or display name - if allow_not_found: - return (None, None) - console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") - raise typer.Exit(1) + 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})") -def _resolve_catalog_extension( - argument: str, - catalog, - command_name: str = "info", -) -> tuple[Optional[dict], Optional[Exception]]: - """Resolve an extension argument (ID or display name) from the catalog. + elif from_url: + # Validate URL scheme before downloading + from ipaddress import ip_address + from urllib.parse import urlparse as _urlparse - Args: - argument: Extension ID or display name provided by user - catalog: ExtensionCatalog instance - command_name: Name of the command for error messages + _parsed = _urlparse(from_url) - Returns: - Tuple of (extension_info, catalog_error) - - If found: (ext_info_dict, None) - - If catalog error: (None, error) - - If not found: (None, None) - """ - from rich.table import Table - from .extensions import ExtensionError + 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) - try: - # First try by ID - ext_info = catalog.get_extension_info(argument) - if ext_info: - return (ext_info, None) + def _validate_download_redirect(old_url, new_url): + if not _is_allowed_download_url(_urlparse(new_url)): + import urllib.error - # Try by display name - search using argument as query, then filter for exact match - search_results = catalog.search(query=argument) - name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] + raise urllib.error.URLError( + "redirect target must use HTTPS with a hostname, " + "or HTTP for localhost/loopback" + ) - if len(name_matches) == 1: - return (name_matches[0], None) - elif len(name_matches) > 1: - # Ambiguous display-name match in catalog - console.print( - f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " - "Multiple catalog extensions share this name:" - ) - table = Table(title="Matching extensions") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name", style="white") - table.add_column("Version", style="green") - table.add_column("Catalog", style="dim") - for ext in name_matches: - table.add_row( - ext.get("id", ""), - ext.get("name", ""), - str(ext.get("version", "")), - ext.get("_catalog_name", ""), + if not _is_allowed_download_url(_parsed): + console.print( + "[red]Error:[/red] URL must use HTTPS with a hostname, " + "or HTTP for localhost/loopback." ) - console.print(table) - console.print("\nPlease rerun using the extension ID:") - console.print(f" [bold]specify extension {command_name} [/bold]") - raise typer.Exit(1) + raise typer.Exit(1) - # Not found - return (None, None) + console.print(f"Installing preset from [cyan]{from_url}[/cyan]...") + import urllib.error + import tempfile + import shutil - except ExtensionError as e: - return (None, e) + 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) -@extension_app.command("list") -def extension_list( - available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), - all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), -): - """List installed extensions.""" - from .extensions import ExtensionManager + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - installed = manager.list_installed() + 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 installed and not (available or all_extensions): - console.print("[yellow]No extensions installed.[/yellow]") - console.print("\nInstall an extension with:") - console.print(" specify extension add ") - return + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") + raise typer.Exit(1) - if installed: - console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + # 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) - for ext in installed: - status_icon = "✓" if ext["enabled"] else "✗" - status_color = "green" if ext["enabled"] else "red" + 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" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") - console.print(f" [dim]{ext['id']}[/dim]") - console.print(f" {ext['description']}") - console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") - console.print() + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") - if available or all_extensions: - console.print("\nInstall an extension:") - console.print(" [cyan]specify extension add [/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) -@catalog_app.command("list") -def catalog_list(): - """List all active extension catalogs.""" - from .extensions import ExtensionCatalog, ValidationError + +@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() - catalog = ExtensionCatalog(project_root) + manager = PresetManager(project_root) - try: - active_catalogs = catalog.get_active_catalogs() - except ValidationError as e: - console.print(f"[red]Error:[/red] {e}") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) - console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") - for entry in active_catalogs: - install_str = ( - "[green]install allowed[/green]" - if entry.install_allowed - else "[yellow]discovery only[/yellow]" - ) - console.print(f" [bold]{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" / "extension-catalogs.yml" - user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" - if os.environ.get("SPECKIT_CATALOG_URL"): - console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") else: - try: - proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None - except ValidationError: - proj_loaded = False - if proj_loaded: - 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 ValidationError: - user_loaded = False - if user_loaded: - console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") - else: - console.print("[dim]Using built-in default catalog stack.[/dim]") - console.print( - "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" - ) + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") + raise typer.Exit(1) -@catalog_app.command("add") -def catalog_add( - url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), - name: str = typer.Option(..., "--name", help="Catalog name"), - priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), - install_allowed: bool = typer.Option( - False, "--install-allowed/--no-install-allowed", - help="Allow extensions from this catalog to be installed", - ), - description: str = typer.Option("", "--description", help="Description of the catalog"), +@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"), ): - """Add a catalog to .specify/extension-catalogs.yml.""" - from .extensions import ExtensionCatalog, ValidationError + """Search for presets in the catalog.""" + from .presets import PresetCatalog, PresetError project_root = _require_specify_project() - specify_dir = project_root / ".specify" + catalog = PresetCatalog(project_root) - # Validate URL - tmp_catalog = ExtensionCatalog(project_root) try: - tmp_catalog._validate_catalog_url(url) - except ValidationError as e: + results = catalog.search(query=query, tag=tag, author=author) + except PresetError as e: console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) - config_path = specify_dir / "extension-catalogs.yml" + 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(): @@ -867,7 +1237,7 @@ def catalog_add( 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 extension catalog remove' first, or choose a different name.") + console.print("Use 'specify preset catalog remove' first, or choose a different name.") raise typer.Exit(1) catalogs.append({ @@ -879,7 +1249,7 @@ def 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})") @@ -888,23 +1258,23 @@ def catalog_add( console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") -@catalog_app.command("remove") -def catalog_remove( +@preset_catalog_app.command("remove") +def preset_catalog_remove( name: str = typer.Argument(help="Catalog name to remove"), ): - """Remove a catalog from .specify/extension-catalogs.yml.""" + """Remove a catalog from .specify/preset-catalogs.yml.""" project_root = _require_specify_project() specify_dir = project_root / ".specify" - config_path = specify_dir / "extension-catalogs.yml" + config_path = specify_dir / "preset-catalogs.yml" if not config_path.exists(): - console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") + 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 catalog config.") + console.print("[red]Error:[/red] Failed to read preset catalog config.") raise typer.Exit(1) catalogs = config.get("catalogs", []) @@ -919,2546 +1289,2244 @@ def 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: console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]") -@extension_app.command("add") -def extension_add( - extension: str = typer.Argument(help="Extension name or path"), - dev: bool = typer.Option(False, "--dev", help="Install from local directory"), - from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), - force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), - priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), -): - """Install an extension.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND - - project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - - manager = ExtensionManager(project_root) - speckit_version = get_speckit_version() - - if force: - console.print("[yellow]--force:[/yellow] Will overwrite if already installed") - - # Prompt for URL-based installs BEFORE the spinner so the user can - # actually see and respond to the confirmation (the Rich status - # spinner overwrites the typer.confirm prompt line, making it appear - # as though the command is hung). - # Guard with ``not dev`` so that --dev + --from does not show a - # confusing confirmation for a URL that will be ignored. - if from_url and not dev: - from urllib.parse import urlparse - from rich.markup import escape as _escape_markup +# ===== Extension Commands ===== - 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("[red]Error:[/red] URL must use HTTPS for security.") - console.print("HTTP is only allowed for localhost URLs.") - raise typer.Exit(1) +def _resolve_installed_extension( + argument: str, + installed_extensions: list, + command_name: str = "command", + allow_not_found: bool = False, +) -> tuple[Optional[str], Optional[str]]: + """Resolve an extension argument (ID or display name) to an installed extension. - safe_url = _escape_markup(from_url) + Args: + argument: Extension ID or display name provided by user + installed_extensions: List of installed extension dicts from manager.list_installed() + command_name: Name of the command for error messages (e.g., "enable", "disable") + allow_not_found: If True, return (None, None) when not found instead of raising - # Warn about untrusted sources — default-deny confirmation - console.print() - console.print(Panel( - f"[bold]You are installing an extension from an external URL that is not\n" - f"listed in any of your configured extension catalogs.[/bold]\n\n" - f"URL: {safe_url}\n\n" - f"Only install extensions from sources you trust.", - title="[bold yellow]⚠ Untrusted Source[/bold yellow]", - border_style="yellow", - padding=(1, 2), - )) - console.print() - confirm = typer.confirm("Continue with installation?", default=False) - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) + Returns: + Tuple of (extension_id, display_name), or (None, None) if allow_not_found=True and not found - try: - with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): - if dev: - # Install from local directory - source_path = Path(extension).expanduser().resolve() - if not source_path.exists(): - console.print(f"[red]Error:[/red] Directory not found: {source_path}") - raise typer.Exit(1) + Raises: + typer.Exit: If extension not found (and allow_not_found=False) or name is ambiguous + """ + from rich.table import Table - if not (source_path / "extension.yml").exists(): - console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") - raise typer.Exit(1) + # First, try exact ID match + for ext in installed_extensions: + if ext["id"] == argument: + return (ext["id"], ext["name"]) - if force: - console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...") + # If not found by ID, try display name match + name_matches = [ext for ext in installed_extensions if ext["name"].lower() == argument.lower()] - manifest = manager.install_from_directory( - source_path, - speckit_version, - priority=priority, - link_commands=True, - force=force - ) + if len(name_matches) == 1: + # Unique display-name match + return (name_matches[0]["id"], name_matches[0]["name"]) + elif len(name_matches) > 1: + # Ambiguous display-name match + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple installed extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + for ext in name_matches: + table.add_row(ext.get("id", ""), ext.get("name", ""), str(ext.get("version", ""))) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) + else: + # No match by ID or display name + if allow_not_found: + return (None, None) + console.print(f"[red]Error:[/red] Extension '{argument}' is not installed") + raise typer.Exit(1) - elif from_url: - # Install from URL (ZIP file) - import urllib.error - console.print(f"Downloading from {safe_url}...") +def _resolve_catalog_extension( + argument: str, + catalog, + command_name: str = "info", +) -> tuple[Optional[dict], Optional[Exception]]: + """Resolve an extension argument (ID or display name) from the catalog. - # Download ZIP to temp location - download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" - download_dir.mkdir(parents=True, exist_ok=True) - zip_path = download_dir / f"{extension}-url-download.zip" + Args: + argument: Extension ID or display name provided by user + catalog: ExtensionCatalog instance + command_name: Name of the command for error messages - try: - from specify_cli.authentication.http import open_url as _open_url + Returns: + Tuple of (extension_info, catalog_error) + - If found: (ext_info_dict, None) + - If catalog error: (None, error) + - If not found: (None, None) + """ + from rich.table import Table + from .extensions import ExtensionError - with _open_url(from_url, timeout=60) as response: - zip_data = response.read() - zip_path.write_bytes(zip_data) + try: + # First try by ID + ext_info = catalog.get_extension_info(argument) + if ext_info: + return (ext_info, None) - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) - except urllib.error.URLError as e: - console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}") - raise typer.Exit(1) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() + # Try by display name - search using argument as query, then filter for exact match + search_results = catalog.search(query=argument) + name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()] - else: - # Try bundled extensions first (shipped with spec-kit) - bundled_path = _locate_bundled_extension(extension) - if bundled_path is not None: - manifest = manager.install_from_directory( - bundled_path, speckit_version, priority=priority, force=force - ) - else: - # Install from catalog (also resolves display names to IDs) - catalog = ExtensionCatalog(project_root) + if len(name_matches) == 1: + return (name_matches[0], None) + elif len(name_matches) > 1: + # Ambiguous display-name match in catalog + console.print( + f"[red]Error:[/red] Extension name '{argument}' is ambiguous. " + "Multiple catalog extensions share this name:" + ) + table = Table(title="Matching extensions") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Name", style="white") + table.add_column("Version", style="green") + table.add_column("Catalog", style="dim") + for ext in name_matches: + table.add_row( + ext.get("id", ""), + ext.get("name", ""), + str(ext.get("version", "")), + ext.get("_catalog_name", ""), + ) + console.print(table) + console.print("\nPlease rerun using the extension ID:") + console.print(f" [bold]specify extension {command_name} [/bold]") + raise typer.Exit(1) - # Check if extension exists in catalog (supports both ID and display name) - ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") - raise typer.Exit(1) - if not ext_info: - console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") - console.print("\nSearch available extensions:") - console.print(" specify extension search") - raise typer.Exit(1) + # Not found + return (None, None) - # If catalog resolved a display name to an ID, check bundled again - resolved_id = ext_info['id'] - if resolved_id != extension: - bundled_path = _locate_bundled_extension(resolved_id) - if bundled_path is not None: - manifest = manager.install_from_directory( - bundled_path, speckit_version, priority=priority, force=force - ) + except ExtensionError as e: + return (None, e) - if bundled_path is None: - # Bundled extensions without a download URL must come from the local package - if ext_info.get("bundled") and not ext_info.get("download_url"): - console.print( - f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " - f"but could not be found in the installed package." - ) - console.print( - "\nThis usually means the spec-kit installation is incomplete or corrupted." - ) - console.print("Try reinstalling spec-kit:") - console.print(f" {REINSTALL_COMMAND}") - raise typer.Exit(1) - # Enforce install_allowed policy - if not ext_info.get("_install_allowed", True): - catalog_name = ext_info.get("_catalog_name", "community") - console.print( - f"[red]Error:[/red] '{extension}' is available in the " - f"'{catalog_name}' catalog but installation is not allowed from that catalog." - ) - console.print( - f"\nTo enable installation, add '{extension}' to an approved catalog " - f"(install_allowed: true) in .specify/extension-catalogs.yml." - ) - raise typer.Exit(1) +@extension_app.command("list") +def extension_list( + available: bool = typer.Option(False, "--available", help="Show available extensions from catalog"), + all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), +): + """List installed extensions.""" + from .extensions import ExtensionManager - # Download extension ZIP (use resolved ID, not original argument which may be display name) - extension_id = ext_info['id'] - console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") - zip_path = catalog.download_extension(extension_id) + project_root = _require_specify_project() + manager = ExtensionManager(project_root) + installed = manager.list_installed() - try: - # Install from downloaded ZIP - manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() + if not installed and not (available or all_extensions): + console.print("[yellow]No extensions installed.[/yellow]") + console.print("\nInstall an extension with:") + console.print(" specify extension add ") + return - console.print("\n[green]✓[/green] Extension installed successfully!") - console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") - console.print(f" {manifest.description}") + if installed: + console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") - for warning in manifest.warnings: - console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") + for ext in installed: + status_icon = "✓" if ext["enabled"] else "✗" + status_color = "green" if ext["enabled"] else "red" - is_cline = load_init_options(project_root).get("ai") == "cline" + console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})") + console.print(f" [dim]{ext['id']}[/dim]") + console.print(f" {ext['description']}") + console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") + console.print() - if is_cline: - from specify_cli.integrations.cline import format_cline_command_name + if available or all_extensions: + console.print("\nInstall an extension:") + console.print(" [cyan]specify extension add [/cyan]") - console.print("\n[bold cyan]Provided commands:[/bold cyan]") - for cmd in manifest.commands: - cmd_name = cmd['name'] - if is_cline: - cmd_name = format_cline_command_name(cmd_name) - console.print(f" • {cmd_name} - {cmd.get('description', '')}") - # Report agent skills registration - reg_meta = manager.registry.get(manifest.id) - reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] - # Normalize to guard against corrupted registry entries - if not isinstance(reg_skills, list): - reg_skills = [] - if reg_skills: - console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") +@catalog_app.command("list") +def catalog_list(): + """List all active extension catalogs.""" + from .extensions import ExtensionCatalog, ValidationError - console.print("\n[yellow]⚠[/yellow] Configuration may be required") - console.print(f" Check: .specify/extensions/{manifest.id}/") + project_root = _require_specify_project() + catalog = ExtensionCatalog(project_root) + try: + active_catalogs = catalog.get_active_catalogs() except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except CompatibilityError as e: - console.print(f"\n[red]Compatibility Error:[/red] {e}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") + console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) + console.print("\n[bold cyan]Active Extension Catalogs:[/bold cyan]\n") + for entry in active_catalogs: + install_str = ( + "[green]install allowed[/green]" + if entry.install_allowed + else "[yellow]discovery only[/yellow]" + ) + console.print(f" [bold]{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() -@extension_app.command("remove") -def extension_remove( - extension: str = typer.Argument(help="Extension ID or name to remove"), - keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), - force: bool = typer.Option(False, "--force", help="Skip confirmation"), -): - """Uninstall an extension.""" - from .extensions import ExtensionManager - - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - - # Get extension info for command and skill counts - ext_manifest = manager.get_extension(extension_id) - reg_meta = manager.registry.get(extension_id) - # Derive cmd_count from the registry's registered_commands (includes aliases) - # rather than from the manifest (primary commands only). Use max() across - # agents to get the per-agent count; sum() would double-count since users - # think in logical commands, not per-agent file counts. - # Use get() without a default so we can distinguish "key missing" (fall back - # to manifest) from "key present but empty dict" (zero commands registered). - registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None - if isinstance(registered_commands, dict): - cmd_count = max( - (len(v) for v in registered_commands.values() if isinstance(v, list)), - default=0, - ) + config_path = project_root / ".specify" / "extension-catalogs.yml" + user_config_path = Path.home() / ".specify" / "extension-catalogs.yml" + if os.environ.get("SPECKIT_CATALOG_URL"): + console.print("[dim]Catalog configured via SPECKIT_CATALOG_URL environment variable.[/dim]") else: - cmd_count = len(ext_manifest.commands) if ext_manifest else 0 - raw_skills = reg_meta.get("registered_skills") if reg_meta else None - skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 + try: + proj_loaded = config_path.exists() and catalog._load_catalog_config(config_path) is not None + except ValidationError: + 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 ValidationError: + user_loaded = False + if user_loaded: + console.print("[dim]Config: ~/.specify/extension-catalogs.yml[/dim]") + else: + console.print("[dim]Using built-in default catalog stack.[/dim]") + console.print( + "[dim]Add .specify/extension-catalogs.yml to customize.[/dim]" + ) - # Confirm removal - if not force: - console.print("\n[yellow]⚠ This will remove:[/yellow]") - console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") - if skill_count: - console.print(f" • {skill_count} agent skill(s)") - console.print(f" • Extension directory: .specify/extensions/{extension_id}/") - if not keep_config: - console.print(" • Config files (will be backed up)") - console.print() - confirm = typer.confirm("Continue?") - if not confirm: - console.print("Cancelled") - raise typer.Exit(0) +@catalog_app.command("add") +def catalog_add( + url: str = typer.Argument(help="Catalog URL (must use HTTPS)"), + name: str = typer.Option(..., "--name", help="Catalog name"), + priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + False, "--install-allowed/--no-install-allowed", + help="Allow extensions from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), +): + """Add a catalog to .specify/extension-catalogs.yml.""" + from .extensions import ExtensionCatalog, ValidationError - # Remove extension - success = manager.remove(extension_id, keep_config=keep_config) + project_root = _require_specify_project() + specify_dir = project_root / ".specify" - if success: - console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") - if keep_config: - console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") - else: - console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") - console.print(f"\nTo reinstall: specify extension add {extension_id}") - else: - console.print("[red]Error:[/red] Failed to remove extension") + # Validate URL + tmp_catalog = ExtensionCatalog(project_root) + try: + tmp_catalog._validate_catalog_url(url) + except ValidationError as e: + console.print(f"[red]Error:[/red] {e}") raise typer.Exit(1) + config_path = specify_dir / "extension-catalogs.yml" -@extension_app.command("search") -def extension_search( - query: str = typer.Argument(None, help="Search query (optional)"), - tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), - author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), - verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), -): - """Search for available extensions in catalog.""" - from .extensions import ExtensionCatalog, ExtensionError + # 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 = {} - project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) + 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) - try: - console.print("🔍 Searching extension catalog...") - results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) + # 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 extension catalog remove' first, or choose a different name.") + raise typer.Exit(1) - if not results: - console.print("\n[yellow]No extensions found matching criteria[/yellow]") - if query or tag or author or verified: - console.print("\nTry:") - console.print(" • Broader search terms") - console.print(" • Remove filters") - console.print(" • specify extension search (show all)") - raise typer.Exit(0) + catalogs.append({ + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + }) - console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") + config["catalogs"] = catalogs + config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8") - for ext in results: - # Extension header - verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" - console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") - console.print(f" {ext['description']}") + 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)}") - # Metadata - console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") - if ext.get('tags'): - tags_str = ", ".join(ext['tags']) - console.print(f" [dim]Tags:[/dim] {tags_str}") - # Source catalog - catalog_name = ext.get("_catalog_name", "") - install_allowed = ext.get("_install_allowed", True) - if catalog_name: - if install_allowed: - console.print(f" [dim]Catalog:[/dim] {catalog_name}") - else: - console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") +@catalog_app.command("remove") +def catalog_remove( + name: str = typer.Argument(help="Catalog name to remove"), +): + """Remove a catalog from .specify/extension-catalogs.yml.""" + project_root = _require_specify_project() + specify_dir = project_root / ".specify" - # Stats - stats = [] - if ext.get('downloads') is not None: - stats.append(f"Downloads: {ext['downloads']:,}") - if ext.get('stars') is not None: - stats.append(f"Stars: {ext['stars']}") - if stats: - console.print(f" [dim]{' | '.join(stats)}[/dim]") + config_path = specify_dir / "extension-catalogs.yml" + if not config_path.exists(): + console.print("[red]Error:[/red] No catalog config found. Nothing to remove.") + raise typer.Exit(1) - # Links - if ext.get('repository'): - console.print(f" [dim]Repository:[/dim] {ext['repository']}") + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except Exception: + console.print("[red]Error:[/red] Failed to read catalog config.") + raise typer.Exit(1) - # Install command (show warning if not installable) - if install_allowed: - console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") - else: - console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") - console.print( - f" Add to an approved catalog with install_allowed: true, " - f"or install from a ZIP URL: specify extension add {ext['id']} --from " - ) - console.print() + 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] - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + 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") -@extension_app.command("info") -def extension_info( - extension: str = typer.Argument(help="Extension ID or name"), + 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]") + + +@extension_app.command("add") +def extension_add( + extension: str = typer.Argument(help="Extension name or path"), + dev: bool = typer.Option(False, "--dev", help="Install from local directory"), + from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), + force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), + priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): - """Show detailed information about an extension.""" - from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority + """Install an extension.""" + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND project_root = _require_specify_project() - catalog = ExtensionCatalog(project_root) + # Validate priority + if priority < 1: + console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") + raise typer.Exit(1) + manager = ExtensionManager(project_root) - installed = manager.list_installed() + speckit_version = get_speckit_version() - # Try to resolve from installed extensions first (by ID or name) - # Use allow_not_found=True since the extension may be catalog-only - resolved_installed_id, resolved_installed_name = _resolve_installed_extension( - extension, installed, "info", allow_not_found=True - ) + if force: + console.print("[yellow]--force:[/yellow] Will overwrite if already installed") - # Try catalog lookup (with error handling) - # If we resolved an installed extension by display name, use its ID for catalog lookup - # to ensure we get the correct catalog entry (not a different extension with same name) - lookup_key = resolved_installed_id if resolved_installed_id else extension - ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") + # Prompt for URL-based installs BEFORE the spinner so the user can + # actually see and respond to the confirmation (the Rich status + # spinner overwrites the typer.confirm prompt line, making it appear + # as though the command is hung). + # Guard with ``not dev`` so that --dev + --from does not show a + # confusing confirmation for a URL that will be ignored. + if from_url and not dev: + from urllib.parse import urlparse + from rich.markup import escape as _escape_markup - # Case 1: Found in catalog - show full catalog info - if ext_info: - _print_extension_info(ext_info, manager) - return + parsed = urlparse(from_url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") - # Case 2: Installed locally but catalog lookup failed or not in catalog - if resolved_installed_id: - # Get local manifest info - ext_manifest = manager.get_extension(resolved_installed_id) - metadata = manager.registry.get(resolved_installed_id) - metadata_is_dict = isinstance(metadata, dict) - if not metadata_is_dict: - console.print( - "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " - "some information may be unavailable." - ) - version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + console.print("[red]Error:[/red] URL must use HTTPS for security.") + console.print("HTTP is only allowed for localhost URLs.") + raise typer.Exit(1) - console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})") - console.print(f"ID: {resolved_installed_id}") + safe_url = _escape_markup(from_url) + + # Warn about untrusted sources — default-deny confirmation + console.print() + console.print(Panel( + f"[bold]You are installing an extension from an external URL that is not\n" + f"listed in any of your configured extension catalogs.[/bold]\n\n" + f"URL: {safe_url}\n\n" + f"Only install extensions from sources you trust.", + title="[bold yellow]⚠ Untrusted Source[/bold yellow]", + border_style="yellow", + padding=(1, 2), + )) console.print() + confirm = typer.confirm("Continue with installation?", default=False) + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) - if ext_manifest: - console.print(f"{ext_manifest.description}") - console.print() - # Author is optional in extension.yml, safely retrieve it - author = ext_manifest.data.get("extension", {}).get("author") - if author: - console.print(f"[dim]Author:[/dim] {author}") - if ext_manifest.category: - console.print(f"[dim]Category:[/dim] {ext_manifest.category}") - if ext_manifest.effect: - console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}") - console.print() + try: + with console.status(f"[cyan]Installing extension: {extension}[/cyan]"): + if dev: + # Install from local directory + source_path = Path(extension).expanduser().resolve() + if not source_path.exists(): + console.print(f"[red]Error:[/red] Directory not found: {source_path}") + raise typer.Exit(1) - if ext_manifest.commands: - console.print("[bold]Commands:[/bold]") - for cmd in ext_manifest.commands: - console.print(f" • {cmd['name']}: {cmd.get('description', '')}") - console.print() + if not (source_path / "extension.yml").exists(): + console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") + raise typer.Exit(1) - # Show catalog status - if catalog_error: - console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") - console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") - else: - console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") + if force: + console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...") - console.print() - console.print("[green]✓ Installed[/green]") - priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) - console.print(f"[dim]Priority:[/dim] {priority}") - console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") - return + manifest = manager.install_from_directory( + source_path, + speckit_version, + priority=priority, + link_commands=True, + force=force + ) - # Case 3: Not found anywhere - if catalog_error: - console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") - console.print("\nTry again when online, or use the extension ID directly.") - else: - console.print(f"[red]Error:[/red] Extension '{extension}' not found") - console.print("\nTry: specify extension search") - raise typer.Exit(1) + elif from_url: + # Install from URL (ZIP file) + import urllib.error + console.print(f"Downloading from {safe_url}...") -def _print_extension_info(ext_info: dict, manager): - """Print formatted extension info from catalog data.""" - from .extensions import normalize_priority + # Download ZIP to temp location + download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + zip_path = download_dir / f"{extension}-url-download.zip" - # Header - verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" - console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") - console.print(f"ID: {ext_info['id']}") - console.print() + try: + from specify_cli.authentication.http import open_url as _open_url - # Description - console.print(f"{ext_info['description']}") - console.print() + with _open_url(from_url, timeout=60) as response: + zip_data = response.read() + zip_path.write_bytes(zip_data) - # Author and License - console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") - console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) + except urllib.error.URLError as e: + console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}") + raise typer.Exit(1) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() - # Category and Effect - if ext_info.get('category'): - console.print(f"[dim]Category:[/dim] {ext_info['category']}") - if ext_info.get('effect'): - console.print(f"[dim]Effect:[/dim] {ext_info['effect']}") + else: + # Try bundled extensions first (shipped with spec-kit) + bundled_path = _locate_bundled_extension(extension) + if bundled_path is not None: + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) + else: + # Install from catalog (also resolves display names to IDs) + catalog = ExtensionCatalog(project_root) - # Source catalog - if ext_info.get("_catalog_name"): - install_allowed = ext_info.get("_install_allowed", True) - install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" - console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") - console.print() + # Check if extension exists in catalog (supports both ID and display name) + ext_info, catalog_error = _resolve_catalog_extension(extension, catalog, "add") + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + raise typer.Exit(1) + if not ext_info: + console.print(f"[red]Error:[/red] Extension '{extension}' not found in catalog") + console.print("\nSearch available extensions:") + console.print(" specify extension search") + raise typer.Exit(1) - # Requirements - if ext_info.get('requires'): - console.print("[bold]Requirements:[/bold]") - reqs = ext_info['requires'] - if reqs.get('speckit_version'): - console.print(f" • Spec Kit: {reqs['speckit_version']}") - if reqs.get('tools'): - for tool in reqs['tools']: - tool_name = tool['name'] - tool_version = tool.get('version', 'any') - required = " (required)" if tool.get('required') else " (optional)" - console.print(f" • {tool_name}: {tool_version}{required}") - console.print() + # If catalog resolved a display name to an ID, check bundled again + resolved_id = ext_info['id'] + if resolved_id != extension: + bundled_path = _locate_bundled_extension(resolved_id) + if bundled_path is not None: + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) - # Provides - if ext_info.get('provides'): - console.print("[bold]Provides:[/bold]") - provides = ext_info['provides'] - if provides.get('commands'): - console.print(f" • Commands: {provides['commands']}") - if provides.get('hooks'): - console.print(f" • Hooks: {provides['hooks']}") - console.print() + if bundled_path is None: + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): + console.print( + f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) - # Tags - if ext_info.get('tags'): - tags_str = ", ".join(ext_info['tags']) - console.print(f"[bold]Tags:[/bold] {tags_str}") - console.print() + # Enforce install_allowed policy + if not ext_info.get("_install_allowed", True): + catalog_name = ext_info.get("_catalog_name", "community") + console.print( + f"[red]Error:[/red] '{extension}' is available in the " + f"'{catalog_name}' catalog but installation is not allowed from that catalog." + ) + console.print( + f"\nTo enable installation, add '{extension}' to an approved catalog " + f"(install_allowed: true) in .specify/extension-catalogs.yml." + ) + raise typer.Exit(1) - # Statistics - stats = [] - if ext_info.get('downloads') is not None: - stats.append(f"Downloads: {ext_info['downloads']:,}") - if ext_info.get('stars') is not None: - stats.append(f"Stars: {ext_info['stars']}") - if stats: - console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") - console.print() - - # Links - console.print("[bold]Links:[/bold]") - if ext_info.get('repository'): - console.print(f" • Repository: {ext_info['repository']}") - if ext_info.get('homepage'): - console.print(f" • Homepage: {ext_info['homepage']}") - if ext_info.get('documentation'): - console.print(f" • Documentation: {ext_info['documentation']}") - if ext_info.get('changelog'): - console.print(f" • Changelog: {ext_info['changelog']}") - console.print() - - # Installation status and command - is_installed = manager.registry.is_installed(ext_info['id']) - install_allowed = ext_info.get("_install_allowed", True) - if is_installed: - console.print("[green]✓ Installed[/green]") - metadata = manager.registry.get(ext_info['id']) - priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) - console.print(f"[dim]Priority:[/dim] {priority}") - console.print(f"\nTo remove: specify extension remove {ext_info['id']}") - elif install_allowed: - console.print("[yellow]Not installed[/yellow]") - console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") - else: - catalog_name = ext_info.get("_catalog_name", "community") - console.print("[yellow]Not installed[/yellow]") - console.print( - f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " - f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " - f"with install_allowed: true to enable installation." - ) + # Download extension ZIP (use resolved ID, not original argument which may be display name) + extension_id = ext_info['id'] + console.print(f"Downloading {ext_info['name']} v{ext_info.get('version', 'unknown')}...") + zip_path = catalog.download_extension(extension_id) + try: + # Install from downloaded ZIP + manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() -@extension_app.command("update") -def extension_update( - extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), -): - """Update extension(s) to latest version.""" - from .extensions import ( - ExtensionManager, - ExtensionCatalog, - ExtensionError, - ValidationError, - CommandRegistrar, - HookExecutor, - normalize_priority, - ) - from packaging import version as pkg_version - import shutil + console.print("\n[green]✓[/green] Extension installed successfully!") + console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})") + console.print(f" {manifest.description}") - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - catalog = ExtensionCatalog(project_root) - speckit_version = get_speckit_version() + for warning in manifest.warnings: + console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}") - try: - # Get list of extensions to update - installed = manager.list_installed() - if extension: - # Update specific extension - resolve ID from argument (handles ambiguous names) - extension_id, _ = _resolve_installed_extension(extension, installed, "update") - extensions_to_update = [extension_id] - else: - # Update all extensions - extensions_to_update = [ext["id"] for ext in installed] + is_cline = load_init_options(project_root).get("ai") == "cline" - if not extensions_to_update: - console.print("[yellow]No extensions installed[/yellow]") - raise typer.Exit(0) + if is_cline: + from specify_cli.integrations.cline import format_cline_command_name - console.print("🔄 Checking for updates...\n") + console.print("\n[bold cyan]Provided commands:[/bold cyan]") + for cmd in manifest.commands: + cmd_name = cmd['name'] + if is_cline: + cmd_name = format_cline_command_name(cmd_name) + console.print(f" • {cmd_name} - {cmd.get('description', '')}") - updates_available = [] + # Report agent skills registration + reg_meta = manager.registry.get(manifest.id) + reg_skills = reg_meta.get("registered_skills", []) if reg_meta else [] + # Normalize to guard against corrupted registry entries + if not isinstance(reg_skills, list): + reg_skills = [] + if reg_skills: + console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered") - for ext_id in extensions_to_update: - # Get installed version - metadata = manager.registry.get(ext_id) - if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: - console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") - continue - try: - installed_version = pkg_version.Version(metadata["version"]) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" - ) - continue + console.print("\n[yellow]⚠[/yellow] Configuration may be required") + console.print(f" Check: .specify/extensions/{manifest.id}/") - # Get catalog info - ext_info = catalog.get_extension_info(ext_id) - if not ext_info: - console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") - continue + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except CompatibilityError as e: + console.print(f"\n[red]Compatibility Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) - # Check if installation is allowed from this catalog - if not ext_info.get("_install_allowed", True): - console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") - continue - try: - catalog_version = pkg_version.Version(ext_info["version"]) - except pkg_version.InvalidVersion: - console.print( - f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" - ) - continue +@extension_app.command("remove") +def extension_remove( + extension: str = typer.Argument(help="Extension ID or name to remove"), + keep_config: bool = typer.Option(False, "--keep-config", help="Don't remove config files"), + force: bool = typer.Option(False, "--force", help="Skip confirmation"), +): + """Uninstall an extension.""" + from .extensions import ExtensionManager - if catalog_version > installed_version: - updates_available.append( - { - "id": ext_id, - "name": ext_info.get("name", ext_id), # Display name for status messages - "installed": str(installed_version), - "available": str(catalog_version), - "download_url": ext_info.get("download_url"), - } - ) - else: - console.print(f"✓ {ext_id}: Up to date (v{installed_version})") + project_root = _require_specify_project() + manager = ExtensionManager(project_root) - if not updates_available: - console.print("\n[green]All extensions are up to date![/green]") - raise typer.Exit(0) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "remove") - # Show available updates - console.print("\n[bold]Updates available:[/bold]\n") - for update in updates_available: - console.print( - f" • {update['id']}: {update['installed']} → {update['available']}" - ) + # Get extension info for command and skill counts + ext_manifest = manager.get_extension(extension_id) + reg_meta = manager.registry.get(extension_id) + # Derive cmd_count from the registry's registered_commands (includes aliases) + # rather than from the manifest (primary commands only). Use max() across + # agents to get the per-agent count; sum() would double-count since users + # think in logical commands, not per-agent file counts. + # Use get() without a default so we can distinguish "key missing" (fall back + # to manifest) from "key present but empty dict" (zero commands registered). + registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None + if isinstance(registered_commands, dict): + cmd_count = max( + (len(v) for v in registered_commands.values() if isinstance(v, list)), + default=0, + ) + else: + cmd_count = len(ext_manifest.commands) if ext_manifest else 0 + raw_skills = reg_meta.get("registered_skills") if reg_meta else None + skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0 + # Confirm removal + if not force: + console.print("\n[yellow]⚠ This will remove:[/yellow]") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") + if skill_count: + console.print(f" • {skill_count} agent skill(s)") + console.print(f" • Extension directory: .specify/extensions/{extension_id}/") + if not keep_config: + console.print(" • Config files (will be backed up)") console.print() - confirm = typer.confirm("Update these extensions?") + + confirm = typer.confirm("Continue?") if not confirm: console.print("Cancelled") raise typer.Exit(0) - # Perform updates with atomic backup/restore - console.print() - updated_extensions = [] - failed_updates = [] - registrar = CommandRegistrar() - hook_executor = HookExecutor(project_root) - from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths - - # UNSET sentinel: backup not yet captured (exception before backup step) - UNSET = object() - - for update in updates_available: - extension_id = update["id"] - ext_name = update["name"] # Use display name for user-facing messages - console.print(f"📦 Updating {ext_name}...") - - # Backup paths - backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" - backup_ext_dir = backup_base / "extension" - backup_commands_dir = backup_base / "commands" - backup_config_dir = backup_base / "config" - - # Store backup state - backup_registry_entry = None # None means registry entry not yet captured - backup_installed = UNSET # Original installed list from extensions.yml - backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured - backed_up_command_files = {} - - try: - # 1. Backup registry entry (always, even if extension dir doesn't exist) - backup_registry_entry = manager.registry.get(extension_id) - - # 2. Backup extension directory - extension_dir = manager.extensions_dir / extension_id - if extension_dir.exists(): - backup_base.mkdir(parents=True, exist_ok=True) - if backup_ext_dir.exists(): - shutil.rmtree(backup_ext_dir) - shutil.copytree(extension_dir, backup_ext_dir) - - # Backup config files separately so they can be restored - # after a successful install (install_from_directory clears dest dir). - config_files = list(extension_dir.glob("*-config.yml")) + list( - extension_dir.glob("*-config.local.yml") - ) - for cfg_file in config_files: - backup_config_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) - - # 3. Backup command files for all agents - registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} - for agent_name, cmd_names in registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = _AgentReg._resolve_agent_dir( - agent_name, agent_config, project_root - ) - - for cmd_name in cmd_names: - output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) - cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" - if cmd_file.exists(): - backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name - backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(cmd_file, backup_cmd_path) - backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) - - # Also backup copilot prompt files - if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - if prompt_file.exists(): - backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name - backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(prompt_file, backup_prompt_path) - backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) - - # 4. Backup hooks and installed list from extensions.yml - # get_project_config() always normalizes installed->[] and hooks->{}, - # so no sentinel is needed to distinguish key-absent from key-empty. - config = hook_executor.get_project_config() - if isinstance(config, dict): - import copy - # Deep-copy so nested mapping entries (e.g. version-pin dicts) - # are not affected by in-place mutations during the update. - backup_installed = copy.deepcopy(config.get("installed", [])) - backup_hooks = {} - for hook_name, hook_list in config.get("hooks", {}).items(): - if not isinstance(hook_list, list): - continue - ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] - if ext_hooks: - backup_hooks[hook_name] = ext_hooks - - # 5. Download new version - zip_path = catalog.download_extension(extension_id) - try: - # 6. Validate extension ID from ZIP BEFORE modifying installation - # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) - with zipfile.ZipFile(zip_path, "r") as zf: - import yaml - manifest_data = None - namelist = zf.namelist() - - # First try root-level extension.yml - if "extension.yml" in namelist: - with zf.open("extension.yml") as f: - manifest_data = yaml.safe_load(f) or {} - else: - # Look for extension.yml in a single top-level subdirectory - # (e.g., "repo-name-branch/extension.yml") - manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] - if len(manifest_paths) == 1: - with zf.open(manifest_paths[0]) as f: - manifest_data = yaml.safe_load(f) or {} - - if manifest_data is None: - raise ValueError("Downloaded extension archive is missing 'extension.yml'") - - zip_extension_id = manifest_data.get("extension", {}).get("id") - if zip_extension_id != extension_id: - raise ValueError( - f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" - ) - - # 7. Remove old extension (handles command file cleanup and registry removal) - manager.remove(extension_id, keep_config=True) - - # 8. Install new version - _ = manager.install_from_zip(zip_path, speckit_version) - - # Restore user config files from backup after successful install. - new_extension_dir = manager.extensions_dir / extension_id - if backup_config_dir.exists() and new_extension_dir.exists(): - for cfg_file in backup_config_dir.iterdir(): - if cfg_file.is_file(): - shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) - - # 9. Restore metadata from backup (installed_at, enabled state) - if backup_registry_entry and isinstance(backup_registry_entry, dict): - # Copy current registry entry to avoid mutating internal - # registry state before explicit restore(). - current_metadata = manager.registry.get(extension_id) - if current_metadata is None or not isinstance(current_metadata, dict): - raise RuntimeError( - f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" - ) - new_metadata = dict(current_metadata) - - # Preserve the original installation timestamp - if "installed_at" in backup_registry_entry: - new_metadata["installed_at"] = backup_registry_entry["installed_at"] - - # Preserve the original priority (normalized to handle corruption) - if "priority" in backup_registry_entry: - new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) - - # If extension was disabled before update, disable it again - if not backup_registry_entry.get("enabled", True): - new_metadata["enabled"] = False - - # Use restore() instead of update() because update() always - # preserves the existing installed_at, ignoring our override - manager.registry.restore(extension_id, new_metadata) + # Remove extension + success = manager.remove(extension_id, keep_config=keep_config) - # Also disable hooks in extensions.yml if extension was disabled - if not backup_registry_entry.get("enabled", True): - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = False - hook_executor.save_project_config(config) - finally: - # Clean up downloaded ZIP - if zip_path.exists(): - zip_path.unlink() + if success: + console.print(f"\n[green]✓[/green] Extension '{display_name}' removed successfully") + if keep_config: + console.print(f"\nConfig files preserved in .specify/extensions/{extension_id}/") + else: + console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension_id}/") + console.print(f"\nTo reinstall: specify extension add {extension_id}") + else: + console.print("[red]Error:[/red] Failed to remove extension") + raise typer.Exit(1) - # 10. Clean up backup on success - if backup_base.exists(): - shutil.rmtree(backup_base) - console.print(f" [green]✓[/green] Updated to v{update['available']}") - updated_extensions.append(ext_name) +@extension_app.command("search") +def extension_search( + query: str = typer.Argument(None, help="Search query (optional)"), + tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"), + author: Optional[str] = typer.Option(None, "--author", help="Filter by author"), + verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"), +): + """Search for available extensions in catalog.""" + from .extensions import ExtensionCatalog, ExtensionError - except KeyboardInterrupt: - raise - except Exception as e: - console.print(f" [red]✗[/red] Failed: {e}") - failed_updates.append((ext_name, str(e))) + project_root = _require_specify_project() + catalog = ExtensionCatalog(project_root) - # Rollback on failure - console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") + try: + console.print("🔍 Searching extension catalog...") + results = catalog.search(query=query, tag=tag, author=author, verified_only=verified) - try: - # Restore extension directory - # Only perform destructive rollback if backup exists (meaning we - # actually modified the extension). This avoids deleting a valid - # installation when failure happened before changes were made. - extension_dir = manager.extensions_dir / extension_id - if backup_ext_dir.exists(): - if extension_dir.exists(): - shutil.rmtree(extension_dir) - shutil.copytree(backup_ext_dir, extension_dir) + if not results: + console.print("\n[yellow]No extensions found matching criteria[/yellow]") + if query or tag or author or verified: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify extension search (show all)") + raise typer.Exit(0) - # Remove any NEW command files created by failed install - # (files that weren't in the original backup) - try: - new_registry_entry = manager.registry.get(extension_id) - if new_registry_entry is None or not isinstance(new_registry_entry, dict): - new_registered_commands = {} - else: - new_registered_commands = new_registry_entry.get("registered_commands", {}) - for agent_name, cmd_names in new_registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: - continue - agent_config = registrar.AGENT_CONFIGS[agent_name] - commands_dir = _AgentReg._resolve_agent_dir( - agent_name, agent_config, project_root - ) + console.print(f"\n[green]Found {len(results)} extension(s):[/green]\n") - for cmd_name in cmd_names: - output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) - cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" - # Delete if it exists and wasn't in our backup - if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: - cmd_file.unlink() + for ext in results: + # Extension header + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f"[bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") + console.print(f" {ext['description']}") - # Also handle copilot prompt files - if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" - if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: - prompt_file.unlink() - except KeyError: - pass # No new registry entry exists, nothing to clean up + # Metadata + console.print(f"\n [dim]Author:[/dim] {ext.get('author', 'Unknown')}") + if ext.get('tags'): + tags_str = ", ".join(ext['tags']) + console.print(f" [dim]Tags:[/dim] {tags_str}") - # Restore backed up command files - for original_path, backup_path in backed_up_command_files.items(): - backup_file = Path(backup_path) - if backup_file.exists(): - original_file = Path(original_path) - original_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(backup_file, original_file) + # Source catalog + catalog_name = ext.get("_catalog_name", "") + install_allowed = ext.get("_install_allowed", True) + if catalog_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {catalog_name}") + else: + console.print(f" [dim]Catalog:[/dim] {catalog_name} [yellow](discovery only — not installable)[/yellow]") - # Restore metadata in extensions.yml (hooks and installed list). - # Only run if backup step 4 was reached (backup_hooks is not None); - # otherwise we have no safe baseline to restore from and could corrupt - # the config by removing pre-existing hooks. - if backup_hooks is not None: - config = hook_executor.get_project_config() - if not isinstance(config, dict): - config = {} + # Stats + stats = [] + if ext.get('downloads') is not None: + stats.append(f"Downloads: {ext['downloads']:,}") + if ext.get('stars') is not None: + stats.append(f"Stars: {ext['stars']}") + if stats: + console.print(f" [dim]{' | '.join(stats)}[/dim]") - modified = False + # Links + if ext.get('repository'): + console.print(f" [dim]Repository:[/dim] {ext['repository']}") - # 1. Restore hooks in extensions.yml - if not isinstance(config.get("hooks"), dict): - config["hooks"] = {} - modified = True + # Install command (show warning if not installable) + if install_allowed: + console.print(f"\n [cyan]Install:[/cyan] specify extension add {ext['id']}") + else: + console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.") + console.print( + f" Add to an approved catalog with install_allowed: true, " + f"or install from a ZIP URL: specify extension add {ext['id']} --from " + ) + console.print() - # Remove any hooks for this extension added by the failed install - for hook_name in list(config["hooks"].keys()): - hooks_list = config["hooks"][hook_name] - if not isinstance(hooks_list, list): - config["hooks"][hook_name] = [] - modified = True - continue + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) - original_len = len(hooks_list) - config["hooks"][hook_name] = [ - h for h in hooks_list - if isinstance(h, dict) and h.get("extension") != extension_id - ] - if len(config["hooks"][hook_name]) != original_len: - modified = True - # Add back the backed-up hooks - if backup_hooks: - for hook_name, hooks in backup_hooks.items(): - if not isinstance(config["hooks"].get(hook_name), list): - config["hooks"][hook_name] = [] - config["hooks"][hook_name].extend(hooks) - modified = True +@extension_app.command("info") +def extension_info( + extension: str = typer.Argument(help="Extension ID or name"), +): + """Show detailed information about an extension.""" + from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority - # 2. Restore installed list in extensions.yml - if backup_installed is not UNSET: - if config.get("installed") != backup_installed: - config["installed"] = backup_installed - modified = True + project_root = _require_specify_project() + catalog = ExtensionCatalog(project_root) + manager = ExtensionManager(project_root) + installed = manager.list_installed() - if modified: - hook_executor.save_project_config(config) + # Try to resolve from installed extensions first (by ID or name) + # Use allow_not_found=True since the extension may be catalog-only + resolved_installed_id, resolved_installed_name = _resolve_installed_extension( + extension, installed, "info", allow_not_found=True + ) - # Restore registry entry (use restore() since entry was removed) - if backup_registry_entry: - manager.registry.restore(extension_id, backup_registry_entry) + # Try catalog lookup (with error handling) + # If we resolved an installed extension by display name, use its ID for catalog lookup + # to ensure we get the correct catalog entry (not a different extension with same name) + lookup_key = resolved_installed_id if resolved_installed_id else extension + ext_info, catalog_error = _resolve_catalog_extension(lookup_key, catalog, "info") - console.print(" [green]✓[/green] Rollback successful") - # Clean up backup directory only on successful rollback - if backup_base.exists(): - shutil.rmtree(backup_base) - except Exception as rollback_error: - console.print(f" [red]✗[/red] Rollback failed: {rollback_error}") - console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") + # Case 1: Found in catalog - show full catalog info + if ext_info: + _print_extension_info(ext_info, manager) + return - # Summary - console.print() - if updated_extensions: - console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") - if failed_updates: - console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") - for ext_name, error in failed_updates: - console.print(f" • {ext_name}: {error}") - raise typer.Exit(1) + # Case 2: Installed locally but catalog lookup failed or not in catalog + if resolved_installed_id: + # Get local manifest info + ext_manifest = manager.get_extension(resolved_installed_id) + metadata = manager.registry.get(resolved_installed_id) + metadata_is_dict = isinstance(metadata, dict) + if not metadata_is_dict: + console.print( + "[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; " + "some information may be unavailable." + ) + version = metadata.get("version", "unknown") if metadata_is_dict else "unknown" - except ValidationError as e: - console.print(f"\n[red]Validation Error:[/red] {e}") - raise typer.Exit(1) - except ExtensionError as e: - console.print(f"\n[red]Error:[/red] {e}") - raise typer.Exit(1) + console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})") + console.print(f"ID: {resolved_installed_id}") + console.print() + if ext_manifest: + console.print(f"{ext_manifest.description}") + console.print() + # Author is optional in extension.yml, safely retrieve it + author = ext_manifest.data.get("extension", {}).get("author") + if author: + console.print(f"[dim]Author:[/dim] {author}") + if ext_manifest.category: + console.print(f"[dim]Category:[/dim] {ext_manifest.category}") + if ext_manifest.effect: + console.print(f"[dim]Effect:[/dim] {ext_manifest.effect}") + console.print() -@extension_app.command("enable") -def extension_enable( - extension: str = typer.Argument(help="Extension ID or name to enable"), -): - """Enable a disabled extension.""" - from .extensions import ExtensionManager, HookExecutor + if ext_manifest.commands: + console.print("[bold]Commands:[/bold]") + for cmd in ext_manifest.commands: + console.print(f" • {cmd['name']}: {cmd.get('description', '')}") + console.print() - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) + # Show catalog status + if catalog_error: + console.print(f"[yellow]Catalog unavailable:[/yellow] {catalog_error}") + console.print("[dim]Note: Using locally installed extension; catalog info could not be verified.[/dim]") + else: + console.print("[yellow]Note:[/yellow] Not found in catalog (custom/local extension)") - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") + console.print() + console.print("[green]✓ Installed[/green]") + priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None) + console.print(f"[dim]Priority:[/dim] {priority}") + console.print(f"\nTo remove: specify extension remove {resolved_installed_id}") + return - # Update registry - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") - raise typer.Exit(1) + # Case 3: Not found anywhere + if catalog_error: + console.print(f"[red]Error:[/red] Could not query extension catalog: {catalog_error}") + console.print("\nTry again when online, or use the extension ID directly.") + else: + console.print(f"[red]Error:[/red] Extension '{extension}' not found") + console.print("\nTry: specify extension search") + raise typer.Exit(1) - if metadata.get("enabled", True): - console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") - raise typer.Exit(0) - manager.registry.update(extension_id, {"enabled": True}) +def _print_extension_info(ext_info: dict, manager): + """Print formatted extension info from catalog data.""" + from .extensions import normalize_priority - # Enable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = True - hook_executor.save_project_config(config) + # Header + verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" + console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}") + console.print(f"ID: {ext_info['id']}") + console.print() - console.print(f"[green]✓[/green] Extension '{display_name}' enabled") + # Description + console.print(f"{ext_info['description']}") + console.print() + # Author and License + console.print(f"[dim]Author:[/dim] {ext_info.get('author', 'Unknown')}") + console.print(f"[dim]License:[/dim] {ext_info.get('license', 'Unknown')}") -@extension_app.command("disable") -def extension_disable( - extension: str = typer.Argument(help="Extension ID or name to disable"), -): - """Disable an extension without removing it.""" - from .extensions import ExtensionManager, HookExecutor + # Category and Effect + if ext_info.get('category'): + console.print(f"[dim]Category:[/dim] {ext_info['category']}") + if ext_info.get('effect'): + console.print(f"[dim]Effect:[/dim] {ext_info['effect']}") - project_root = _require_specify_project() - manager = ExtensionManager(project_root) - hook_executor = HookExecutor(project_root) + # Source catalog + if ext_info.get("_catalog_name"): + install_allowed = ext_info.get("_install_allowed", True) + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f"[dim]Source catalog:[/dim] {ext_info['_catalog_name']}{install_note}") + console.print() - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") + # Requirements + if ext_info.get('requires'): + console.print("[bold]Requirements:[/bold]") + reqs = ext_info['requires'] + if reqs.get('speckit_version'): + console.print(f" • Spec Kit: {reqs['speckit_version']}") + if reqs.get('tools'): + for tool in reqs['tools']: + tool_name = tool['name'] + tool_version = tool.get('version', 'any') + required = " (required)" if tool.get('required') else " (optional)" + console.print(f" • {tool_name}: {tool_version}{required}") + console.print() - # Update registry - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") - raise typer.Exit(1) + # Provides + if ext_info.get('provides'): + console.print("[bold]Provides:[/bold]") + provides = ext_info['provides'] + if provides.get('commands'): + console.print(f" • Commands: {provides['commands']}") + if provides.get('hooks'): + console.print(f" • Hooks: {provides['hooks']}") + console.print() - if not metadata.get("enabled", True): - console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") - raise typer.Exit(0) + # Tags + if ext_info.get('tags'): + tags_str = ", ".join(ext_info['tags']) + console.print(f"[bold]Tags:[/bold] {tags_str}") + console.print() - manager.registry.update(extension_id, {"enabled": False}) + # Statistics + stats = [] + if ext_info.get('downloads') is not None: + stats.append(f"Downloads: {ext_info['downloads']:,}") + if ext_info.get('stars') is not None: + stats.append(f"Stars: {ext_info['stars']}") + if stats: + console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}") + console.print() - # Disable hooks in extensions.yml - config = hook_executor.get_project_config() - if "hooks" in config: - for hook_name in config["hooks"]: - for hook in config["hooks"][hook_name]: - if hook.get("extension") == extension_id: - hook["enabled"] = False - hook_executor.save_project_config(config) + # Links + console.print("[bold]Links:[/bold]") + if ext_info.get('repository'): + console.print(f" • Repository: {ext_info['repository']}") + if ext_info.get('homepage'): + console.print(f" • Homepage: {ext_info['homepage']}") + if ext_info.get('documentation'): + console.print(f" • Documentation: {ext_info['documentation']}") + if ext_info.get('changelog'): + console.print(f" • Changelog: {ext_info['changelog']}") + console.print() - console.print(f"[green]✓[/green] Extension '{display_name}' disabled") - console.print("\nCommands will no longer be available. Hooks will not execute.") - console.print(f"To re-enable: specify extension enable {extension_id}") + # Installation status and command + is_installed = manager.registry.is_installed(ext_info['id']) + install_allowed = ext_info.get("_install_allowed", True) + if is_installed: + console.print("[green]✓ Installed[/green]") + metadata = manager.registry.get(ext_info['id']) + priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None) + console.print(f"[dim]Priority:[/dim] {priority}") + console.print(f"\nTo remove: specify extension remove {ext_info['id']}") + elif install_allowed: + console.print("[yellow]Not installed[/yellow]") + console.print(f"\n[cyan]Install:[/cyan] specify extension add {ext_info['id']}") + else: + catalog_name = ext_info.get("_catalog_name", "community") + console.print("[yellow]Not installed[/yellow]") + console.print( + f"\n[yellow]⚠[/yellow] '{ext_info['id']}' is available in the '{catalog_name}' catalog " + f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml " + f"with install_allowed: true to enable installation." + ) -@extension_app.command("set-priority") -def extension_set_priority( - extension: str = typer.Argument(help="Extension ID or name"), - priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +@extension_app.command("update") +def extension_update( + extension: str = typer.Argument(None, help="Extension ID or name to update (or all)"), ): - """Set the resolution priority of an installed extension.""" - from .extensions import ExtensionManager + """Update extension(s) to latest version.""" + from .extensions import ( + ExtensionManager, + ExtensionCatalog, + ExtensionError, + ValidationError, + CommandRegistrar, + HookExecutor, + normalize_priority, + ) + from packaging import version as pkg_version + import shutil project_root = _require_specify_project() - # Validate priority - if priority < 1: - console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") - raise typer.Exit(1) - manager = ExtensionManager(project_root) + catalog = ExtensionCatalog(project_root) + speckit_version = get_speckit_version() - # Resolve extension ID from argument (handles ambiguous names) - installed = manager.list_installed() - extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") - - # Get current metadata - metadata = manager.registry.get(extension_id) - if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Extension '{extension_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]Extension '{display_name}' already has priority {priority}[/yellow]") - raise typer.Exit(0) - - old_priority = normalize_priority(raw_priority) - - # Update priority - manager.registry.update(extension_id, {"priority": priority}) - - console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}") - console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") + try: + # Get list of extensions to update + installed = manager.list_installed() + if extension: + # Update specific extension - resolve ID from argument (handles ambiguous names) + extension_id, _ = _resolve_installed_extension(extension, installed, "update") + extensions_to_update = [extension_id] + else: + # Update all extensions + extensions_to_update = [ext["id"] for ext in installed] + if not extensions_to_update: + console.print("[yellow]No extensions installed[/yellow]") + raise typer.Exit(0) -# ===== Workflow Commands ===== + console.print("🔄 Checking for updates...\n") -workflow_app = typer.Typer( - name="workflow", - help="Manage and run automation workflows", - add_completion=False, -) -app.add_typer(workflow_app, name="workflow") + updates_available = [] -workflow_catalog_app = typer.Typer( - name="catalog", - help="Manage workflow catalogs", - add_completion=False, -) -workflow_app.add_typer(workflow_catalog_app, name="catalog") + for ext_id in extensions_to_update: + # Get installed version + metadata = manager.registry.get(ext_id) + if metadata is None or not isinstance(metadata, dict) or "version" not in metadata: + console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)") + continue + try: + installed_version = pkg_version.Version(metadata["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid installed version '{metadata.get('version')}' in registry (skipping)" + ) + continue -workflow_step_app = typer.Typer( - name="step", - help="Manage workflow step types", - add_completion=False, -) -workflow_app.add_typer(workflow_step_app, name="step") + # Get catalog info + ext_info = catalog.get_extension_info(ext_id) + if not ext_info: + console.print(f"⚠ {ext_id}: Not found in catalog (skipping)") + continue -workflow_step_catalog_app = typer.Typer( - name="catalog", - help="Manage step catalogs", - add_completion=False, -) -workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog") + # Check if installation is allowed from this catalog + if not ext_info.get("_install_allowed", True): + console.print(f"⚠ {ext_id}: Updates not allowed from '{ext_info.get('_catalog_name', 'catalog')}' (skipping)") + continue + try: + catalog_version = pkg_version.Version(ext_info["version"]) + except pkg_version.InvalidVersion: + console.print( + f"⚠ {ext_id}: Invalid catalog version '{ext_info.get('version')}' (skipping)" + ) + continue -def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]: - """Parse repeated ``key=value`` CLI inputs into a dict. + if catalog_version > installed_version: + updates_available.append( + { + "id": ext_id, + "name": ext_info.get("name", ext_id), # Display name for status messages + "installed": str(installed_version), + "available": str(catalog_version), + "download_url": ext_info.get("download_url"), + } + ) + else: + console.print(f"✓ {ext_id}: Up to date (v{installed_version})") - Shared by ``workflow run`` and ``workflow resume``. Exits with an error - on any entry missing ``=``. - """ - inputs: dict[str, Any] = {} - for kv in input_values or []: - if "=" not in kv: - console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") - raise typer.Exit(1) - key, _, value = kv.partition("=") - inputs[key.strip()] = value.strip() - return inputs + if not updates_available: + console.print("\n[green]All extensions are up to date![/green]") + raise typer.Exit(0) + # Show available updates + console.print("\n[bold]Updates available:[/bold]\n") + for update in updates_available: + console.print( + f" • {update['id']}: {update['installed']} → {update['available']}" + ) -def _workflow_run_payload(state: Any) -> dict[str, Any]: - """Machine-readable summary of a run/resume outcome.""" - return { - "run_id": state.run_id, - "workflow_id": state.workflow_id, - "status": state.status.value, - "current_step_id": state.current_step_id, - "current_step_index": state.current_step_index, - } + console.print() + confirm = typer.confirm("Update these extensions?") + if not confirm: + console.print("Cancelled") + raise typer.Exit(0) + # Perform updates with atomic backup/restore + console.print() + updated_extensions = [] + failed_updates = [] + registrar = CommandRegistrar() + hook_executor = HookExecutor(project_root) + from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths -def _run_outcome_exit_code(status_value: str) -> int: - """Exit code for a finished run/resume: non-zero on terminal failure. + # UNSET sentinel: backup not yet captured (exception before backup step) + UNSET = object() - ``failed`` and ``aborted`` map to 1 so scripts and orchestrators can - rely on the process exit code; ``completed`` and ``paused`` map to 0 - (paused is a legitimate waiting state, not a failure). - """ - return 1 if status_value in ("failed", "aborted") else 0 + for update in updates_available: + extension_id = update["id"] + ext_name = update["name"] # Use display name for user-facing messages + console.print(f"📦 Updating {ext_name}...") + # Backup paths + backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update" + backup_ext_dir = backup_base / "extension" + backup_commands_dir = backup_base / "commands" + backup_config_dir = backup_base / "config" -def _emit_workflow_json(payload: dict[str, Any]) -> None: - """Write a workflow payload as machine-readable JSON to stdout. + # Store backup state + backup_registry_entry = None # None means registry entry not yet captured + backup_installed = UNSET # Original installed list from extensions.yml + backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured + backed_up_command_files = {} - Uses the builtin ``print`` rather than ``console.print`` so Rich - markup interpretation, syntax highlighting, and line-wrapping can - never alter the emitted JSON. - """ - print(json.dumps(payload, indent=2)) + try: + # 1. Backup registry entry (always, even if extension dir doesn't exist) + backup_registry_entry = manager.registry.get(extension_id) + # 2. Backup extension directory + extension_dir = manager.extensions_dir / extension_id + if extension_dir.exists(): + backup_base.mkdir(parents=True, exist_ok=True) + if backup_ext_dir.exists(): + shutil.rmtree(backup_ext_dir) + shutil.copytree(extension_dir, backup_ext_dir) -@contextlib.contextmanager -def _stdout_to_stderr_when(active: bool): - """Redirect everything written to stdout onto stderr while *active*. + # Backup config files separately so they can be restored + # after a successful install (install_from_directory clears dest dir). + config_files = list(extension_dir.glob("*-config.yml")) + list( + extension_dir.glob("*-config.local.yml") + ) + for cfg_file in config_files: + backup_config_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) - Suppressing the banner and the step-start callback is not enough to - keep a ``--json`` stream clean: individual steps may still write to - stdout while the engine runs — the gate step prints its prompt, - and the prompt step runs a subprocess that inherits the process's - stdout file descriptor. Either would corrupt the single JSON object. + # 3. Backup command files for all agents + registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {} + for agent_name, cmd_names in registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = _AgentReg._resolve_agent_dir( + agent_name, agent_config, project_root + ) - Redirecting at the file-descriptor level (``dup2``) captures both - Python-level writes and inherited-fd subprocess output, so step - progress lands on stderr (still visible to a human) while stdout - carries only the emitted JSON. A no-op when *active* is false. - """ - if not active: - yield - return - sys.stdout.flush() - saved_stdout_fd = os.dup(1) - try: - os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr) - with contextlib.redirect_stdout(sys.stderr): - yield - finally: - sys.stdout.flush() - os.dup2(saved_stdout_fd, 1) # restore the real stdout - os.close(saved_stdout_fd) + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + if cmd_file.exists(): + backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name + backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(cmd_file, backup_cmd_path) + backed_up_command_files[str(cmd_file)] = str(backup_cmd_path) + # Also backup copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists(): + backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name + backup_prompt_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(prompt_file, backup_prompt_path) + backed_up_command_files[str(prompt_file)] = str(backup_prompt_path) -@workflow_app.command("run") -def workflow_run( - source: str = typer.Argument(..., help="Workflow ID or YAML file path"), - input_values: list[str] | None = typer.Option( - None, "--input", "-i", help="Input values as key=value pairs" - ), - json_output: bool = typer.Option( - False, - "--json", - help="Emit the run outcome as a single JSON object instead of formatted text.", - ), -): - """Run a workflow from an installed ID or local YAML path.""" - from .workflows import load_custom_steps - from .workflows.engine import WorkflowEngine + # 4. Backup hooks and installed list from extensions.yml + # get_project_config() always normalizes installed->[] and hooks->{}, + # so no sentinel is needed to distinguish key-absent from key-empty. + config = hook_executor.get_project_config() + if isinstance(config, dict): + import copy + # Deep-copy so nested mapping entries (e.g. version-pin dicts) + # are not affected by in-place mutations during the update. + backup_installed = copy.deepcopy(config.get("installed", [])) + backup_hooks = {} + for hook_name, hook_list in config.get("hooks", {}).items(): + if not isinstance(hook_list, list): + continue + ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id] + if ext_hooks: + backup_hooks[hook_name] = ext_hooks - source_path = Path(source).expanduser() - is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file() + # 5. Download new version + zip_path = catalog.download_extension(extension_id) + try: + # 6. Validate extension ID from ZIP BEFORE modifying installation + # Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs) + with zipfile.ZipFile(zip_path, "r") as zf: + import yaml + manifest_data = None + namelist = zf.namelist() - if is_file_source: - # When running a YAML file directly, use cwd as project root - # without requiring a .specify/ project directory. - project_root = Path.cwd() - specify_dir = project_root / ".specify" - if specify_dir.is_symlink(): - console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory") - raise typer.Exit(1) - if specify_dir.exists() and not specify_dir.is_dir(): - console.print("[red]Error:[/red] .specify path exists but is not a directory") - raise typer.Exit(1) - else: - project_root = _require_specify_project() + # First try root-level extension.yml + if "extension.yml" in namelist: + with zf.open("extension.yml") as f: + manifest_data = yaml.safe_load(f) or {} + else: + # Look for extension.yml in a single top-level subdirectory + # (e.g., "repo-name-branch/extension.yml") + manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1] + if len(manifest_paths) == 1: + with zf.open(manifest_paths[0]) as f: + manifest_data = yaml.safe_load(f) or {} - load_custom_steps(project_root) - engine = WorkflowEngine(project_root) - if not json_output: - engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + if manifest_data is None: + raise ValueError("Downloaded extension archive is missing 'extension.yml'") - try: - definition = engine.load_workflow(source_path if is_file_source else source) - except FileNotFoundError: - console.print(f"[red]Error:[/red] Workflow not found: {source}") - raise typer.Exit(1) - except ValueError as exc: - console.print(f"[red]Error:[/red] Invalid workflow: {exc}") - raise typer.Exit(1) + zip_extension_id = manifest_data.get("extension", {}).get("id") + if zip_extension_id != extension_id: + raise ValueError( + f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'" + ) - # Validate - errors = engine.validate(definition) - if errors: - console.print("[red]Workflow validation failed:[/red]") - for err in errors: - console.print(f" • {err}") - raise typer.Exit(1) + # 7. Remove old extension (handles command file cleanup and registry removal) + manager.remove(extension_id, keep_config=True) - # Parse inputs - inputs = _parse_input_values(input_values) + # 8. Install new version + _ = manager.install_from_zip(zip_path, speckit_version) - if not json_output: - console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") - console.print(f"[dim]Version: {definition.version}[/dim]\n") + # Restore user config files from backup after successful install. + new_extension_dir = manager.extensions_dir / extension_id + if backup_config_dir.exists() and new_extension_dir.exists(): + for cfg_file in backup_config_dir.iterdir(): + if cfg_file.is_file(): + shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) - try: - with _stdout_to_stderr_when(json_output): - state = engine.execute(definition, inputs) - except ValueError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - except Exception as exc: - console.print(f"[red]Workflow failed:[/red] {exc}") - raise typer.Exit(1) + # 9. Restore metadata from backup (installed_at, enabled state) + if backup_registry_entry and isinstance(backup_registry_entry, dict): + # Copy current registry entry to avoid mutating internal + # registry state before explicit restore(). + current_metadata = manager.registry.get(extension_id) + if current_metadata is None or not isinstance(current_metadata, dict): + raise RuntimeError( + f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete" + ) + new_metadata = dict(current_metadata) - if json_output: - _emit_workflow_json(_workflow_run_payload(state)) - raise typer.Exit(_run_outcome_exit_code(state.status.value)) + # Preserve the original installation timestamp + if "installed_at" in backup_registry_entry: + new_metadata["installed_at"] = backup_registry_entry["installed_at"] - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - } - color = status_colors.get(state.status.value, "white") - console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") - console.print(f"[dim]Run ID: {state.run_id}[/dim]") + # Preserve the original priority (normalized to handle corruption) + if "priority" in backup_registry_entry: + new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"]) - if state.status.value == "paused": - console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") + # If extension was disabled before update, disable it again + if not backup_registry_entry.get("enabled", True): + new_metadata["enabled"] = False - raise typer.Exit(_run_outcome_exit_code(state.status.value)) + # Use restore() instead of update() because update() always + # preserves the existing installed_at, ignoring our override + manager.registry.restore(extension_id, new_metadata) + # Also disable hooks in extensions.yml if extension was disabled + if not backup_registry_entry.get("enabled", True): + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + finally: + # Clean up downloaded ZIP + if zip_path.exists(): + zip_path.unlink() -@workflow_app.command("resume") -def workflow_resume( - run_id: str = typer.Argument(..., help="Run ID to resume"), - input_values: list[str] | None = typer.Option( - None, "--input", "-i", help="Updated input values as key=value pairs" - ), - json_output: bool = typer.Option( - False, - "--json", - help="Emit the resume outcome as a single JSON object instead of formatted text.", - ), -): - """Resume a paused or failed workflow run.""" - from .workflows import load_custom_steps - from .workflows.engine import WorkflowEngine + # 10. Clean up backup on success + if backup_base.exists(): + shutil.rmtree(backup_base) - project_root = _require_specify_project() - load_custom_steps(project_root) - engine = WorkflowEngine(project_root) - if not json_output: - engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + console.print(f" [green]✓[/green] Updated to v{update['available']}") + updated_extensions.append(ext_name) - inputs = _parse_input_values(input_values) + except KeyboardInterrupt: + raise + except Exception as e: + console.print(f" [red]✗[/red] Failed: {e}") + failed_updates.append((ext_name, str(e))) - try: - with _stdout_to_stderr_when(json_output): - state = engine.resume(run_id, inputs or None) - except FileNotFoundError: - console.print(f"[red]Error:[/red] Run not found: {run_id}") - raise typer.Exit(1) - except ValueError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - except Exception as exc: - console.print(f"[red]Resume failed:[/red] {exc}") - raise typer.Exit(1) + # Rollback on failure + console.print(f" [yellow]↩[/yellow] Rolling back {ext_name}...") - if json_output: - _emit_workflow_json(_workflow_run_payload(state)) - raise typer.Exit(_run_outcome_exit_code(state.status.value)) + try: + # Restore extension directory + # Only perform destructive rollback if backup exists (meaning we + # actually modified the extension). This avoids deleting a valid + # installation when failure happened before changes were made. + extension_dir = manager.extensions_dir / extension_id + if backup_ext_dir.exists(): + if extension_dir.exists(): + shutil.rmtree(extension_dir) + shutil.copytree(backup_ext_dir, extension_dir) - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - } - color = status_colors.get(state.status.value, "white") - console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + # Remove any NEW command files created by failed install + # (files that weren't in the original backup) + try: + new_registry_entry = manager.registry.get(extension_id) + if new_registry_entry is None or not isinstance(new_registry_entry, dict): + new_registered_commands = {} + else: + new_registered_commands = new_registry_entry.get("registered_commands", {}) + for agent_name, cmd_names in new_registered_commands.items(): + if agent_name not in registrar.AGENT_CONFIGS: + continue + agent_config = registrar.AGENT_CONFIGS[agent_name] + commands_dir = _AgentReg._resolve_agent_dir( + agent_name, agent_config, project_root + ) - raise typer.Exit(_run_outcome_exit_code(state.status.value)) + for cmd_name in cmd_names: + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" + # Delete if it exists and wasn't in our backup + if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: + cmd_file.unlink() + # Also handle copilot prompt files + if agent_name == "copilot": + prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + if prompt_file.exists() and str(prompt_file) not in backed_up_command_files: + prompt_file.unlink() + except KeyError: + pass # No new registry entry exists, nothing to clean up -@workflow_app.command("status") -def workflow_status( - run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), - json_output: bool = typer.Option( - False, - "--json", - help="Emit run status as a single JSON object instead of formatted text.", - ), -): - """Show workflow run status.""" - from .workflows.engine import WorkflowEngine + # Restore backed up command files + for original_path, backup_path in backed_up_command_files.items(): + backup_file = Path(backup_path) + if backup_file.exists(): + original_file = Path(original_path) + original_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(backup_file, original_file) - project_root = _require_specify_project() - engine = WorkflowEngine(project_root) + # Restore metadata in extensions.yml (hooks and installed list). + # Only run if backup step 4 was reached (backup_hooks is not None); + # otherwise we have no safe baseline to restore from and could corrupt + # the config by removing pre-existing hooks. + if backup_hooks is not None: + config = hook_executor.get_project_config() + if not isinstance(config, dict): + config = {} - if run_id: - try: - from .workflows.engine import RunState - state = RunState.load(run_id, project_root) - except FileNotFoundError: - console.print(f"[red]Error:[/red] Run not found: {run_id}") - raise typer.Exit(1) + modified = False - if json_output: - # Build on the shared run/resume payload so the common fields - # (including current_step_index) stay identical across commands. - payload = { - **_workflow_run_payload(state), - "created_at": state.created_at, - "updated_at": state.updated_at, - "steps": { - sid: sd.get("status", "unknown") - for sid, sd in state.step_results.items() - }, - } - _emit_workflow_json(payload) - return + # 1. Restore hooks in extensions.yml + if not isinstance(config.get("hooks"), dict): + config["hooks"] = {} + modified = True - status_colors = { - "completed": "green", - "paused": "yellow", - "failed": "red", - "aborted": "red", - "running": "blue", - "created": "dim", - } - color = status_colors.get(state.status.value, "white") + # Remove any hooks for this extension added by the failed install + for hook_name in list(config["hooks"].keys()): + hooks_list = config["hooks"][hook_name] + if not isinstance(hooks_list, list): + config["hooks"][hook_name] = [] + modified = True + continue + + original_len = len(hooks_list) + config["hooks"][hook_name] = [ + h for h in hooks_list + if isinstance(h, dict) and h.get("extension") != extension_id + ] + if len(config["hooks"][hook_name]) != original_len: + modified = True + + # Add back the backed-up hooks + if backup_hooks: + for hook_name, hooks in backup_hooks.items(): + if not isinstance(config["hooks"].get(hook_name), list): + config["hooks"][hook_name] = [] + config["hooks"][hook_name].extend(hooks) + modified = True - console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") - console.print(f" Workflow: {state.workflow_id}") - console.print(f" Status: [{color}]{state.status.value}[/{color}]") - console.print(f" Created: {state.created_at}") - console.print(f" Updated: {state.updated_at}") + # 2. Restore installed list in extensions.yml + if backup_installed is not UNSET: + if config.get("installed") != backup_installed: + config["installed"] = backup_installed + modified = True - if state.current_step_id: - console.print(f" Current: {state.current_step_id}") + if modified: + hook_executor.save_project_config(config) - if state.step_results: - console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") - for step_id, step_data in state.step_results.items(): - s = step_data.get("status", "unknown") - sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") - console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") - else: - runs = engine.list_runs() + # Restore registry entry (use restore() since entry was removed) + if backup_registry_entry: + manager.registry.restore(extension_id, backup_registry_entry) - if json_output: - payload = { - "runs": [ - { - "run_id": r["run_id"], - "workflow_id": r.get("workflow_id"), - "status": r.get("status", "unknown"), - "updated_at": r.get("updated_at"), - } - for r in runs - ] - } - _emit_workflow_json(payload) - return + console.print(" [green]✓[/green] Rollback successful") + # Clean up backup directory only on successful rollback + if backup_base.exists(): + shutil.rmtree(backup_base) + except Exception as rollback_error: + console.print(f" [red]✗[/red] Rollback failed: {rollback_error}") + console.print(f" [dim]Backup preserved at: {backup_base}[/dim]") - if not runs: - console.print("[yellow]No workflow runs found.[/yellow]") - return + # Summary + console.print() + if updated_extensions: + console.print(f"[green]✓[/green] Successfully updated {len(updated_extensions)} extension(s)") + if failed_updates: + console.print(f"[red]✗[/red] Failed to update {len(failed_updates)} extension(s):") + for ext_name, error in failed_updates: + console.print(f" • {ext_name}: {error}") + raise typer.Exit(1) - console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") - for run_data in runs: - s = run_data.get("status", "unknown") - sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") - console.print( - f" [{sc}]●[/{sc}] {run_data['run_id']} " - f"{run_data.get('workflow_id', '?')} " - f"[{sc}]{s}[/{sc}] " - f"[dim]{run_data.get('updated_at', '?')}[/dim]" - ) + except ValidationError as e: + console.print(f"\n[red]Validation Error:[/red] {e}") + raise typer.Exit(1) + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] {e}") + raise typer.Exit(1) -@workflow_app.command("list") -def workflow_list(): - """List installed workflows.""" - from .workflows.catalog import WorkflowRegistry +@extension_app.command("enable") +def extension_enable( + extension: str = typer.Argument(help="Extension ID or name to enable"), +): + """Enable a disabled extension.""" + from .extensions import ExtensionManager, HookExecutor project_root = _require_specify_project() - registry = WorkflowRegistry(project_root) - installed = registry.list() + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) - if not installed: - console.print("[yellow]No workflows installed.[/yellow]") - console.print("\nInstall a workflow with:") - console.print(" [cyan]specify workflow add [/cyan]") - return + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "enable") - console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") - for wf_id, wf_data in installed.items(): - console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") - desc = wf_data.get("description", "") - if desc: - console.print(f" {desc}") - console.print() + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) + if metadata.get("enabled", True): + console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") + raise typer.Exit(0) -@workflow_app.command("add") -def workflow_add( - source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), -): - """Install a workflow from catalog, URL, or local path.""" - from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError - from .workflows.engine import WorkflowDefinition + manager.registry.update(extension_id, {"enabled": True}) - project_root = _require_specify_project() - registry = WorkflowRegistry(project_root) - workflows_dir = project_root / ".specify" / "workflows" + # Enable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = True + hook_executor.save_project_config(config) - def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: - """Validate and install a workflow from a local YAML file.""" - try: - definition = WorkflowDefinition.from_yaml(yaml_path) - except (ValueError, yaml.YAMLError) as exc: - console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") - raise typer.Exit(1) - if not definition.id or not definition.id.strip(): - console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") - raise typer.Exit(1) + console.print(f"[green]✓[/green] Extension '{display_name}' enabled") - from .workflows.engine import validate_workflow - errors = validate_workflow(definition) - if errors: - console.print("[red]Error:[/red] Workflow validation failed:") - for err in errors: - console.print(f" \u2022 {err}") - raise typer.Exit(1) - dest_dir = workflows_dir / definition.id - dest_dir.mkdir(parents=True, exist_ok=True) - import shutil - shutil.copy2(yaml_path, dest_dir / "workflow.yml") - registry.add(definition.id, { - "name": definition.name, - "version": definition.version, - "description": definition.description, - "source": source_label, - }) - console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed") +@extension_app.command("disable") +def extension_disable( + extension: str = typer.Argument(help="Extension ID or name to disable"), +): + """Disable an extension without removing it.""" + from .extensions import ExtensionManager, HookExecutor - # Try as URL (http/https) - if source.startswith("http://") or source.startswith("https://"): - from ipaddress import ip_address - from urllib.parse import urlparse - from specify_cli.authentication.http import open_url as _open_url + project_root = _require_specify_project() + manager = ExtensionManager(project_root) + hook_executor = HookExecutor(project_root) - parsed_src = urlparse(source) - src_host = parsed_src.hostname or "" - src_loopback = src_host == "localhost" - if not src_loopback: - try: - src_loopback = ip_address(src_host).is_loopback - except ValueError: - # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. - pass - if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): - console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") - raise typer.Exit(1) + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "disable") - from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset + # Update registry + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") + raise typer.Exit(1) - _wf_url_extra_headers = None - _resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30) - if _resolved_wf_url: - source = _resolved_wf_url - _wf_url_extra_headers = {"Accept": "application/octet-stream"} + if not metadata.get("enabled", True): + console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") + raise typer.Exit(0) - import tempfile - try: - with _open_url(source, timeout=30, extra_headers=_wf_url_extra_headers) as resp: - final_url = resp.geturl() - final_parsed = urlparse(final_url) - final_host = final_parsed.hostname or "" - final_lb = final_host == "localhost" - if not final_lb: - try: - final_lb = ip_address(final_host).is_loopback - except ValueError: - # Redirect host is not an IP literal; keep loopback as determined above. - pass - if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): - console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") - raise typer.Exit(1) - with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: - tmp.write(resp.read()) - tmp_path = Path(tmp.name) - except typer.Exit: - raise - except Exception as exc: - console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") - raise typer.Exit(1) - try: - _validate_and_install_local(tmp_path, source) - finally: - tmp_path.unlink(missing_ok=True) - return + manager.registry.update(extension_id, {"enabled": False}) - # Try as a local file/directory - source_path = Path(source) - if source_path.exists(): - if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): - _validate_and_install_local(source_path, str(source_path)) - return - elif source_path.is_dir(): - wf_file = source_path / "workflow.yml" - if not wf_file.exists(): - console.print(f"[red]Error:[/red] No workflow.yml found in {source}") - raise typer.Exit(1) - _validate_and_install_local(wf_file, str(source_path)) - return + # Disable hooks in extensions.yml + config = hook_executor.get_project_config() + if "hooks" in config: + for hook_name in config["hooks"]: + for hook in config["hooks"][hook_name]: + if hook.get("extension") == extension_id: + hook["enabled"] = False + hook_executor.save_project_config(config) + + console.print(f"[green]✓[/green] Extension '{display_name}' disabled") + console.print("\nCommands will no longer be available. Hooks will not execute.") + console.print(f"To re-enable: specify extension enable {extension_id}") - # Try from catalog - catalog = WorkflowCatalog(project_root) - try: - info = catalog.get_workflow_info(source) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - if not info: - console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") - raise typer.Exit(1) +@extension_app.command("set-priority") +def extension_set_priority( + extension: str = typer.Argument(help="Extension ID or name"), + priority: int = typer.Argument(help="New priority (lower = higher precedence)"), +): + """Set the resolution priority of an installed extension.""" + from .extensions import ExtensionManager - if not info.get("_install_allowed", True): - console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") - console.print("Direct installation is not enabled for this catalog source.") + 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) - workflow_url = info.get("url") - if not workflow_url: - console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") - raise typer.Exit(1) + manager = ExtensionManager(project_root) - # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) - from ipaddress import ip_address - from urllib.parse import urlparse + # Resolve extension ID from argument (handles ambiguous names) + installed = manager.list_installed() + extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority") - parsed_url = urlparse(workflow_url) - url_host = parsed_url.hostname or "" - is_loopback = False - if url_host == "localhost": - is_loopback = True - else: - try: - is_loopback = ip_address(url_host).is_loopback - except ValueError: - # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. - pass - if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): - console.print( - f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " - "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." - ) + # Get current metadata + metadata = manager.registry.get(extension_id) + if metadata is None or not isinstance(metadata, dict): + console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)") raise typer.Exit(1) - workflow_dir = workflows_dir / source - # Validate that source is a safe directory name (no path traversal) - try: - workflow_dir.resolve().relative_to(workflows_dir.resolve()) - except ValueError: - console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") - raise typer.Exit(1) - workflow_file = workflow_dir / "workflow.yml" + 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]Extension '{display_name}' already has priority {priority}[/yellow]") + raise typer.Exit(0) - try: - from specify_cli.authentication.http import open_url as _open_url - from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset + old_priority = normalize_priority(raw_priority) - _wf_cat_extra_headers = None - _resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30) - if _resolved_workflow_url: - workflow_url = _resolved_workflow_url - _wf_cat_extra_headers = {"Accept": "application/octet-stream"} + # Update priority + manager.registry.update(extension_id, {"priority": priority}) - workflow_dir.mkdir(parents=True, exist_ok=True) - with _open_url(workflow_url, timeout=30, extra_headers=_wf_cat_extra_headers) as response: - # Validate final URL after redirects - final_url = response.geturl() - final_parsed = urlparse(final_url) - final_host = final_parsed.hostname or "" - final_loopback = final_host == "localhost" - if not final_loopback: - try: - final_loopback = ip_address(final_host).is_loopback - except ValueError: - # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. - pass - if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): - if workflow_dir.exists(): - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print( - f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" - ) - raise typer.Exit(1) - workflow_file.write_bytes(response.read()) - except Exception as exc: - if workflow_dir.exists(): - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") - raise typer.Exit(1) + console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}") + console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") - # Validate the downloaded workflow before registering - try: - definition = WorkflowDefinition.from_yaml(workflow_file) - except (ValueError, yaml.YAMLError) as exc: - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") - raise typer.Exit(1) - from .workflows.engine import validate_workflow - errors = validate_workflow(definition) - if errors: - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print("[red]Error:[/red] Downloaded workflow validation failed:") - for err in errors: - console.print(f" \u2022 {err}") - raise typer.Exit(1) +# ===== Workflow Commands ===== - # Enforce that the workflow's internal ID matches the catalog key - if definition.id and definition.id != source: - import shutil - shutil.rmtree(workflow_dir, ignore_errors=True) - console.print( - f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " - f"does not match catalog key ({source!r}). " - f"The catalog entry may be misconfigured." - ) - raise typer.Exit(1) +workflow_app = typer.Typer( + name="workflow", + help="Manage and run automation workflows", + add_completion=False, +) +app.add_typer(workflow_app, name="workflow") - registry.add(source, { - "name": definition.name or info.get("name", source), - "version": definition.version or info.get("version", "0.0.0"), - "description": definition.description or info.get("description", ""), - "source": "catalog", - "catalog_name": info.get("_catalog_name", ""), - "url": workflow_url, - }) - console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog") +workflow_catalog_app = typer.Typer( + name="catalog", + help="Manage workflow catalogs", + add_completion=False, +) +workflow_app.add_typer(workflow_catalog_app, name="catalog") -@workflow_app.command("remove") -def workflow_remove( - workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), -): - """Uninstall a workflow.""" - from .workflows.catalog import WorkflowRegistry +def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]: + """Parse repeated ``key=value`` CLI inputs into a dict. - project_root = _require_specify_project() - registry = WorkflowRegistry(project_root) + Shared by ``workflow run`` and ``workflow resume``. Exits with an error + on any entry missing ``=``. + """ + inputs: dict[str, Any] = {} + for kv in input_values or []: + if "=" not in kv: + console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") + raise typer.Exit(1) + key, _, value = kv.partition("=") + inputs[key.strip()] = value.strip() + return inputs - if not registry.is_installed(workflow_id): - console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") - raise typer.Exit(1) - # Remove workflow files - workflow_dir = project_root / ".specify" / "workflows" / workflow_id - if workflow_dir.exists(): - import shutil - shutil.rmtree(workflow_dir) +def _workflow_run_payload(state: Any) -> dict[str, Any]: + """Machine-readable summary of a run/resume outcome.""" + return { + "run_id": state.run_id, + "workflow_id": state.workflow_id, + "status": state.status.value, + "current_step_id": state.current_step_id, + "current_step_index": state.current_step_index, + } - registry.remove(workflow_id) - console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") +def _emit_workflow_json(payload: dict[str, Any]) -> None: + """Write a workflow payload as machine-readable JSON to stdout. -@workflow_app.command("search") -def workflow_search( - query: str | None = typer.Argument(None, help="Search query"), - tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), -): - """Search workflow catalogs.""" - from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + Uses the builtin ``print`` rather than ``console.print`` so Rich + markup interpretation, syntax highlighting, and line-wrapping can + never alter the emitted JSON. + """ + print(json.dumps(payload, indent=2)) - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - try: - results = catalog.search(query=query, tag=tag) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) +@contextlib.contextmanager +def _stdout_to_stderr_when(active: bool): + """Redirect everything written to stdout onto stderr while *active*. - if not results: - console.print("[yellow]No workflows found.[/yellow]") - return + Suppressing the banner and the step-start callback is not enough to + keep a ``--json`` stream clean: individual steps may still write to + stdout while the engine runs — the gate step prints its prompt, + and the prompt step runs a subprocess that inherits the process's + stdout file descriptor. Either would corrupt the single JSON object. - console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") - for wf in results: - console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") - desc = wf.get("description", "") - if desc: - console.print(f" {desc}") - tags = wf.get("tags", []) - if tags: - console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") - console.print() + Redirecting at the file-descriptor level (``dup2``) captures both + Python-level writes and inherited-fd subprocess output, so step + progress lands on stderr (still visible to a human) while stdout + carries only the emitted JSON. A no-op when *active* is false. + """ + if not active: + yield + return + sys.stdout.flush() + saved_stdout_fd = os.dup(1) + try: + os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr) + with contextlib.redirect_stdout(sys.stderr): + yield + finally: + sys.stdout.flush() + os.dup2(saved_stdout_fd, 1) # restore the real stdout + os.close(saved_stdout_fd) -@workflow_app.command("info") -def workflow_info( - workflow_id: str = typer.Argument(..., help="Workflow ID"), +@workflow_app.command("run") +def workflow_run( + source: str = typer.Argument(..., help="Workflow ID or YAML file path"), + input_values: list[str] | None = typer.Option( + None, "--input", "-i", help="Input values as key=value pairs" + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help=( + "Show the rendered prompt/inputs for each step without invoking the AI. " + "Suppressed when --json is set so the JSON object is the only thing on stdout." + ), + ), + json_output: bool = typer.Option( + False, + "--json", + help="Emit the run outcome as a single JSON object instead of formatted text.", + ), ): - """Show workflow details and step graph.""" - from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + """Run a workflow from an installed ID or local YAML path.""" from .workflows.engine import WorkflowEngine - project_root = _require_specify_project() + source_path = Path(source).expanduser() + is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file() - # Check installed first - registry = WorkflowRegistry(project_root) - installed = registry.get(workflow_id) + if is_file_source: + # When running a YAML file directly, use cwd as project root + # without requiring a .specify/ project directory. + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if specify_dir.is_symlink(): + console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory") + raise typer.Exit(1) + if specify_dir.exists() and not specify_dir.is_dir(): + console.print("[red]Error:[/red] .specify path exists but is not a directory") + raise typer.Exit(1) + else: + project_root = _require_specify_project() engine = WorkflowEngine(project_root) + if not json_output: + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") - definition = None try: - definition = engine.load_workflow(workflow_id) + definition = engine.load_workflow(source_path if is_file_source else source) except FileNotFoundError: - # Local workflow definition not found on disk; fall back to - # catalog/registry lookup below. - pass + console.print(f"[red]Error:[/red] Workflow not found: {source}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] Invalid workflow: {exc}") + raise typer.Exit(1) - if definition: - console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") - console.print(f" Version: {definition.version}") - if definition.author: - console.print(f" Author: {definition.author}") - if definition.description: - console.print(f" Description: {definition.description}") - if definition.default_integration: - console.print(f" Integration: {definition.default_integration}") - if installed: - console.print(" [green]Installed[/green]") + # Validate + errors = engine.validate(definition) + if errors: + console.print("[red]Workflow validation failed:[/red]") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) - if definition.inputs: - console.print("\n [bold]Inputs:[/bold]") - for name, inp in definition.inputs.items(): - if isinstance(inp, dict): - req = "required" if inp.get("required") else "optional" - console.print(f" {name} ({inp.get('type', 'string')}) — {req}") + # Parse inputs + inputs = _parse_input_values(input_values) - if definition.steps: - console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") - for step in definition.steps: - stype = step.get("type", "command") - console.print(f" → {step.get('id', '?')} [{stype}]") - return + if not json_output: + console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") + console.print(f"[dim]Version: {definition.version}[/dim]\n") - # Try catalog - catalog = WorkflowCatalog(project_root) - try: - info = catalog.get_workflow_info(workflow_id) - except WorkflowCatalogError: - info = None + if dry_run and not json_output: + # When ``--json`` is set, the dry-run banner (and the per-step + # preview loop below) are suppressed entirely so stdout stays + # a single, well-formed JSON object. Redirecting to stderr + # would also work but is noisier than a script-friendly + # ``--json`` consumer actually wants. + console.print("[bold yellow]DRY RUN — no AI invocation will occur[/bold yellow]\n") - if info: - console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") - console.print(f" Version: {info.get('version', '?')}") - if info.get("description"): - console.print(f" Description: {info['description']}") - if info.get("tags"): - console.print(f" Tags: {', '.join(info['tags'])}") - console.print(" [yellow]Not installed[/yellow]") - else: - console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") + try: + with _stdout_to_stderr_when(json_output): + state = engine.execute(definition, inputs, dry_run=dry_run) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + # When a step throws during template resolution in --dry-run mode, + # any previews already resolved by earlier steps are still the + # most useful debug signal we can offer — surface them before + # exiting instead of silently dropping them. The engine attaches + # the partial state to the exception as ``exc.partial_state``. + # Skipped under --json so the contract of "stdout is a single + # JSON object" holds; in the JSON branch the partial state will + # still be on disk and ``workflow status `` will show it. + partial = getattr(exc, "partial_state", None) + if partial is not None and dry_run and not json_output: + _print_dry_run_previews(partial) + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Workflow failed:[/red] {exc}") + # See ValueError branch above — keep partial previews on failure. + partial = getattr(exc, "partial_state", None) + if partial is not None and dry_run and not json_output: + _print_dry_run_previews(partial) raise typer.Exit(1) + if json_output: + _emit_workflow_json(_workflow_run_payload(state)) + return -@workflow_catalog_app.command("list") -def workflow_catalog_list(): - """List configured workflow catalog sources.""" - from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + console.print(f"[dim]Run ID: {state.run_id}[/dim]") - project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) + if state.status.value == "paused": + console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") - try: - configs = catalog.get_catalog_configs() - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) + if dry_run and not json_output: + _print_dry_run_previews(state) - console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") - for i, cfg in enumerate(configs): - install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" - console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") - console.print(f" {cfg['url']}") - if cfg.get("description"): - console.print(f" [dim]{cfg['description']}[/dim]") - console.print() +def _print_dry_run_previews(state: Any) -> None: + """Print the rendered dry-run previews for each step that produced one. -@workflow_catalog_app.command("add") -def workflow_catalog_add( - url: str = typer.Argument(..., help="Catalog URL to add"), - name: str = typer.Option(None, "--name", help="Catalog name"), + Used by ``specify workflow run --dry-run`` to surface what the engine + *would* have dispatched for each step, so the user can verify the + rendered prompts, commands, and gate choices before running for real. + Prefers the dedicated ``dry_run_message`` field on each step's output + (set by step implementations in dry-run mode) and falls back to + ``message`` for compatibility with custom step types that have not + adopted the new field. Step IDs and messages are both escaped / + rendered as plain text so valid workflow IDs (e.g. containing ``[`` + or ``]``) cannot break Rich markup parsing. + """ + from rich.markup import escape as _escape_markup + + for step_id, step_data in state.step_results.items(): + output = step_data.get("output", {}) + if not output.get("dry_run"): + continue + msg = output.get("dry_run_message") or output.get("message", "") + if not msg: + continue + console.print(f"\n[bold cyan]Step:[/bold cyan] {_escape_markup(step_id)}") + # ``msg`` is plain text from the step implementation + # (e.g. ``[DRY RUN] Command: ...``). Disable Rich markup parsing + # so the literal ``[DRY RUN]`` bracket pair is shown verbatim + # and does not raise a ``MarkupError`` for an unknown tag. + console.print(msg, markup=False) + + +@workflow_app.command("resume") +def workflow_resume( + run_id: str = typer.Argument(..., help="Run ID to resume"), + input_values: list[str] | None = typer.Option( + None, "--input", "-i", help="Updated input values as key=value pairs" + ), + json_output: bool = typer.Option( + False, + "--json", + help="Emit the resume outcome as a single JSON object instead of formatted text.", + ), ): - """Add a workflow catalog source.""" - from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + """Resume a paused or failed workflow run.""" + from .workflows.engine import WorkflowEngine project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) + engine = WorkflowEngine(project_root) + if not json_output: + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + inputs = _parse_input_values(input_values) + try: - catalog.add_catalog(url, name) - except WorkflowValidationError as exc: + with _stdout_to_stderr_when(json_output): + state = engine.resume(run_id, inputs or None) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + except ValueError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Resume failed:[/red] {exc}") + raise typer.Exit(1) - console.print(f"[green]✓[/green] Catalog source added: {url}") + if json_output: + _emit_workflow_json(_workflow_run_payload(state)) + return + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") -@workflow_catalog_app.command("remove") -def workflow_catalog_remove( - index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +@workflow_app.command("status") +def workflow_status( + run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), + json_output: bool = typer.Option( + False, + "--json", + help="Emit run status as a single JSON object instead of formatted text.", + ), ): - """Remove a workflow catalog source by index.""" - from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + """Show workflow run status.""" + from .workflows.engine import WorkflowEngine project_root = _require_specify_project() - catalog = WorkflowCatalog(project_root) - try: - removed_name = catalog.remove_catalog(index) - except WorkflowValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) + engine = WorkflowEngine(project_root) - console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") + if run_id: + try: + from .workflows.engine import RunState + state = RunState.load(run_id, project_root) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + + if json_output: + # Build on the shared run/resume payload so the common fields + # (including current_step_index) stay identical across commands. + payload = { + **_workflow_run_payload(state), + "created_at": state.created_at, + "updated_at": state.updated_at, + "steps": { + sid: sd.get("status", "unknown") + for sid, sd in state.step_results.items() + }, + } + _emit_workflow_json(payload) + return + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + "running": "blue", + "created": "dim", + } + color = status_colors.get(state.status.value, "white") -# ===== Workflow Step Commands ===== + console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") + console.print(f" Workflow: {state.workflow_id}") + console.print(f" Status: [{color}]{state.status.value}[/{color}]") + console.print(f" Created: {state.created_at}") + console.print(f" Updated: {state.updated_at}") -@workflow_step_app.command("list") -def workflow_step_list(): - """List installed step types (built-in and custom).""" - from .workflows import STEP_REGISTRY - from .workflows.catalog import StepRegistry + if state.current_step_id: + console.print(f" Current: {state.current_step_id}") - project_root = _require_specify_project() - specify_dir = project_root / ".specify" + if state.step_results: + console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") + for step_id, step_data in state.step_results.items(): + s = step_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") + console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") + else: + runs = engine.list_runs() - # Read installed custom steps from registry only — no dynamic imports - installed: dict = {} - if specify_dir.exists(): - registry = StepRegistry(project_root) - installed = registry.list() + if json_output: + payload = { + "runs": [ + { + "run_id": r["run_id"], + "workflow_id": r.get("workflow_id"), + "status": r.get("status", "unknown"), + "updated_at": r.get("updated_at"), + } + for r in runs + ] + } + _emit_workflow_json(payload) + return - console.print("\n[bold cyan]Installed Step Types:[/bold cyan]\n") + if not runs: + console.print("[yellow]No workflow runs found.[/yellow]") + return - built_in = sorted(k for k in STEP_REGISTRY if k not in installed) - if built_in: - console.print(" [bold]Built-in:[/bold]") - for key in built_in: - console.print(f" • {key}") - console.print() + console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") + for run_data in runs: + s = run_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") + console.print( + f" [{sc}]●[/{sc}] {run_data['run_id']} " + f"{run_data.get('workflow_id', '?')} " + f"[{sc}]{s}[/{sc}] " + f"[dim]{run_data.get('updated_at', '?')}[/dim]" + ) - if installed: - console.print(" [bold]Custom (installed):[/bold]") - for key in sorted(installed): - meta = installed[key] or {} - name = meta.get("name", key) - version = meta.get("version", "?") - console.print(f" • [bold]{name}[/bold] ({key}) v{version}") - console.print() - if not built_in and not installed: - console.print("[yellow]No step types found.[/yellow]") +@workflow_app.command("list") +def workflow_list(): + """List installed workflows.""" + from .workflows.catalog import WorkflowRegistry - if specify_dir.exists(): - console.print( - " Install a new step type with: [cyan]specify workflow step add [/cyan]" - ) + project_root = _require_specify_project() + registry = WorkflowRegistry(project_root) + installed = registry.list() + if not installed: + console.print("[yellow]No workflows installed.[/yellow]") + console.print("\nInstall a workflow with:") + console.print(" [cyan]specify workflow add [/cyan]") + return -# IDs that map to internal names used under .specify/workflows/steps/ and must -# not be used as custom step IDs (dotfile check is done separately at runtime). -_RESERVED_STEP_IDS: frozenset[str] = frozenset({".cache", "step-registry.json"}) + console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") + for wf_id, wf_data in installed.items(): + console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") + desc = wf_data.get("description", "") + if desc: + console.print(f" {desc}") + console.print() -# Windows reserved device names (case-insensitive, with or without extensions) -_WINDOWS_RESERVED_NAMES: frozenset[str] = frozenset({ - "con", "prn", "aux", "nul", - "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", - "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", -}) -# Characters invalid in filenames on Windows -_WINDOWS_INVALID_CHARS: frozenset[str] = frozenset('<>:"|?*') +@workflow_app.command("add") +def workflow_add( + source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), +): + """Install a workflow from catalog, URL, or local path.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowDefinition + project_root = _require_specify_project() + registry = WorkflowRegistry(project_root) + workflows_dir = project_root / ".specify" / "workflows" -def _validate_step_id_or_exit(step_id: str) -> None: - """Validate that ``step_id`` is a single safe path component. + def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: + """Validate and install a workflow from a local YAML file.""" + try: + definition = WorkflowDefinition.from_yaml(yaml_path) + except (ValueError, yaml.YAMLError) as exc: + console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") + raise typer.Exit(1) + if not definition.id or not definition.id.strip(): + console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") + raise typer.Exit(1) - Rejects empty strings, whitespace-only strings, leading/trailing whitespace, - path separators, ``.``/``..`` components, dotfile prefixes, reserved names, - Windows-invalid filename characters, trailing dots/spaces, and Windows - reserved device names. Exits with code 1 on failure. - """ - # Strip the stem (before first dot) for Windows reserved-name check - stem = step_id.split(".")[0].lower() if step_id else "" - if ( - not step_id - or not step_id.strip() - or step_id != step_id.strip() - or "/" in step_id - or "\\" in step_id - or step_id in (".", "..") - or step_id.startswith(".") - or step_id.endswith(".") - or step_id.endswith(" ") - or step_id.lower() in _RESERVED_STEP_IDS - or stem in _WINDOWS_RESERVED_NAMES - or any(c in _WINDOWS_INVALID_CHARS for c in step_id) - or any(ord(c) < 32 for c in step_id) - ): - console.print( - f"[red]Error:[/red] Invalid step id '{step_id}': must be a single safe " - "path component (no separators, no leading dot, not a reserved name, " - "no invalid filename characters)" - ) - raise typer.Exit(1) + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + console.print("[red]Error:[/red] Workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + dest_dir = workflows_dir / definition.id + dest_dir.mkdir(parents=True, exist_ok=True) + import shutil + shutil.copy2(yaml_path, dest_dir / "workflow.yml") + registry.add(definition.id, { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": source_label, + }) + console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed") -def _resolve_steps_base_dir_or_exit(project_root: Path) -> Path: - """Resolve .specify/workflows/steps while refusing symlinked parent directories.""" - project_root_resolved = project_root.resolve() - steps_base_dir_unresolved = project_root / ".specify" / "workflows" / "steps" + # Try as URL (http/https) + if source.startswith("http://") or source.startswith("https://"): + from ipaddress import ip_address + from urllib.parse import urlparse + from specify_cli.authentication.http import open_url as _open_url - current = project_root - for part in (".specify", "workflows", "steps"): - current = current / part - if current.is_symlink(): - console.print( - f"[red]Error:[/red] Refusing to use symlinked step directory '{current}'" - ) - raise typer.Exit(1) - if current.exists() and not current.is_dir(): - console.print( - f"[red]Error:[/red] Step directory path is not a directory: '{current}'" - ) + parsed_src = urlparse(source) + src_host = parsed_src.hostname or "" + src_loopback = src_host == "localhost" + if not src_loopback: + try: + src_loopback = ip_address(src_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. + pass + if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): + console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") raise typer.Exit(1) - steps_base_dir = steps_base_dir_unresolved.resolve() - try: - steps_base_dir.relative_to(project_root_resolved) - except ValueError: - console.print( - f"[red]Error:[/red] Step directory escapes project root: '{steps_base_dir}'" - ) - raise typer.Exit(1) - - return steps_base_dir + from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset + _wf_url_extra_headers = None + _resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30) + if _resolved_wf_url: + source = _resolved_wf_url + _wf_url_extra_headers = {"Accept": "application/octet-stream"} -@workflow_step_app.command("add") -def workflow_step_add( - step_id: str = typer.Argument(..., help="Step type ID from catalog"), -): - """Install a custom step type from the step catalog.""" - from .workflows.catalog import StepCatalog, StepCatalogError, StepRegistry, StepValidationError + import tempfile + try: + with _open_url(source, timeout=30, extra_headers=_wf_url_extra_headers) as resp: + final_url = resp.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_lb = final_host == "localhost" + if not final_lb: + try: + final_lb = ip_address(final_host).is_loopback + except ValueError: + # Redirect host is not an IP literal; keep loopback as determined above. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): + console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") + raise typer.Exit(1) + with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: + tmp.write(resp.read()) + tmp_path = Path(tmp.name) + except typer.Exit: + raise + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") + raise typer.Exit(1) + try: + _validate_and_install_local(tmp_path, source) + finally: + tmp_path.unlink(missing_ok=True) + return - project_root = _require_specify_project() + # Try as a local file/directory + source_path = Path(source) + if source_path.exists(): + if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): + _validate_and_install_local(source_path, str(source_path)) + return + elif source_path.is_dir(): + wf_file = source_path / "workflow.yml" + if not wf_file.exists(): + console.print(f"[red]Error:[/red] No workflow.yml found in {source}") + raise typer.Exit(1) + _validate_and_install_local(wf_file, str(source_path)) + return - catalog = StepCatalog(project_root) + # Try from catalog + catalog = WorkflowCatalog(project_root) try: - info = catalog.get_step_info(step_id) - except StepCatalogError as exc: + info = catalog.get_workflow_info(source) + except WorkflowCatalogError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) if not info: - console.print(f"[red]Error:[/red] Step type '{step_id}' not found in catalog") + console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") raise typer.Exit(1) if not info.get("_install_allowed", True): - console.print( - f"[yellow]Warning:[/yellow] Step type '{step_id}' is from a discovery-only catalog" - ) + console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") console.print("Direct installation is not enabled for this catalog source.") raise typer.Exit(1) - # Reject step IDs that collide with built-in step types - from .workflows import STEP_REGISTRY as _step_reg - if step_id in _step_reg: - console.print( - f"[red]Error:[/red] Step type '{step_id}' conflicts with a built-in step type" - ) - raise typer.Exit(1) - - # Reject if already installed - registry = StepRegistry(project_root) - if registry.is_installed(step_id): - console.print( - f"[red]Error:[/red] Step type '{step_id}' is already installed. " - "Remove it first with: [cyan]specify workflow step remove " - f"{step_id}[/cyan]" - ) - raise typer.Exit(1) - - step_yml_url = info.get("step_yml_url") or info.get("url") - if not step_yml_url: - console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL") + workflow_url = info.get("url") + if not workflow_url: + console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") raise typer.Exit(1) - # Derive __init__.py URL: replace trailing step.yml with __init__.py - # or use explicit init_url if provided. - init_url = info.get("init_url") - if not init_url: - if step_yml_url.endswith("step.yml"): - init_url = step_yml_url[: -len("step.yml")] + "__init__.py" - else: - console.print( - f"[red]Error:[/red] Cannot derive __init__.py URL from '{step_yml_url}'. " - "Catalog entry should provide 'init_url' or a 'url' ending in 'step.yml'." - ) - raise typer.Exit(1) - + # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) + from ipaddress import ip_address from urllib.parse import urlparse - from specify_cli.authentication.http import open_url as _open_url - - def _safe_fetch(url: str) -> bytes: - parsed = urlparse(url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") - if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): - raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}") - if not parsed.hostname: - raise ValueError(f"Refusing to fetch from URL with no hostname: {url}") - with _open_url(url, timeout=30) as resp: - final_url = resp.geturl() - final_parsed = urlparse(final_url) - final_is_localhost = final_parsed.hostname in ("localhost", "127.0.0.1", "::1") - if final_parsed.scheme != "https" and not ( - final_parsed.scheme == "http" and final_is_localhost - ): - raise ValueError(f"Redirect to non-HTTPS URL: {final_url}") - if not final_parsed.hostname: - raise ValueError(f"Redirect to URL with no hostname: {final_url}") - return resp.read() - - _validate_step_id_or_exit(step_id) - - steps_base_dir = _resolve_steps_base_dir_or_exit(project_root) - step_dir = (steps_base_dir / step_id).resolve() - # Defense-in-depth: ensure the resolved directory is a direct child of - # steps_base_dir even after symlink resolution. - try: - rel_parts = step_dir.relative_to(steps_base_dir).parts - except ValueError: - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - if rel_parts != (step_id,): - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - import shutil - import tempfile - - # Refuse if step_dir already exists (e.g. leftover from a previous failed/manual - # install that wasn't registered). The user should remove it before retrying. - if step_dir.exists(): + parsed_url = urlparse(workflow_url) + url_host = parsed_url.hostname or "" + is_loopback = False + if url_host == "localhost": + is_loopback = True + else: + try: + is_loopback = ip_address(url_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): console.print( - f"[red]Error:[/red] Step directory already exists at '{step_dir}'. " - f"Remove it manually or use: [cyan]specify workflow step remove {step_id}[/cyan]" + f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " + "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." ) raise typer.Exit(1) - # Create steps_base_dir now so the staging temp dir is on the same filesystem, - # enabling a truly atomic os.rename() below. - try: - steps_base_dir.mkdir(parents=True, exist_ok=True) - tmp_path = Path(tempfile.mkdtemp(prefix="speckit_step_tmp_", dir=steps_base_dir)) - except OSError as exc: - console.print(f"[red]Error:[/red] Failed to create staging directory: {exc}") - raise typer.Exit(1) - try: - try: - step_yml_content = _safe_fetch(step_yml_url) - init_py_content = _safe_fetch(init_url) - except Exception as exc: - console.print(f"[red]Error:[/red] Failed to download step files: {exc}") - raise typer.Exit(1) - - # Validate step.yml - try: - import yaml as _yaml - - meta = _yaml.safe_load(step_yml_content.decode("utf-8")) or {} - except Exception as exc: - console.print(f"[red]Error:[/red] Invalid step.yml: {exc}") - raise typer.Exit(1) - - if not isinstance(meta, dict): - console.print("[red]Error:[/red] step.yml must be a YAML mapping") - raise typer.Exit(1) - - step_meta = meta.get("step", {}) - if not isinstance(step_meta, dict): - console.print("[red]Error:[/red] step.yml 'step' field must be a mapping") - raise typer.Exit(1) - type_key = step_meta.get("type_key", "") - if not type_key: - console.print("[red]Error:[/red] step.yml missing 'step.type_key' field") - raise typer.Exit(1) + workflow_dir = workflows_dir / source + # Validate that source is a safe directory name (no path traversal) + try: + workflow_dir.resolve().relative_to(workflows_dir.resolve()) + except ValueError: + console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") + raise typer.Exit(1) + workflow_file = workflow_dir / "workflow.yml" - if type_key != step_id: - console.print( - f"[red]Error:[/red] step.yml type_key ({type_key!r}) does not match " - f"catalog ID ({step_id!r})" - ) - raise typer.Exit(1) + try: + from specify_cli.authentication.http import open_url as _open_url + from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset - # Write the two required files. - try: - (tmp_path / "step.yml").write_bytes(step_yml_content) - (tmp_path / "__init__.py").write_bytes(init_py_content) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to write step files to staging directory: {exc}" - ) - raise typer.Exit(1) + _wf_cat_extra_headers = None + _resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30) + if _resolved_workflow_url: + workflow_url = _resolved_workflow_url + _wf_cat_extra_headers = {"Accept": "application/octet-stream"} - # Optionally download additional package files declared in the catalog entry - # (e.g. helper modules). Each entry in ``extra_files`` is a mapping of - # relative-path → URL. step.yml and __init__.py are ignored here (already - # written). Paths are validated to stay within the step package directory to - # prevent path-traversal attacks. - extra_files = info.get("extra_files") - if extra_files is not None and not isinstance(extra_files, dict): - console.print( - "[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; " - "additional package files will not be downloaded." - ) - extra_files = {} - for rel_path, file_url in (extra_files or {}).items(): - if not isinstance(rel_path, str) or not rel_path.strip(): - console.print( - "[red]Error:[/red] Catalog entry 'extra_files' contains an " - "empty or non-string path key" - ) - raise typer.Exit(1) - if rel_path in ("step.yml", "__init__.py"): - continue # already written above - # Reject dot-path segments ('', '.', '..') that would refer to the - # package directory itself (IsADirectoryError) or escape it. - rel_parts = Path(rel_path).parts - if not rel_parts or any(seg in ("", ".", "..") for seg in rel_parts): - console.print( - f"[red]Error:[/red] extra_files path '{rel_path}' is not a " - "valid relative file path" - ) - raise typer.Exit(1) - if not isinstance(file_url, str) or not file_url.strip(): - console.print( - f"[red]Error:[/red] extra_files entry '{rel_path}' has an " - "empty or non-string URL" - ) - raise typer.Exit(1) - # Resolve both destination and base to handle any symlinks in tmp_path itself, - # ensuring the traversal check is robust even on non-canonical paths. - resolved_base = tmp_path.resolve() - dest = (tmp_path / rel_path).resolve() - try: - dest.relative_to(resolved_base) - except ValueError: - console.print( - f"[red]Error:[/red] extra_files path '{rel_path}' is outside " - "the step package directory" - ) - raise typer.Exit(1) - try: - file_content = _safe_fetch(file_url) - except Exception as exc: - console.print( - f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}" - ) - raise typer.Exit(1) - try: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(file_content) - except OSError as exc: + workflow_dir.mkdir(parents=True, exist_ok=True) + with _open_url(workflow_url, timeout=30, extra_headers=_wf_cat_extra_headers) as response: + # Validate final URL after redirects + final_url = response.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_loopback = final_host == "localhost" + if not final_loopback: + try: + final_loopback = ip_address(final_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) console.print( - f"[red]Error:[/red] Failed to write extra file '{rel_path}': {exc}" + f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" ) raise typer.Exit(1) + workflow_file.write_bytes(response.read()) + except Exception as exc: + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") + raise typer.Exit(1) - # Atomically rename the staging directory to the final location. - # Both paths are under steps_base_dir (same filesystem), so os.rename() - # is atomic on POSIX and won't leave a partially-written directory at - # step_dir on failure. - try: - os.rename(tmp_path, step_dir) - except OSError as exc: - console.print(f"[red]Error:[/red] Failed to install step '{step_id}': {exc}") - raise typer.Exit(1) - finally: - # Clean up if the rename hasn't moved tmp_path yet (i.e. on any failure). - shutil.rmtree(tmp_path, ignore_errors=True) + # Validate the downloaded workflow before registering + try: + definition = WorkflowDefinition.from_yaml(workflow_file) + except (ValueError, yaml.YAMLError) as exc: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") + raise typer.Exit(1) - step_name = info.get("name") or step_id - step_version = info.get("version") or step_meta.get("version") or "0.0.0" + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print("[red]Error:[/red] Downloaded workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) - # Register in step registry - registry = StepRegistry(project_root) - try: - registry.add( - step_id, - { - "name": step_name, - "version": step_version, - "description": info.get("description", step_meta.get("description", "")), - "author": info.get("author", step_meta.get("author", "")), - "source": "catalog", - "catalog_name": info.get("_catalog_name", ""), - "type_key": type_key, - }, + # Enforce that the workflow's internal ID matches the catalog key + if definition.id and definition.id != source: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " + f"does not match catalog key ({source!r}). " + f"The catalog entry may be misconfigured." ) - except StepValidationError as exc: - # Roll back the just-installed directory so the system isn't left with - # an unregistered step package on disk after a registry write failure - # (e.g. read-only filesystem, permission denied). - shutil.rmtree(step_dir, ignore_errors=True) - console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - console.print( - f"[green]✓[/green] Step type '{step_name}' ({step_id}) installed" - ) - console.print( - " Use [cyan]specify workflow step list[/cyan] to verify the installation." - ) + registry.add(source, { + "name": definition.name or info.get("name", source), + "version": definition.version or info.get("version", "0.0.0"), + "description": definition.description or info.get("description", ""), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "url": workflow_url, + }) + console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog") -@workflow_step_app.command("remove") -def workflow_step_remove( - step_id: str = typer.Argument(..., help="Step type ID to uninstall"), +@workflow_app.command("remove") +def workflow_remove( + workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), ): - """Uninstall a custom step type.""" - from .workflows.catalog import StepRegistry, StepValidationError + """Uninstall a workflow.""" + from .workflows.catalog import WorkflowRegistry project_root = _require_specify_project() + registry = WorkflowRegistry(project_root) - _validate_step_id_or_exit(step_id) - - registry = StepRegistry(project_root) - in_registry = registry.is_installed(step_id) - - steps_base_dir = _resolve_steps_base_dir_or_exit(project_root) - step_dir = (steps_base_dir / step_id).resolve() - # Defense-in-depth: even though _validate_step_id_or_exit rejects path - # separators, ensure that the resolved directory is a single child of - # steps_base_dir and is not steps_base_dir itself. - try: - rel_parts = step_dir.relative_to(steps_base_dir).parts - except ValueError: - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - if rel_parts != (step_id,): - console.print(f"[red]Error:[/red] Invalid step id '{step_id}'") - raise typer.Exit(1) - - dir_exists = step_dir.exists() - - if not in_registry and not dir_exists: - console.print(f"[red]Error:[/red] Step type '{step_id}' is not installed") + if not registry.is_installed(workflow_id): + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") raise typer.Exit(1) - if not in_registry and dir_exists: - # The registry was likely reset due to corruption. Warn the user that the - # directory is being removed even though there is no registry entry, so - # the orphaned package can be cleaned up and a fresh install attempted. - console.print( - f"[yellow]Warning:[/yellow] '{step_id}' has no registry entry " - "(registry may have been reset). Removing the orphaned directory." - ) - - if dir_exists and not in_registry: - # No registry write needed; just delete the orphaned directory. + # Remove workflow files + workflow_dir = project_root / ".specify" / "workflows" / workflow_id + if workflow_dir.exists(): import shutil - try: - shutil.rmtree(step_dir) - except OSError as exc: - console.print( - f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}" - ) - raise typer.Exit(1) - elif in_registry: - # Remove the registry entry, then the directory. If the directory - # delete fails, restore the registry entry so state stays consistent - # and a future `step add` isn't blocked by an orphaned directory - # with no registry entry. - registry_metadata = registry.get(step_id) - try: - registry.remove(step_id) - except StepValidationError as exc: - console.print(f"[red]Error:[/red] {exc}") - raise typer.Exit(1) - if dir_exists: - import shutil - try: - shutil.rmtree(step_dir) - except OSError as exc: - # Restore the original registry entry verbatim (bypass add() - # which would overwrite timestamps). - try: - if registry_metadata is not None: - registry.data["steps"][step_id] = registry_metadata - registry.save() - except Exception as restore_exc: # noqa: BLE001 - console.print( - f"[yellow]Warning:[/yellow] Failed to restore registry entry " - f"for '{step_id}' after directory removal failure: {restore_exc}" - ) - console.print( - f"[red]Error:[/red] Failed to remove step directory {step_dir}: {exc}" - ) - raise typer.Exit(1) - console.print(f"[green]✓[/green] Step type '{step_id}' uninstalled") + shutil.rmtree(workflow_dir) + registry.remove(workflow_id) + console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") -@workflow_step_app.command("search") -def workflow_step_search( + +@workflow_app.command("search") +def workflow_search( query: str | None = typer.Argument(None, help="Search query"), + tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), ): - """Search the step type catalog.""" - from .workflows.catalog import StepCatalog, StepCatalogError + """Search workflow catalogs.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError project_root = _require_specify_project() - - catalog = StepCatalog(project_root) + catalog = WorkflowCatalog(project_root) try: - results = catalog.search(query=query) - except StepCatalogError as exc: + results = catalog.search(query=query, tag=tag) + except WorkflowCatalogError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) if not results: - if query: - console.print(f"[yellow]No step types found matching '{query}'.[/yellow]") - else: - console.print("[yellow]No step types found in catalog.[/yellow]") + console.print("[yellow]No workflows found.[/yellow]") return - console.print(f"\n[bold cyan]Step Types ({len(results)}):[/bold cyan]\n") - for step in results: - install_note = ( - "" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]" - ) - console.print( - f" [bold]{step.get('name', step.get('id', '?'))}[/bold]" - f" ({step.get('id', '?')}) v{step.get('version', '?')}{install_note}" - ) - desc = step.get("description", "") + console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") + for wf in results: + console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") + desc = wf.get("description", "") if desc: console.print(f" {desc}") + tags = wf.get("tags", []) + if tags: + console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") console.print() -@workflow_step_app.command("info") -def workflow_step_info( - step_id: str = typer.Argument(..., help="Step type ID"), +@workflow_app.command("info") +def workflow_info( + workflow_id: str = typer.Argument(..., help="Workflow ID"), ): - """Show details for a step type.""" - from .workflows import STEP_REGISTRY - from .workflows.catalog import StepCatalog, StepCatalogError, StepRegistry + """Show workflow details and step graph.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowEngine project_root = _require_specify_project() - registry = StepRegistry(project_root) - installed_meta = registry.get(step_id) + # Check installed first + registry = WorkflowRegistry(project_root) + installed = registry.get(workflow_id) - # Check if it's a built-in - builtin_step = STEP_REGISTRY.get(step_id) - is_builtin = builtin_step is not None and not installed_meta + engine = WorkflowEngine(project_root) - if is_builtin: - console.print(f"\n[bold cyan]{step_id}[/bold cyan] [dim](built-in)[/dim]") - console.print(f" Type key: {step_id}") - console.print(" [green]Built-in step type[/green]") - return + definition = None + try: + definition = engine.load_workflow(workflow_id) + except FileNotFoundError: + # Local workflow definition not found on disk; fall back to + # catalog/registry lookup below. + pass - if installed_meta: - console.print( - f"\n[bold cyan]{installed_meta.get('name', step_id)}[/bold cyan] ({step_id})" - ) - console.print(f" Version: {installed_meta.get('version', '?')}") - if installed_meta.get("author"): - console.print(f" Author: {installed_meta['author']}") - if installed_meta.get("description"): - console.print(f" Description: {installed_meta['description']}") - console.print(" [green]Installed[/green]") + if definition: + console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") + console.print(f" Version: {definition.version}") + if definition.author: + console.print(f" Author: {definition.author}") + if definition.description: + console.print(f" Description: {definition.description}") + if definition.default_integration: + console.print(f" Integration: {definition.default_integration}") + if installed: + console.print(" [green]Installed[/green]") + + if definition.inputs: + console.print("\n [bold]Inputs:[/bold]") + for name, inp in definition.inputs.items(): + if isinstance(inp, dict): + req = "required" if inp.get("required") else "optional" + console.print(f" {name} ({inp.get('type', 'string')}) — {req}") + + if definition.steps: + console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") + for step in definition.steps: + stype = step.get("type", "command") + console.print(f" → {step.get('id', '?')} [{stype}]") return # Try catalog - catalog = StepCatalog(project_root) + catalog = WorkflowCatalog(project_root) try: - info = catalog.get_step_info(step_id) - except StepCatalogError: + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError: info = None if info: - console.print( - f"\n[bold cyan]{info.get('name', step_id)}[/bold cyan] ({step_id})" - ) + console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") console.print(f" Version: {info.get('version', '?')}") - if info.get("author"): - console.print(f" Author: {info['author']}") if info.get("description"): console.print(f" Description: {info['description']}") + if info.get("tags"): + console.print(f" Tags: {', '.join(info['tags'])}") console.print(" [yellow]Not installed[/yellow]") - console.print( - f"\n Install with: [cyan]specify workflow step add {step_id}[/cyan]" - ) else: - console.print(f"[red]Error:[/red] Step type '{step_id}' not found") + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") raise typer.Exit(1) -@workflow_step_catalog_app.command("list") -def workflow_step_catalog_list(): - """List configured step catalog sources.""" - from .workflows.catalog import StepCatalog, StepCatalogError +@workflow_catalog_app.command("list") +def workflow_catalog_list(): + """List configured workflow catalog sources.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError project_root = _require_specify_project() - catalog = StepCatalog(project_root) + catalog = WorkflowCatalog(project_root) try: configs = catalog.get_catalog_configs() - except StepCatalogError as exc: + except WorkflowCatalogError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - console.print("\n[bold cyan]Step Catalog Sources:[/bold cyan]\n") + console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") for i, cfg in enumerate(configs): - install_status = ( - "[green]install allowed[/green]" - if cfg["install_allowed"] - else "[yellow]discovery only[/yellow]" - ) + install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") console.print(f" {cfg['url']}") if cfg.get("description"): @@ -3466,45 +3534,41 @@ def workflow_step_catalog_list(): console.print() -@workflow_step_catalog_app.command("add") -def workflow_step_catalog_add( +@workflow_catalog_app.command("add") +def workflow_catalog_add( url: str = typer.Argument(..., help="Catalog URL to add"), name: str = typer.Option(None, "--name", help="Catalog name"), ): - """Add a step catalog source.""" - from .workflows.catalog import StepCatalog, StepValidationError + """Add a workflow catalog source.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError project_root = _require_specify_project() - - catalog = StepCatalog(project_root) + catalog = WorkflowCatalog(project_root) try: catalog.add_catalog(url, name) - except StepValidationError as exc: + except WorkflowValidationError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - console.print(f"[green]✓[/green] Step catalog source added: {url}") + console.print(f"[green]✓[/green] Catalog source added: {url}") -@workflow_step_catalog_app.command("remove") -def workflow_step_catalog_remove( - index: int = typer.Argument( - ..., help="Catalog index to remove (from 'step catalog list')" - ), +@workflow_catalog_app.command("remove") +def workflow_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), ): - """Remove a step catalog source by index.""" - from .workflows.catalog import StepCatalog, StepValidationError + """Remove a workflow catalog source by index.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError project_root = _require_specify_project() - - catalog = StepCatalog(project_root) + catalog = WorkflowCatalog(project_root) try: removed_name = catalog.remove_catalog(index) - except StepValidationError as exc: + except WorkflowValidationError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - console.print(f"[green]✓[/green] Step catalog source '{removed_name}' removed") + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") def main(): diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 0d56f7df70..c86db76625 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -545,6 +545,13 @@ def execute( state.status = RunStatus.FAILED state.append_log({"event": "workflow_failed", "error": str(exc)}) state.save() + # Attach the partially-populated state to the exception so + # callers (most notably the CLI's exception handler) can + # surface any dry-run previews already resolved by earlier + # steps — the most useful debug signal a dry-run failure + # can offer. The state has been saved to disk by this point + # so consumers can also reload it via ``RunState.load()``. + exc.partial_state = state # type: ignore[attr-defined] raise if state.status == RunStatus.RUNNING: @@ -622,6 +629,12 @@ def resume( state.status = RunStatus.FAILED state.append_log({"event": "resume_failed", "error": str(exc)}) state.save() + # Attach the partially-populated state to the exception so + # callers (e.g. the CLI's exception handler for ``workflow + # resume``) can surface any dry-run previews already + # resolved by earlier resumed steps. Mirrors the same + # contract used by :meth:`execute`. + exc.partial_state = state # type: ignore[attr-defined] raise if state.status == RunStatus.RUNNING: diff --git a/tests/test_workflows.py b/tests/test_workflows.py index a87c09cf05..628296570c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -32,10 +32,7 @@ def temp_dir(): """Create a temporary directory for tests.""" tmpdir = tempfile.mkdtemp() yield Path(tmpdir) - # On Windows, file handles from dynamic imports or registry access may - # still be held briefly after the test. Use ignore_errors to avoid - # flaky teardown failures (WinError 32). - shutil.rmtree(tmpdir, ignore_errors=(sys.platform == "win32")) + shutil.rmtree(tmpdir) @pytest.fixture @@ -104,7 +101,7 @@ def test_all_step_types_registered(self): expected = { "command", "shell", "prompt", "gate", "if", "switch", - "while", "do-while", "fan-out", "fan-in", "init", + "while", "do-while", "fan-out", "fan-in", } assert expected.issubset(set(STEP_REGISTRY.keys())) @@ -289,59 +286,6 @@ def test_filter_contains(self): ctx = StepContext(inputs={"text": "hello world"}) assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True - def test_filter_from_json_parses_object(self): - from specify_cli.workflows.expressions import evaluate_expression - from specify_cli.workflows.base import StepContext - - ctx = StepContext( - steps={"emit": {"output": {"stdout": '{"items": [1, 2, 3]}'}}} - ) - result = evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx) - assert result == {"items": [1, 2, 3]} - - def test_filter_from_json_invalid_json_raises(self): - import pytest - from specify_cli.workflows.expressions import evaluate_expression - from specify_cli.workflows.base import StepContext - - ctx = StepContext(steps={"emit": {"output": {"stdout": "not json"}}}) - with pytest.raises(ValueError, match="from_json: invalid JSON"): - evaluate_expression("{{ steps.emit.output.stdout | from_json }}", ctx) - - def test_filter_from_json_non_string_raises(self): - import pytest - from specify_cli.workflows.expressions import evaluate_expression - from specify_cli.workflows.base import StepContext - - ctx = StepContext(steps={"emit": {"output": {"exit_code": 0}}}) - with pytest.raises(ValueError, match="expected a JSON string"): - evaluate_expression("{{ steps.emit.output.exit_code | from_json }}", ctx) - - def test_filter_from_json_rejects_malformed_forms(self): - # `from_json` is strict: no arguments and no trailing tokens. Every - # mis-wired form — parenthesized, accidental arg, or trailing - # garbage — must raise rather than silently fall through to the - # unknown-filter path and return the unparsed value. - import pytest - from specify_cli.workflows.expressions import evaluate_expression - from specify_cli.workflows.base import StepContext - - ctx = StepContext(steps={"emit": {"output": {"stdout": '{"a": 1}'}}}) - bad_forms = ( - "from_json()", - "from_json('x')", - "from_json ()", - "from_json ('x')", - "from_json)", - "from_json extra", - "from_json 'x'", - ) - for bad in bad_forms: - with pytest.raises(ValueError, match="from_json: expected"): - evaluate_expression( - "{{ steps.emit.output.stdout | " + bad + " }}", ctx - ) - def test_condition_evaluation(self): from specify_cli.workflows.expressions import evaluate_condition from specify_cli.workflows.base import StepContext @@ -714,78 +658,70 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): # Claude is a SkillsIntegration so uses /speckit-specify assert "/speckit-specify login" in call_args[0][0][2] - def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch): - """Command preflight falls back to build_exec_args() argv[0].""" - from unittest.mock import MagicMock, patch + def test_dispatch_failure_returns_failed_status(self, tmp_path): + """When the CLI exits non-zero, the step should fail.""" + from unittest.mock import patch, MagicMock from specify_cli.workflows.steps.command import CommandStep from specify_cli.workflows.base import StepContext, StepStatus - monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude") - seen_which: list[str] = [] - - def fake_which(name: str) -> str | None: - seen_which.append(name) - return name if name == "/opt/claude" else None - step = CommandStep() ctx = StepContext( - inputs={"name": "login"}, + inputs={}, default_integration="claude", project_root=str(tmp_path), ) config = { "id": "test", "command": "speckit.specify", - "input": {"args": "{{ inputs.name }}"}, + "input": {"args": "test"}, } mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = '{"result": "done"}' - mock_result.stderr = "" + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "API error" - with patch("specify_cli.workflows.steps.command.shutil.which", side_effect=fake_which), \ - patch("subprocess.run", return_value=mock_result) as mock_run: + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): result = step.execute(config, ctx) - assert result.status == StepStatus.COMPLETED + assert result.status == StepStatus.FAILED assert result.output["dispatched"] is True - assert seen_which[:2] == ["claude", "/opt/claude"] - call_args = mock_run.call_args - assert call_args[0][0][0] == "/opt/claude" - assert "/speckit-specify login" in call_args[0][0][2] + assert result.output["exit_code"] == 1 - def test_dispatch_failure_returns_failed_status(self, tmp_path): - """When the CLI exits non-zero, the step should fail.""" - from unittest.mock import patch, MagicMock + def test_dry_run_returns_completed_without_dispatch(self, tmp_path): + """Dry-run mode: step returns COMPLETED and shows rendered prompt without invoking CLI.""" from specify_cli.workflows.steps.command import CommandStep from specify_cli.workflows.base import StepContext, StepStatus step = CommandStep() ctx = StepContext( - inputs={}, + inputs={"name": "login"}, default_integration="claude", project_root=str(tmp_path), + dry_run=True, ) config = { "id": "test", "command": "speckit.specify", - "input": {"args": "test"}, + "input": {"args": "{{ inputs.name }}"}, } + result = step.execute(config, ctx) - mock_result = MagicMock() - mock_result.returncode = 1 - mock_result.stdout = "" - mock_result.stderr = "API error" - - with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ - patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \ - patch("subprocess.run", return_value=mock_result): - result = step.execute(config, ctx) - - assert result.status == StepStatus.FAILED - assert result.output["dispatched"] is True - assert result.output["exit_code"] == 1 + assert result.status == StepStatus.COMPLETED + assert result.output["dry_run"] is True + assert result.output["dispatched"] is False + assert result.output["executed"] is False + assert result.output["command"] == "speckit.specify" + assert result.output["input"]["args"] == "login" + # No AI call was made (shutil.which would fail anyway, but dry_run short-circuits before that) + assert "invoke_command" in result.output + # The DRY RUN preview is published on both ``message`` and + # ``dry_run_message`` so the CLI's render loop can prefer the + # latter without per-step-type special-casing. + assert "DRY RUN" in result.output["message"] + assert result.output["dry_run_message"] == result.output["message"] class TestPromptStep: @@ -906,46 +842,41 @@ def test_dispatch_with_mock_cli(self, tmp_path): assert result.status == StepStatus.COMPLETED assert result.output["dispatched"] is True assert result.output["exit_code"] == 0 - - def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch): - """Prompt preflight falls back to build_exec_args() argv[0].""" - from unittest.mock import MagicMock, patch + # Real-run branch must set executed=True so downstream + # ``{{ steps..output.executed }}`` resolves to truthy. + assert result.output["executed"] is True + + def test_dry_run_prompt_short_circuits(self, tmp_path): + """PromptStep must honor ``context.dry_run`` — the same contract + as CommandStep / GateStep. Without this, a workflow with + ``type: prompt`` makes real AI calls even in dry-run mode.""" from specify_cli.workflows.steps.prompt import PromptStep from specify_cli.workflows.base import StepContext, StepStatus - monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude") - seen_which: list[str] = [] - - def fake_which(name: str) -> str | None: - seen_which.append(name) - return name if name == "/opt/claude" else None - step = PromptStep() ctx = StepContext( + inputs={"file": "auth.py"}, default_integration="claude", project_root=str(tmp_path), + dry_run=True, ) config = { - "id": "ask", + "id": "review", "type": "prompt", - "prompt": "Explain this code", + "prompt": "Review {{ inputs.file }} for security issues", } - - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "Here is the explanation" - mock_result.stderr = "" - - with patch("specify_cli.workflows.steps.prompt.shutil.which", side_effect=fake_which), \ - patch("subprocess.run", return_value=mock_result) as mock_run: - result = step.execute(config, ctx) + result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED - assert result.output["dispatched"] is True - assert seen_which[:2] == ["claude", "/opt/claude"] - call_args = mock_run.call_args - assert call_args[0][0][0] == "/opt/claude" - assert call_args[0][0][2] == "Explain this code" + assert result.output["dry_run"] is True + assert result.output["executed"] is False + assert result.output["dispatched"] is False + assert result.output["exit_code"] == 0 + # The dry-run preview lives on both message and dry_run_message. + assert "DRY RUN" in result.output["message"] + assert result.output["dry_run_message"] == result.output["message"] + # The rendered prompt must surface in the preview. + assert "auth.py" in result.output["message"] def test_validate_missing_prompt(self): from specify_cli.workflows.steps.prompt import PromptStep @@ -965,17 +896,6 @@ def test_validate_valid(self): class TestShellStep: """Test the shell step type.""" - @staticmethod - def _python_run(tmp_path, body): - """A portable shell ``run`` that executes ``body`` with the current - interpreter, avoiding non-portable shell quoting (e.g. Windows - ``cmd.exe`` keeping single quotes) in the output_format tests.""" - import sys - - script = tmp_path / "emit.py" - script.write_text(body, encoding="utf-8") - return f'"{sys.executable}" "{script}"' - def test_execute_echo(self): from specify_cli.workflows.steps.shell import ShellStep from specify_cli.workflows.base import StepContext, StepStatus @@ -1008,62 +928,6 @@ def test_validate_missing_run(self): assert any("missing 'run'" in e for e in errors) - def test_output_format_json_exposes_data(self, tmp_path): - from specify_cli.workflows.steps.shell import ShellStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = ShellStep() - ctx = StepContext(project_root=str(tmp_path)) - config = { - "id": "emit", - "run": self._python_run( - tmp_path, 'import json; print(json.dumps({"items": [1, 2]}))\n' - ), - "output_format": "json", - } - result = step.execute(config, ctx) - assert result.status == StepStatus.COMPLETED - assert result.output["data"] == {"items": [1, 2]} - assert result.output["exit_code"] == 0 # raw keys still present - - def test_output_format_json_invalid_stdout_fails(self, tmp_path): - from specify_cli.workflows.steps.shell import ShellStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = ShellStep() - ctx = StepContext(project_root=str(tmp_path)) - config = { - "id": "emit", - "run": self._python_run(tmp_path, "print('not-json')\n"), - "output_format": "json", - } - result = step.execute(config, ctx) - assert result.status == StepStatus.FAILED - assert "output_format: json" in (result.error or "") - - def test_no_output_format_keeps_raw_output_only(self, tmp_path): - from specify_cli.workflows.steps.shell import ShellStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = ShellStep() - ctx = StepContext(project_root=str(tmp_path)) - config = { - "id": "emit", - "run": self._python_run( - tmp_path, 'import json; print(json.dumps({"items": []}))\n' - ), - } - result = step.execute(config, ctx) - assert result.status == StepStatus.COMPLETED - assert "data" not in result.output - - def test_validate_rejects_unknown_output_format(self): - from specify_cli.workflows.steps.shell import ShellStep - - step = ShellStep() - errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"}) - assert any("'output_format' must be 'json'" in e for e in errors) - class _StubStdin: """Stdin stub exposing only a fixed ``isatty`` result. @@ -1102,171 +966,6 @@ def _force_gate_stdin(monkeypatch, *, tty: bool): monkeypatch.setattr(gate_module, "sys", _FakeSys(tty=tty)) -class TestInitStep: - """Test the init step type.""" - - def test_builds_here_argv_and_bootstraps(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = InitStep() - ctx = StepContext( - project_root=str(tmp_path), default_integration="copilot" - ) - config = {"id": "bootstrap", "here": True, "script": "sh"} - result = step.execute(config, ctx) - - assert result.status == StepStatus.COMPLETED - assert result.output["exit_code"] == 0 - argv = result.output["argv"] - assert argv[0] == "init" - assert "--here" in argv - assert "--integration" in argv and "copilot" in argv - assert "--ignore-agent-tools" in argv - assert (tmp_path / ".specify").is_dir() - - def test_default_integration_falls_back_to_workflow_default(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = InitStep() - ctx = StepContext( - project_root=str(tmp_path), default_integration="copilot" - ) - result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh"}, ctx - ) - assert result.status == StepStatus.COMPLETED - assert result.output["integration"] == "copilot" - - def test_project_name_creates_subdirectory(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = InitStep() - ctx = StepContext( - project_root=str(tmp_path), default_integration="copilot" - ) - result = step.execute( - { - "id": "bootstrap", - "project": "demo", - "script": "sh", - }, - ctx, - ) - assert result.status == StepStatus.COMPLETED - assert (tmp_path / "demo" / ".specify").is_dir() - - def test_invalid_integration_fails(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = InitStep() - ctx = StepContext(project_root=str(tmp_path)) - result = step.execute( - { - "id": "bootstrap", - "here": True, - "integration": "no-such-agent", - "script": "sh", - }, - ctx, - ) - assert result.status == StepStatus.FAILED - assert result.output["exit_code"] != 0 - assert result.error is not None - - def test_non_empty_current_dir_without_force_fails_fast(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - (tmp_path / "existing.txt").write_text("data") - - step = InitStep() - ctx = StepContext( - project_root=str(tmp_path), default_integration="copilot" - ) - result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh"}, - ctx, - ) - assert result.status == StepStatus.FAILED - assert "force: true" in (result.error or "") - assert not (tmp_path / ".specify").exists() - - def test_engine_owned_dirs_do_not_trigger_non_empty_check(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - # Simulate the engine creating its run-state directory before steps run - (tmp_path / ".specify" / "workflows" / "runs" / "abc123").mkdir( - parents=True - ) - - step = InitStep() - ctx = StepContext( - project_root=str(tmp_path), default_integration="copilot" - ) - result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh"}, - ctx, - ) - assert result.status == StepStatus.COMPLETED - # Verify --force was implicitly added - assert "--force" in result.output["argv"] - - def test_default_integration_when_none_provided(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = InitStep() - # No default_integration on context either - ctx = StepContext(project_root=str(tmp_path)) - result = step.execute( - {"id": "bootstrap", "here": True, "script": "sh"}, - ctx, - ) - assert result.status == StepStatus.COMPLETED - assert result.output["integration"] == "copilot" - - def test_integration_options_passed_through(self, tmp_path): - from specify_cli.workflows.steps.init import InitStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = InitStep() - ctx = StepContext( - project_root=str(tmp_path), default_integration="copilot" - ) - result = step.execute( - { - "id": "bootstrap", - "here": True, - "script": "sh", - "integration": "copilot", - "integration_options": "--skills", - }, - ctx, - ) - assert result.status == StepStatus.COMPLETED - assert "--integration-options" in result.output["argv"] - assert "--skills" in result.output["argv"] - assert result.output["integration_options"] == "--skills" - - def test_validate_rejects_bad_script(self): - from specify_cli.workflows.steps.init import InitStep - - step = InitStep() - errors = step.validate({"id": "bootstrap", "script": "bogus"}) - assert any("'script' must be 'sh' or 'ps'" in e for e in errors) - - def test_validate_accepts_valid(self): - from specify_cli.workflows.steps.init import InitStep - - step = InitStep() - assert step.validate({"id": "bootstrap", "script": "sh"}) == [] - - class TestGateStep: """Test the gate step type.""" @@ -1313,6 +1012,77 @@ def test_validate_invalid_on_reject(self): }) assert any("on_reject" in e for e in errors) + def test_dry_run_skips_interactive_gate(self, tmp_path): + """Dry-run mode: gate step returns COMPLETED without pausing for user input.""" + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext(dry_run=True) + config = { + "id": "review", + "message": "Review the spec.", + "options": ["approve", "reject"], + "on_reject": "abort", + } + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + # Dry-run output must be marked so the CLI prints it (mirrors + # CommandStep's output['dry_run'] = True contract). + assert result.output["dry_run"] is True + # The original ``message`` is preserved so downstream + # ``{{ steps..output.message }}`` references still resolve + # to the gate prompt; the DRY RUN preview is published on + # ``dry_run_message`` for the CLI rendering loop. + assert result.output["message"] == "Review the spec." + assert "DRY RUN" in result.output["dry_run_message"] + assert result.output["choice"] == "approve" + + def test_dry_run_normalizes_non_list_options(self, tmp_path): + """A workflow that bypasses validation may set ``options`` to a + non-list (string, dict, scalar). ``GateStep`` must coerce + gracefully instead of indexing into a string or raising.""" + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext(dry_run=True) + for bad_options in (None, "approve,reject", {"approve": True}, 42, ""): + config = { + "id": "review", + "message": "Review the spec.", + "options": bad_options, + } + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["options"] == [] + assert result.output["choice"] is None + + def test_interactive_path_fails_on_empty_options(self, tmp_path, monkeypatch): + """A workflow that bypassed validation and produced + ``options=[]`` would crash the prompt loop with an IndexError + in the non-dry-run interactive path. GateStep must fail + loudly with a clear message instead.""" + from specify_cli.workflows.steps import gate as gate_mod + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + # Force the interactive branch (not the non-interactive PAUSED + # fallback). Patch on the module's ``sys`` binding so the gate + # code resolves the patch even when pytest replaces + # ``sys.stdin`` for capture. + monkeypatch.setattr(gate_mod.sys.stdin, "isatty", lambda: True) + step = GateStep() + ctx = StepContext() + config = { + "id": "review", + "message": "Review the spec.", + "options": None, # gets normalized to [] + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert "no options" in (result.error or "") + def test_interactive_prompt_renders_show_file(self, tmp_path, monkeypatch, capsys): from specify_cli.workflows.steps.gate import GateStep from specify_cli.workflows.base import StepContext, StepStatus @@ -1481,6 +1251,48 @@ def test_templated_show_file_resolving_to_non_string_is_coerced(self): assert result.status == StepStatus.PAUSED assert result.output["show_file"] == "123" + def test_dry_run_accepts_tuple_options(self, tmp_path): + """``options`` may be a tuple (Sequence but not list). GateStep + should accept any non-str Sequence, not just list.""" + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext(dry_run=True) + config = { + "id": "review", + "message": "Review the spec.", + "options": ("approve", "reject"), + } + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["options"] == ["approve", "reject"] + assert result.output["choice"] == "approve" + + def test_dry_run_skips_reject_sentinels_for_choice(self, tmp_path): + """In dry-run, ``choice`` should not be a reject/abort sentinel + that would unintentionally steer downstream branching.""" + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext(dry_run=True) + # First option is the reject sentinel — the dry-run must skip it + # and fall through to the first non-sentinel option. + config = { + "id": "review", + "message": "Review the spec.", + "options": ["reject", "approve", "skip"], + } + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["choice"] == "approve" + + # All options are sentinels — leave choice as None (neutral). + config["options"] = ["reject", "abort"] + result = step.execute(config, ctx) + assert result.output["choice"] is None + class TestIfThenStep: """Test the if/then/else step type.""" @@ -1763,9 +1575,9 @@ def test_execute_with_items(self): assert result.output["item_count"] == 2 assert result.output["max_concurrency"] == 3 - def test_execute_non_list_items_fails_loudly(self): + def test_execute_non_list_items_resolves_empty(self): from specify_cli.workflows.steps.fan_out import FanOutStep - from specify_cli.workflows.base import StepContext, StepStatus + from specify_cli.workflows.base import StepContext step = FanOutStep() ctx = StepContext() @@ -1775,26 +1587,10 @@ def test_execute_non_list_items_fails_loudly(self): "step": {"id": "impl", "command": "speckit.implement"}, } result = step.execute(config, ctx) - assert result.status == StepStatus.FAILED - assert "'items' must resolve to a list" in (result.error or "") assert result.output["item_count"] == 0 + assert result.output["items"] == [] - def test_execute_empty_list_items_is_valid(self): - from specify_cli.workflows.steps.fan_out import FanOutStep - from specify_cli.workflows.base import StepContext, StepStatus - - step = FanOutStep() - ctx = StepContext(steps={"tasks": {"output": {"task_list": []}}}) - config = { - "id": "parallel", - "items": "{{ steps.tasks.output.task_list }}", - "step": {"id": "impl", "command": "speckit.implement"}, - } - result = step.execute(config, ctx) - assert result.status == StepStatus.COMPLETED - assert result.output["item_count"] == 0 - - def test_validate_missing_fields(self): + def test_validate_missing_fields(self): from specify_cli.workflows.steps.fan_out import FanOutStep step = FanOutStep() @@ -2138,6 +1934,118 @@ def test_execute_with_gate_pauses(self, project_dir): assert "gate" in state.step_results assert state.step_results["gate"]["status"] == "paused" + def test_execute_dry_run(self, project_dir): + """Dry-run: engine returns COMPLETED without invoking the AI for command steps.""" + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "dryrun-test" + name: "Dry Run Test" + version: "1.0.0" +inputs: + spec: + type: string + default: "test spec" +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.spec }}" + - id: plan + command: speckit.plan + input: + args: "{{ inputs.spec }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + + # In dry-run mode, even if the CLI tool is installed, no AI calls are made + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run") as mock_run: + state = engine.execute(definition, {"spec": "login feature"}, dry_run=True) + + assert state.status == RunStatus.COMPLETED + assert "specify" in state.step_results + assert state.step_results["specify"]["output"]["dry_run"] is True + assert state.step_results["specify"]["output"]["dispatched"] is False + assert "plan" in state.step_results + assert state.step_results["plan"]["output"]["dry_run"] is True + # subprocess.run should NOT have been called in dry-run mode + assert mock_run.call_count == 0 + + def test_dry_run_persisted_in_run_state(self, project_dir): + """Dry-run flag must be saved into RunState so resume() can restore it.""" + from specify_cli.workflows.engine import RunState, WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "dryrun-persist" + name: "Dry Run Persist" + version: "1.0.0" +steps: + - id: only + type: gate + message: "Continue?" + options: ["yes", "no"] +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, dry_run=True, run_id="dr-persist") + + assert state.dry_run is True + + reloaded = RunState.load("dr-persist", project_dir) + assert reloaded.dry_run is True + + def test_resume_restores_dry_run(self, project_dir, monkeypatch): + """resume() must restore the persisted dry_run so an interrupted dry-run + does not silently turn into a real run.""" + from specify_cli.workflows.engine import RunState, WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "dryrun-resume" + name: "Dry Run Resume" + version: "1.0.0" +steps: + - id: only + type: gate + message: "Continue?" + options: ["yes", "no"] +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + + # Persist a paused dry-run with a known run_id, including a copy of + # the workflow YAML so resume() can reload the definition. + run_id = "dr-resume" + state = RunState(run_id=run_id, workflow_id=definition.id, project_root=project_dir) + state.status = RunStatus.PAUSED + state.dry_run = True + state.save() + run_dir = project_dir / ".specify" / "workflows" / "runs" / run_id + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / "workflow.yml").write_text(yaml_str, encoding="utf-8") + + captured: dict[str, bool] = {} + + def spy_execute_steps(steps, context, state, registry, *, step_offset=0): + captured["dry_run"] = context.dry_run + # No-op so resume() returns without further work. + return None + + monkeypatch.setattr(engine, "_execute_steps", spy_execute_steps) + engine.resume(run_id) + + assert captured.get("dry_run") is True + def test_execute_with_shell_step(self, project_dir): from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition from specify_cli.workflows.base import RunStatus @@ -3601,11 +3509,9 @@ def test_persistence(self, project_dir): class TestWorkflowCatalog: """Test WorkflowCatalog catalog resolution.""" - def test_default_catalogs(self, project_dir, monkeypatch): + def test_default_catalogs(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog - monkeypatch.setattr(Path, "home", lambda: project_dir) - monkeypatch.delenv("SPECKIT_WORKFLOW_CATALOG_URL", raising=False) catalog = WorkflowCatalog(project_dir) entries = catalog.get_active_catalogs() assert len(entries) == 2 @@ -3707,78 +3613,6 @@ def test_get_catalog_configs(self, project_dir): assert configs[0]["name"] == "default" assert isinstance(configs[0]["install_allowed"], bool) - def test_load_catalog_config_non_dict_yaml_raises(self, project_dir): - """A YAML catalog config that is a list (not a mapping) must raise WorkflowValidationError.""" - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError - - config_path = project_dir / ".specify" / "workflow-catalogs.yml" - config_path.write_text("- item1\n- item2\n", encoding="utf-8") - - catalog = WorkflowCatalog(project_dir) - with pytest.raises(WorkflowValidationError, match="expected a mapping"): - catalog.get_active_catalogs() - - def test_add_catalog_malformed_yaml_raises(self, project_dir): - """A malformed YAML config file must raise WorkflowValidationError when adding a catalog.""" - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError - - config_path = project_dir / ".specify" / "workflow-catalogs.yml" - config_path.write_text(": invalid: yaml: {\n", encoding="utf-8") - - catalog = WorkflowCatalog(project_dir) - with pytest.raises(WorkflowValidationError, match="unreadable or malformed"): - catalog.add_catalog("https://example.com/new.json") - - def test_remove_catalog_malformed_yaml_raises(self, project_dir): - """A malformed YAML config file must raise WorkflowValidationError when removing a catalog.""" - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError - - catalog = WorkflowCatalog(project_dir) - catalog.add_catalog("https://example.com/c1.json", "first") - - config_path = project_dir / ".specify" / "workflow-catalogs.yml" - config_path.write_text(": bad: yaml: {\n", encoding="utf-8") - - with pytest.raises(WorkflowValidationError, match="unreadable or malformed"): - catalog.remove_catalog(0) - - def test_add_catalog_wraps_write_oserror(self, project_dir, monkeypatch): - """An OSError on write must be wrapped as WorkflowValidationError.""" - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError - import builtins - - catalog = WorkflowCatalog(project_dir) - config_path = project_dir / ".specify" / "workflow-catalogs.yml" - real_open = builtins.open - - def _raising_open(file, mode="r", *args, **kwargs): - if Path(file) == config_path and "w" in mode: - raise OSError("simulated write failure") - return real_open(file, mode, *args, **kwargs) - - monkeypatch.setattr(builtins, "open", _raising_open) - with pytest.raises(WorkflowValidationError, match="Failed to write catalog config"): - catalog.add_catalog("https://example.com/new-catalog.json", "my-catalog") - - def test_remove_catalog_wraps_write_oserror(self, project_dir, monkeypatch): - """An OSError on write must be wrapped as WorkflowValidationError.""" - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError - import builtins - - catalog = WorkflowCatalog(project_dir) - catalog.add_catalog("https://example.com/c1.json", "first") - config_path = project_dir / ".specify" / "workflow-catalogs.yml" - real_open = builtins.open - - def _raising_open(file, mode="r", *args, **kwargs): - if Path(file) == config_path and "w" in mode: - raise OSError("simulated write failure") - return real_open(file, mode, *args, **kwargs) - - monkeypatch.setattr(builtins, "open", _raising_open) - with pytest.raises(WorkflowValidationError, match="Failed to write catalog config"): - catalog.remove_catalog(0) - # ===== Integration Test ===== @@ -3875,1037 +3709,247 @@ def test_switch_workflow(self, project_dir): assert "do-specify" not in state.step_results -# ===== Step Registry Tests ===== - -class TestStepRegistryCustom: - """Test StepRegistry operations for custom step types.""" - - def test_add_and_get(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry - - registry = StepRegistry(project_dir) - registry.add("deploy", {"name": "Deploy", "version": "1.0.0", "type_key": "deploy"}) - - entry = registry.get("deploy") - assert entry is not None - assert entry["name"] == "Deploy" - assert "installed_at" in entry - - def test_add_does_not_mutate_input_metadata(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry - - registry = StepRegistry(project_dir) - metadata = { - "name": "Deploy", - "type_key": "deploy", - "nested": {"key": "original"}, - } - - registry.add("deploy", metadata) - - assert "installed_at" not in metadata - assert "updated_at" not in metadata - metadata["nested"]["key"] = "changed-after-add" - assert registry.get("deploy")["nested"]["key"] == "original" - - def test_remove(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry +class TestWorkflowJsonOutput: + """Test the --json machine-readable output for run/resume/status.""" - registry = StepRegistry(project_dir) - registry.add("deploy", {"name": "Deploy", "type_key": "deploy"}) - assert registry.is_installed("deploy") + _WF = """ +schema_version: "1.0" +workflow: + id: "json-wf" + name: "JSON WF" + version: "1.0.0" +steps: + - id: ask + type: gate + message: "Review" + options: [approve, reject] + - id: after + type: shell + run: "echo done" +""" - registry.remove("deploy") - assert not registry.is_installed("deploy") + _WF_DONE = """ +schema_version: "1.0" +workflow: + id: "json-done" + name: "JSON Done" + version: "1.0.0" +steps: + - id: only + type: shell + run: "echo done" +""" - def test_remove_missing_returns_false(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry + def _write_wf(self, project_dir, text, name): + path = project_dir / f"{name}.yml" + path.write_text(text, encoding="utf-8") + return path - registry = StepRegistry(project_dir) - removed = registry.remove("nonexistent") - assert removed is False + def _invoke(self, project_dir, args): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app - def test_list(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + return runner.invoke(app, args, catch_exceptions=False) - registry = StepRegistry(project_dir) - registry.add("step-a", {"name": "A", "type_key": "step-a"}) - registry.add("step-b", {"name": "B", "type_key": "step-b"}) + def test_run_json_completed(self, project_dir): + wf = self._write_wf(project_dir, self._WF_DONE, "done") + result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["workflow_id"] == "json-done" + assert payload["status"] == "completed" + assert "run_id" in payload - installed = registry.list() - assert "step-a" in installed - assert "step-b" in installed + def test_run_json_paused(self, project_dir): + wf = self._write_wf(project_dir, self._WF, "gated") + result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "paused" + assert payload["current_step_id"] == "ask" + assert payload["current_step_index"] == 0 - def test_is_installed(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry + def test_run_json_output_has_no_markup_or_ansi(self, project_dir): + wf = self._write_wf(project_dir, self._WF_DONE, "clean") + out = self._invoke( + project_dir, ["workflow", "run", str(wf), "--json"] + ).stdout + # Machine output must be exactly the JSON object: no Rich markup + # tags and no ANSI escape sequences leaking in. + assert "\x1b[" not in out + assert "[/" not in out + assert out.strip() == json.dumps(json.loads(out), indent=2) - registry = StepRegistry(project_dir) - assert not registry.is_installed("missing") + def test_run_default_output_is_human_not_json(self, project_dir): + wf = self._write_wf(project_dir, self._WF_DONE, "done2") + result = self._invoke(project_dir, ["workflow", "run", str(wf)]) + assert result.exit_code == 0 + assert "Running workflow" in result.stdout + with pytest.raises(json.JSONDecodeError): + json.loads(result.stdout) - registry.add("exists", {"name": "Exists", "type_key": "exists"}) - assert registry.is_installed("exists") + def test_run_dry_run_flag_wires_through_cli(self, project_dir): + """`specify workflow run --dry-run` emits the DRY-RUN banner and + per-step previews, and crucially does NOT block on a gate step + (the engine short-circuits gates in dry-run mode). + Covers the user-facing entrypoint the engine-level tests miss. + """ + # Use the gated workflow ``_WF`` — without --dry-run this would + # pause on the gate and require a resume. With --dry-run the + # gate is short-circuited, the run completes end-to-end, and + # the DRY-RUN banner + per-step preview lines are emitted. + wf = self._write_wf(project_dir, self._WF, "dry") + result = self._invoke(project_dir, ["workflow", "run", str(wf), "--dry-run"]) + assert result.exit_code == 0 + # Banner is printed to stdout in default (non-JSON) mode. + assert "DRY RUN" in result.stdout + # The preview loop printed the step_id of the gated step and + # its rendered dry-run body. GateStep in dry-run mode emits a + # ``[DRY RUN] Gate: ...`` body (see workflows/steps/gate). + assert "ask" in result.stdout + assert "[DRY RUN]" in result.stdout + # Status is reported as completed (dry-run short-circuits the + # gate to COMPLETED rather than PAUSED). + assert "Status: completed" in result.stdout + # The "Resume with:" hint that would appear for a paused run + # must NOT be present — the dry-run completed end-to-end. + assert "Resume with:" not in result.stdout + + def test_run_dry_run_with_json_suppresses_banner_and_previews(self, project_dir): + """`--dry-run` together with `--json` must NOT print the banner + or the per-step preview lines — stdout stays a single JSON object. + Mirrors the help text claim added in commit for review 3391287191. + """ + wf = self._write_wf(project_dir, self._WF, "dry-json") + result = self._invoke( + project_dir, ["workflow", "run", str(wf), "--dry-run", "--json"] + ) + assert result.exit_code == 0 + # Stdout is exactly a JSON object — no DRY-RUN banner or preview + # lines leaked in. + assert "DRY RUN" not in result.stdout + assert "[DRY RUN]" not in result.stdout + payload = json.loads(result.stdout) + # And the run still completed end-to-end (gate short-circuited). + assert payload["status"] == "completed" - def test_persistence(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry + def test_run_dry_run_prints_previews_on_engine_exception(self, project_dir): + """When the engine raises mid-run, the dry-run previews that + earlier steps already resolved into ``state.step_results`` are + still the most useful debug signal — they must surface in the + terminal, not be silently dropped. Covers review 3423394535. + """ + wf = self._write_wf(project_dir, self._WF, "dry-fail") - registry1 = StepRegistry(project_dir) - registry1.add("deploy", {"name": "Deploy", "type_key": "deploy"}) + from unittest.mock import patch + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState - registry2 = StepRegistry(project_dir) - assert registry2.is_installed("deploy") + def _raise_with_partial(self, *_args, **_kwargs): + state = RunState(run_id="dr-fail-partial", workflow_id="json-wf") + state.step_results["ask"] = { + "output": { + "dry_run": True, + "dry_run_message": "[DRY RUN] Gate: Review", + "message": "[DRY RUN] Gate: Review", + } + } + state.status = RunStatus.FAILED + err = RuntimeError("synthetic engine failure mid-dry-run") + err.partial_state = state # type: ignore[attr-defined] + raise err + + with patch.object( + __import__( + "specify_cli.workflows.engine", fromlist=["WorkflowEngine"] + ).WorkflowEngine, + "execute", + _raise_with_partial, + ): + result = self._invoke( + project_dir, ["workflow", "run", str(wf), "--dry-run"] + ) - def test_corrupted_registry_resets(self, project_dir): - from specify_cli.workflows.catalog import StepRegistry + # The CLI exits non-zero on the engine exception. + assert result.exit_code != 0 + # The error banner is shown. + assert "Workflow failed" in result.stdout + # The previously-resolved dry-run previews are still printed — + # the engine exception path must not silently drop them. + assert "DRY RUN" in result.stdout + assert "Gate: Review" in result.stdout + + def test_run_dry_run_no_previews_when_json(self, project_dir): + """``--json`` mode must not leak dry-run previews even on + engine exception — the stdout-stays-JSON contract holds + in the failure path too. Mirrors the success-path test + ``test_run_dry_run_with_json_suppresses_banner_and_previews``. + """ + wf = self._write_wf(project_dir, self._WF, "dry-fail-json") - registry = StepRegistry(project_dir) - registry.steps_dir.mkdir(parents=True, exist_ok=True) - registry.registry_path.write_text("not json", encoding="utf-8") + from unittest.mock import patch + from specify_cli.workflows.base import RunStatus + from specify_cli.workflows.engine import RunState - # Loading again should reset - registry2 = StepRegistry(project_dir) - assert registry2.list() == {} + def _raise_with_partial(self, *_args, **_kwargs): + state = RunState(run_id="dr-fail-json", workflow_id="json-wf") + state.step_results["ask"] = { + "output": { + "dry_run": True, + "dry_run_message": "[DRY RUN] Gate: Review", + "message": "[DRY RUN] Gate: Review", + } + } + state.status = RunStatus.FAILED + err = RuntimeError("synthetic engine failure mid-dry-run") + err.partial_state = state # type: ignore[attr-defined] + raise err + + with patch.object( + __import__( + "specify_cli.workflows.engine", fromlist=["WorkflowEngine"] + ).WorkflowEngine, + "execute", + _raise_with_partial, + ): + result = self._invoke( + project_dir, + ["workflow", "run", str(wf), "--dry-run", "--json"], + ) - def test_registry_missing_steps_key_resets(self, project_dir): - """Valid JSON but missing 'steps' key should not crash add/get.""" - from specify_cli.workflows.catalog import StepRegistry - import json as _json + # CLI exits non-zero on the engine exception. + assert result.exit_code != 0 + # Stdout is empty (or near-empty) — no banner, no preview lines. + # Under --json the engine exception short-circuits before any + # _emit_workflow_json call so the user sees a clean error path. + assert "DRY RUN" not in result.stdout + assert "[DRY RUN]" not in result.stdout - registry = StepRegistry(project_dir) - registry.steps_dir.mkdir(parents=True, exist_ok=True) - # Valid JSON but 'steps' is not a dict - registry.registry_path.write_text( - _json.dumps({"schema_version": "1.0", "steps": "bad"}), - encoding="utf-8", + def test_status_json_single_and_list(self, project_dir): + wf = self._write_wf(project_dir, self._WF, "gated2") + run = json.loads( + self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout ) + rid = run["run_id"] - registry2 = StepRegistry(project_dir) - # Should be safe to call add/get without KeyError - assert registry2.list() == {} - registry2.add("deploy", {"name": "Deploy", "type_key": "deploy"}) - assert registry2.is_installed("deploy") - - @pytest.mark.skipif(sys.platform == "win32", reason="chmod not reliable on Windows") - def test_registry_unreadable_file_resets(self, project_dir): - """OSError reading the registry file should fall back to default.""" - from specify_cli.workflows.catalog import StepRegistry - import json as _json - - registry = StepRegistry(project_dir) - registry.steps_dir.mkdir(parents=True, exist_ok=True) - # Write valid registry first - registry.registry_path.write_text( - _json.dumps({"schema_version": "1.0", "steps": {"existing": {}}}), - encoding="utf-8", + single = json.loads( + self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout ) - # Make it unreadable - registry.registry_path.chmod(0o000) - try: - registry2 = StepRegistry(project_dir) - assert registry2.list() == {} - finally: - registry.registry_path.chmod(0o644) - - # After restoring permissions the registry is fully functional - registry2.add("deploy", {"name": "Deploy", "type_key": "deploy"}) - assert registry2.is_installed("deploy") - - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") - def test_registry_load_refuses_symlinked_steps_dir(self, project_dir): - """A symlinked steps directory must not be read from (defense-in-depth).""" - from specify_cli.workflows.catalog import StepRegistry - import json as _json + assert single["run_id"] == rid + assert single["status"] == "paused" + assert single["steps"]["ask"] == "paused" + # status --json carries the same step-position fields as run/resume + # so automation never has to branch on which command produced it. + assert single["current_step_id"] == run["current_step_id"] + assert single["current_step_index"] == run["current_step_index"] - outside = project_dir.parent / "outside-steps" - outside.mkdir(parents=True, exist_ok=True) - (outside / "step-registry.json").write_text( - _json.dumps({"schema_version": "1.0", "steps": {"evil": {}}}), - encoding="utf-8", + listing = json.loads( + self._invoke(project_dir, ["workflow", "status", "--json"]).stdout ) - steps_link = project_dir / ".specify" / "workflows" / "steps" - steps_link.symlink_to(outside, target_is_directory=True) - - registry = StepRegistry(project_dir) - assert registry.list() == {} - - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") - def test_registry_save_refuses_symlinked_steps_dir(self, project_dir): - """save() must refuse symlinked registry paths (defense-in-depth).""" - from specify_cli.workflows.catalog import StepRegistry, StepValidationError - - outside = project_dir.parent / "outside-steps-save" - outside.mkdir(parents=True, exist_ok=True) - steps_link = project_dir / ".specify" / "workflows" / "steps" - steps_link.symlink_to(outside, target_is_directory=True) - - registry = StepRegistry(project_dir) - with pytest.raises(StepValidationError, match="symlinked path"): - registry.save() - - -# ===== Step Catalog Tests ===== - -class TestStepCatalog: - """Test StepCatalog catalog resolution.""" - - def test_default_catalogs(self, project_dir, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog - - monkeypatch.setattr(Path, "home", lambda: project_dir) - monkeypatch.delenv("SPECKIT_STEP_CATALOG_URL", raising=False) - catalog = StepCatalog(project_dir) - entries = catalog.get_active_catalogs() - assert len(entries) == 2 - assert entries[0].name == "default" - assert entries[1].name == "community" - - def test_env_var_override(self, project_dir, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog - - monkeypatch.setenv("SPECKIT_STEP_CATALOG_URL", "https://example.com/step-catalog.json") - catalog = StepCatalog(project_dir) - entries = catalog.get_active_catalogs() - assert len(entries) == 1 - assert entries[0].name == "env-override" - assert entries[0].url == "https://example.com/step-catalog.json" - - def test_project_level_config(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog - - config_path = project_dir / ".specify" / "step-catalogs.yml" - config_path.write_text(yaml.dump({ - "catalogs": [{ - "name": "custom", - "url": "https://example.com/step-catalog.json", - "priority": 1, - "install_allowed": True, - }] - })) - - catalog = StepCatalog(project_dir) - entries = catalog.get_active_catalogs() - assert len(entries) == 1 - assert entries[0].name == "custom" - - def test_validate_url_http_rejected(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog, StepValidationError - - catalog = StepCatalog(project_dir) - with pytest.raises(StepValidationError, match="HTTPS"): - catalog._validate_catalog_url("http://evil.com/step-catalog.json") - - def test_validate_url_localhost_http_allowed(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog - - catalog = StepCatalog(project_dir) - # Should not raise - catalog._validate_catalog_url("http://localhost:8080/step-catalog.json") - - def test_add_catalog(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog - - catalog = StepCatalog(project_dir) - catalog.add_catalog("https://example.com/new-steps.json", "my-steps") - - config_path = project_dir / ".specify" / "step-catalogs.yml" - assert config_path.exists() - data = yaml.safe_load(config_path.read_text()) - assert len(data["catalogs"]) == 1 - assert data["catalogs"][0]["url"] == "https://example.com/new-steps.json" - - def test_add_catalog_empty_yaml_file(self, project_dir): - """An empty YAML config file should be treated as empty, not corrupted.""" - from specify_cli.workflows.catalog import StepCatalog - - config_path = project_dir / ".specify" / "step-catalogs.yml" - config_path.write_text("", encoding="utf-8") - - catalog = StepCatalog(project_dir) - # Should not raise StepValidationError "corrupted" - catalog.add_catalog("https://example.com/steps.json", "my-steps") - - data = yaml.safe_load(config_path.read_text()) - assert len(data["catalogs"]) == 1 - assert data["catalogs"][0]["url"] == "https://example.com/steps.json" - - def test_add_catalog_duplicate_rejected(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog, StepValidationError - - catalog = StepCatalog(project_dir) - catalog.add_catalog("https://example.com/steps.json") - - with pytest.raises(StepValidationError, match="already configured"): - catalog.add_catalog("https://example.com/steps.json") - - def test_remove_catalog(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog - - catalog = StepCatalog(project_dir) - catalog.add_catalog("https://example.com/s1.json", "first") - catalog.add_catalog("https://example.com/s2.json", "second") - - removed = catalog.remove_catalog(0) - assert removed == "first" - - config_path = project_dir / ".specify" / "step-catalogs.yml" - data = yaml.safe_load(config_path.read_text()) - assert len(data["catalogs"]) == 1 - - def test_remove_catalog_invalid_index(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog, StepValidationError - - catalog = StepCatalog(project_dir) - catalog.add_catalog("https://example.com/s1.json") - - with pytest.raises(StepValidationError, match="out of range"): - catalog.remove_catalog(5) - - def test_remove_catalog_no_config(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog, StepValidationError - - catalog = StepCatalog(project_dir) - with pytest.raises(StepValidationError, match="No step catalog config file found"): - catalog.remove_catalog(0) - - def test_add_catalog_wraps_write_oserror(self, project_dir, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog, StepValidationError - import builtins - - catalog = StepCatalog(project_dir) - config_path = project_dir / ".specify" / "step-catalogs.yml" - real_open = builtins.open - - def _raising_open(file, mode="r", *args, **kwargs): - if Path(file) == config_path and "w" in mode: - raise OSError("simulated write failure") - return real_open(file, mode, *args, **kwargs) - - monkeypatch.setattr(builtins, "open", _raising_open) - with pytest.raises(StepValidationError, match="Failed to write catalog config"): - catalog.add_catalog("https://example.com/new-steps.json", "my-steps") - - def test_remove_catalog_wraps_write_oserror(self, project_dir, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog, StepValidationError - import builtins - - catalog = StepCatalog(project_dir) - catalog.add_catalog("https://example.com/s1.json", "first") - config_path = project_dir / ".specify" / "step-catalogs.yml" - real_open = builtins.open - - def _raising_open(file, mode="r", *args, **kwargs): - if Path(file) == config_path and "w" in mode: - raise OSError("simulated write failure") - return real_open(file, mode, *args, **kwargs) - - monkeypatch.setattr(builtins, "open", _raising_open) - with pytest.raises(StepValidationError, match="Failed to write catalog config"): - catalog.remove_catalog(0) - - def test_get_catalog_configs(self, project_dir): - from specify_cli.workflows.catalog import StepCatalog - - catalog = StepCatalog(project_dir) - configs = catalog.get_catalog_configs() - assert len(configs) == 2 - assert configs[0]["name"] == "default" - assert isinstance(configs[0]["install_allowed"], bool) - - def test_search_with_mock_catalog(self, project_dir, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog - - mock_data = { - "schema_version": "1.0", - "steps": { - "deploy": { - "id": "deploy", - "name": "Deploy Step", - "description": "Deploy to production", - "version": "1.0.0", - }, - "notify": { - "id": "notify", - "name": "Notify Step", - "description": "Send notifications", - "version": "1.0.0", - }, - }, - } - - catalog = StepCatalog(project_dir) - monkeypatch.setattr(catalog, "_get_merged_steps", lambda **kw: { - "deploy": dict(mock_data["steps"]["deploy"], _catalog_name="test", _install_allowed=True), - "notify": dict(mock_data["steps"]["notify"], _catalog_name="test", _install_allowed=True), - }) - - results = catalog.search() - assert len(results) == 2 - - results = catalog.search(query="deploy") - assert len(results) == 1 - assert results[0]["id"] == "deploy" - - def test_search_with_non_string_fields(self, project_dir, monkeypatch): - """Non-string catalog fields (e.g. integer id) must not raise TypeError.""" - from specify_cli.workflows.catalog import StepCatalog - - catalog = StepCatalog(project_dir) - monkeypatch.setattr(catalog, "_get_merged_steps", lambda **kw: { - "42": { - "id": 42, - "name": None, - "description": 99, - "_catalog_name": "test", - "_install_allowed": True, - }, - }) - - results = catalog.search() - assert len(results) == 1 - - results = catalog.search(query="42") - assert len(results) == 1 - - results = catalog.search(query="missing") - assert len(results) == 0 - - def test_get_merged_steps_normalizes_list_ids_to_strings(self, project_dir, monkeypatch): - """List-based catalog entries with non-string ids must be normalized.""" - from specify_cli.workflows.catalog import StepCatalog, StepCatalogEntry - - catalog = StepCatalog(project_dir) - entry = StepCatalogEntry( - name="test", - url="https://example.com/steps.json", - priority=1, - install_allowed=True, - ) - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [entry]) - monkeypatch.setattr( - catalog, - "_fetch_single_catalog", - lambda _entry, _force_refresh=False: { - "steps": [{"id": 42, "name": "Integer ID"}] - }, - ) - - merged = catalog._get_merged_steps() - assert "42" in merged - assert 42 not in merged - assert merged["42"]["id"] == "42" - - def test_get_step_info_returns_entry_or_none(self, project_dir, monkeypatch): - """get_step_info returns matching entry or None for missing ids.""" - from specify_cli.workflows.catalog import StepCatalog - - catalog = StepCatalog(project_dir) - monkeypatch.setattr(catalog, "_get_merged_steps", lambda **kw: { - "deploy": { - "id": "deploy", - "name": "Deploy Step", - "version": "1.0.0", - "_catalog_name": "test", - "_install_allowed": True, - }, - }) - - info = catalog.get_step_info("deploy") - assert info is not None - assert info["name"] == "Deploy Step" - - missing = catalog.get_step_info("nonexistent") - assert missing is None - - -# ===== Load Custom Steps Tests ===== - -class TestLoadCustomSteps: - """Test dynamic loading of custom step types from the filesystem.""" - - def test_empty_steps_dir(self, project_dir): - from specify_cli.workflows import load_custom_steps - - loaded = load_custom_steps(project_dir) - assert loaded == [] - - def test_no_steps_dir(self, project_dir): - from specify_cli.workflows import load_custom_steps - - # .specify/workflows/steps does not exist - loaded = load_custom_steps(project_dir) - assert loaded == [] - - def test_load_valid_custom_step(self, project_dir): - from specify_cli.workflows import load_custom_steps, STEP_REGISTRY - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "test-custom" - step_dir.mkdir(parents=True) - - step_yml = """ -schema_version: "1.0" -step: - type_key: "test-custom" - name: "Test Custom Step" - version: "1.0.0" - author: "test" - description: "A test custom step" -""" - (step_dir / "step.yml").write_text(step_yml, encoding="utf-8") - - init_py = """ -from specify_cli.workflows.base import StepBase, StepResult - -class TestCustomStep(StepBase): - type_key = "test-custom" - - def execute(self, config, context): - return StepResult() -""" - (step_dir / "__init__.py").write_text(init_py, encoding="utf-8") - - loaded = load_custom_steps(project_dir) - assert "test-custom" in loaded - assert "test-custom" in STEP_REGISTRY - - def test_skip_missing_step_yml(self, project_dir): - from specify_cli.workflows import load_custom_steps - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "bad-step" - step_dir.mkdir(parents=True) - (step_dir / "__init__.py").write_text("# no step.yml", encoding="utf-8") - - loaded = load_custom_steps(project_dir) - assert "bad-step" not in loaded - - def test_skip_missing_init_py(self, project_dir): - from specify_cli.workflows import load_custom_steps - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "bad-step2" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: bad-step2\n", encoding="utf-8" - ) - - loaded = load_custom_steps(project_dir) - assert "bad-step2" not in loaded - - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") - def test_skip_symlinked_step_files(self, project_dir): - from specify_cli.workflows import load_custom_steps - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "bad-symlinked-files" - step_dir.mkdir(parents=True) - - outside = project_dir.parent / "outside-step-files" - outside.mkdir(parents=True, exist_ok=True) - step_yml_target = outside / "step.yml" - step_yml_target.write_text("step:\n type_key: bad-symlinked-files\n", encoding="utf-8") - init_target = outside / "__init__.py" - init_target.write_text("# external code", encoding="utf-8") - - (step_dir / "step.yml").symlink_to(step_yml_target) - (step_dir / "__init__.py").symlink_to(init_target) - - loaded = load_custom_steps(project_dir) - assert "bad-symlinked-files" not in loaded - - def test_skip_already_registered(self, project_dir): - from specify_cli.workflows import load_custom_steps - - # "command" is already registered as a built-in step - step_dir = project_dir / ".specify" / "workflows" / "steps" / "command" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: command\n", encoding="utf-8" - ) - (step_dir / "__init__.py").write_text("", encoding="utf-8") - - # Should not raise KeyError; just skip - loaded = load_custom_steps(project_dir) - assert "command" not in loaded - - def test_skip_broken_init_py(self, project_dir): - from specify_cli.workflows import load_custom_steps - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "broken-step" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: broken-step\n", encoding="utf-8" - ) - (step_dir / "__init__.py").write_text( - "raise RuntimeError('broken')", encoding="utf-8" - ) - - # Should not propagate exception - loaded = load_custom_steps(project_dir) - assert "broken-step" not in loaded - - def test_module_name_sanitized_for_hyphenated_type_key(self, project_dir): - """type_key values with hyphens produce valid Python module identifiers.""" - import hashlib - import sys - from specify_cli.workflows import load_custom_steps, STEP_REGISTRY - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "my-hyphen-step" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: my-hyphen-step\n name: Hyphen Step\n", - encoding="utf-8", - ) - - init_py = """ -from specify_cli.workflows.base import StepBase, StepResult - -class HyphenStep(StepBase): - type_key = "my-hyphen-step" - - def execute(self, config, context): - return StepResult() -""" - (step_dir / "__init__.py").write_text(init_py, encoding="utf-8") - - loaded = load_custom_steps(project_dir) - assert "my-hyphen-step" in loaded - assert "my-hyphen-step" in STEP_REGISTRY - # Synthetic module name must be a valid identifier (hyphens → underscores) - # and include a collision-resistant hash suffix. - key_hash = hashlib.sha256(b"my-hyphen-step").hexdigest()[:8] - module_name = f"_speckit_custom_step_my_hyphen_step_{key_hash}" - assert module_name in sys.modules - - def test_package_relative_import(self, project_dir): - """Steps can use relative imports to access sibling modules.""" - import hashlib - import sys - from specify_cli.workflows import load_custom_steps, STEP_REGISTRY - - step_dir = project_dir / ".specify" / "workflows" / "steps" / "pkg-step" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: pkg-step\n name: Package Step\n", - encoding="utf-8", - ) - # Helper module that the step will import relatively - (step_dir / "helpers.py").write_text( - "HELPER_VALUE = 'hello'\n", encoding="utf-8" - ) - init_py = """ -from specify_cli.workflows.base import StepBase, StepResult -from .helpers import HELPER_VALUE - -class PkgStep(StepBase): - type_key = "pkg-step" - helper = HELPER_VALUE - - def execute(self, config, context): - return StepResult() -""" - (step_dir / "__init__.py").write_text(init_py, encoding="utf-8") - - loaded = load_custom_steps(project_dir) - assert "pkg-step" in loaded - assert "pkg-step" in STEP_REGISTRY - # Verify the relative import actually resolved; module name includes hash suffix. - key_hash = hashlib.sha256(b"pkg-step").hexdigest()[:8] - module_name = f"_speckit_custom_step_pkg_step_{key_hash}" - assert module_name in sys.modules - assert sys.modules[module_name].PkgStep.helper == "hello" - - def test_module_name_collision_resistance(self, project_dir): - """'a-b' and 'a_b' produce different module names despite the same sanitized form.""" - import hashlib - - # Simulate the module name generation for two type_keys that sanitize the same way - def make_module_name(type_key: str) -> str: - import re - safe_key = re.sub(r"[^A-Za-z0-9_]", "_", type_key) - key_hash = hashlib.sha256(type_key.encode()).hexdigest()[:8] - return f"_speckit_custom_step_{safe_key}_{key_hash}" - - name_a = make_module_name("a-b") - name_b = make_module_name("a_b") - assert name_a != name_b, "Module names for 'a-b' and 'a_b' must differ" - - -# ===== CLI Step Remove Tests ===== - -class TestWorkflowStepRemoveCLI: - """Test the 'specify workflow step remove' CLI command edge cases.""" - - def test_remove_orphaned_directory(self, project_dir, monkeypatch): - """step remove works when directory exists but registry entry is missing. - - This covers the case where the registry was reset due to corruption. - """ - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(project_dir) - - # Create an orphaned step directory (no registry entry) - step_dir = project_dir / ".specify" / "workflows" / "steps" / "orphan-step" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: orphan-step\n", encoding="utf-8" - ) - (step_dir / "__init__.py").write_text("", encoding="utf-8") - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "remove", "orphan-step"]) - - assert result.exit_code == 0, result.output - assert not step_dir.exists() - # Warning should be printed about missing registry entry - assert "Warning" in result.output or "warning" in result.output.lower() - - def test_remove_not_installed(self, project_dir, monkeypatch): - """step remove fails cleanly when neither directory nor registry entry exist.""" - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(project_dir) - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "remove", "ghost-step"]) - - assert result.exit_code != 0 - assert "not installed" in result.output - - def test_remove_registered_step(self, project_dir, monkeypatch): - """step remove works normally when both directory and registry entry exist.""" - from typer.testing import CliRunner - from specify_cli import app - from specify_cli.workflows.catalog import StepRegistry - - monkeypatch.chdir(project_dir) - - # Set up a registered step with a directory - registry = StepRegistry(project_dir) - registry.add("my-step", {"name": "My Step", "type_key": "my-step", "version": "1.0.0"}) - step_dir = project_dir / ".specify" / "workflows" / "steps" / "my-step" - step_dir.mkdir(parents=True) - (step_dir / "step.yml").write_text( - "step:\n type_key: my-step\n", encoding="utf-8" - ) - (step_dir / "__init__.py").write_text("", encoding="utf-8") - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "remove", "my-step"]) - - assert result.exit_code == 0, result.output - assert not step_dir.exists() - registry2 = StepRegistry(project_dir) - assert not registry2.is_installed("my-step") - - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") - def test_remove_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch): - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(project_dir) - outside = project_dir.parent / "outside-steps" - outside.mkdir(parents=True, exist_ok=True) - steps_link = project_dir / ".specify" / "workflows" / "steps" - steps_link.symlink_to(outside, target_is_directory=True) - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "remove", "my-step"]) - - assert result.exit_code != 0 - assert "Refusing to use symlinked step directory" in result.output - - -class TestWorkflowStepAddCLI: - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") - def test_add_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch): - from typer.testing import CliRunner - from specify_cli import app - from specify_cli.workflows.catalog import StepCatalog - - monkeypatch.chdir(project_dir) - outside = project_dir.parent / "outside-steps" - outside.mkdir(parents=True, exist_ok=True) - steps_link = project_dir / ".specify" / "workflows" / "steps" - steps_link.symlink_to(outside, target_is_directory=True) - - def _fake_get_step_info(self, step_id): - return { - "id": step_id, - "name": "Test Step", - "url": "https://example.com/step.yml", - "init_url": "https://example.com/__init__.py", - "_install_allowed": True, - } - - monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) - - assert result.exit_code != 0 - assert "Refusing to use symlinked step directory" in result.output - - def test_add_rejects_non_string_extra_files_key(self, project_dir, monkeypatch): - from typer.testing import CliRunner - from specify_cli import app - from specify_cli.workflows.catalog import StepCatalog - from specify_cli.authentication import http as auth_http - - monkeypatch.chdir(project_dir) - - def _fake_get_step_info(self, step_id): - return { - "id": step_id, - "name": "Test Step", - "url": "https://example.com/step.yml", - "init_url": "https://example.com/__init__.py", - "_install_allowed": True, - "extra_files": { - 123: "https://example.com/helper.py", - }, - } - - class _FakeResponse: - def __init__(self, url: str): - self.url = url - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self): - if self.url.endswith("/step.yml"): - return b"step:\n type_key: my-step\n" - return b"" - - def geturl(self): - return self.url - - def _fake_open_url(url, timeout=30): - return _FakeResponse(url) - - monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) - monkeypatch.setattr(auth_http, "open_url", _fake_open_url) - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) - - assert result.exit_code != 0 - assert "non-string path key" in result.output - - @pytest.mark.parametrize( - "rel_path,expected", - [ - ("", "empty or non-string path key"), - (".", "not a valid relative file path"), - ("..", "not a valid relative file path"), - ("sub/../x", "not a valid relative file path"), - ], - ) - def test_add_rejects_invalid_extra_files_path( - self, project_dir, monkeypatch, rel_path, expected - ): - from typer.testing import CliRunner - from specify_cli import app - from specify_cli.workflows.catalog import StepCatalog - from specify_cli.authentication import http as auth_http - - monkeypatch.chdir(project_dir) - - def _fake_get_step_info(self, step_id): - return { - "id": step_id, - "name": "Test Step", - "url": "https://example.com/step.yml", - "init_url": "https://example.com/__init__.py", - "_install_allowed": True, - "extra_files": {rel_path: "https://example.com/helper.py"}, - } - - class _FakeResponse: - def __init__(self, url: str): - self.url = url - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self): - if self.url.endswith("/step.yml"): - return b"step:\n type_key: my-step\n" - return b"" - - def geturl(self): - return self.url - - def _fake_open_url(url, timeout=30): - return _FakeResponse(url) - - monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) - monkeypatch.setattr(auth_http, "open_url", _fake_open_url) - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) - - assert result.exit_code != 0 - assert expected in result.output - - def test_add_rejects_non_string_extra_files_url(self, project_dir, monkeypatch): - from typer.testing import CliRunner - from specify_cli import app - from specify_cli.workflows.catalog import StepCatalog - from specify_cli.authentication import http as auth_http - - monkeypatch.chdir(project_dir) - - def _fake_get_step_info(self, step_id): - return { - "id": step_id, - "name": "Test Step", - "url": "https://example.com/step.yml", - "init_url": "https://example.com/__init__.py", - "_install_allowed": True, - "extra_files": {"helper.py": None}, - } - - class _FakeResponse: - def __init__(self, url: str): - self.url = url - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self): - if self.url.endswith("/step.yml"): - return b"step:\n type_key: my-step\n" - return b"" - - def geturl(self): - return self.url - - def _fake_open_url(url, timeout=30): - return _FakeResponse(url) - - monkeypatch.setattr(StepCatalog, "get_step_info", _fake_get_step_info) - monkeypatch.setattr(auth_http, "open_url", _fake_open_url) - - runner = CliRunner() - result = runner.invoke(app, ["workflow", "step", "add", "my-step"]) - - assert result.exit_code != 0 - assert "empty or non-string URL" in result.output - - -class TestWorkflowJsonOutput: - """Test the --json machine-readable output for run/resume/status.""" - - _WF = """ -schema_version: "1.0" -workflow: - id: "json-wf" - name: "JSON WF" - version: "1.0.0" -steps: - - id: ask - type: gate - message: "Review" - options: [approve, reject] - - id: after - type: shell - run: "echo done" -""" - - _WF_DONE = """ -schema_version: "1.0" -workflow: - id: "json-done" - name: "JSON Done" - version: "1.0.0" -steps: - - id: only - type: shell - run: "echo done" -""" - - def _write_wf(self, project_dir, text, name): - path = project_dir / f"{name}.yml" - path.write_text(text, encoding="utf-8") - return path - - def _invoke(self, project_dir, args): - from typer.testing import CliRunner - from unittest.mock import patch - from specify_cli import app - - runner = CliRunner() - with patch.object(Path, "cwd", return_value=project_dir): - return runner.invoke(app, args, catch_exceptions=False) - - def test_run_json_completed(self, project_dir): - wf = self._write_wf(project_dir, self._WF_DONE, "done") - result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]) - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["workflow_id"] == "json-done" - assert payload["status"] == "completed" - assert "run_id" in payload - - def test_run_json_paused(self, project_dir): - wf = self._write_wf(project_dir, self._WF, "gated") - result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]) - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["status"] == "paused" - assert payload["current_step_id"] == "ask" - assert payload["current_step_index"] == 0 - - def test_run_json_output_has_no_markup_or_ansi(self, project_dir): - wf = self._write_wf(project_dir, self._WF_DONE, "clean") - out = self._invoke( - project_dir, ["workflow", "run", str(wf), "--json"] - ).stdout - # Machine output must be exactly the JSON object: no Rich markup - # tags and no ANSI escape sequences leaking in. - assert "\x1b[" not in out - assert "[/" not in out - assert out.strip() == json.dumps(json.loads(out), indent=2) - - def test_run_default_output_is_human_not_json(self, project_dir): - wf = self._write_wf(project_dir, self._WF_DONE, "done2") - result = self._invoke(project_dir, ["workflow", "run", str(wf)]) - assert result.exit_code == 0 - assert "Running workflow" in result.stdout - with pytest.raises(json.JSONDecodeError): - json.loads(result.stdout) - - def test_status_json_single_and_list(self, project_dir): - wf = self._write_wf(project_dir, self._WF, "gated2") - run = json.loads( - self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout - ) - rid = run["run_id"] - - single = json.loads( - self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout - ) - assert single["run_id"] == rid - assert single["status"] == "paused" - assert single["steps"]["ask"] == "paused" - # status --json carries the same step-position fields as run/resume - # so automation never has to branch on which command produced it. - assert single["current_step_id"] == run["current_step_id"] - assert single["current_step_index"] == run["current_step_index"] - - listing = json.loads( - self._invoke(project_dir, ["workflow", "status", "--json"]).stdout - ) - assert any(r["run_id"] == rid for r in listing["runs"]) + assert any(r["run_id"] == rid for r in listing["runs"]) def test_resume_json(self, project_dir): wf = self._write_wf(project_dir, self._WF, "gated3") @@ -5249,95 +4293,3 @@ def fake_open_url(url, timeout=None, extra_headers=None): asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] assert len(asset_calls) >= 1 assert asset_calls[0][1] == {"Accept": "application/octet-stream"} - - -class TestWorkflowRunExitCodes: - """CLI-level tests for the run/resume process exit codes.""" - - _WF_OK = """ -schema_version: "1.0" -workflow: - id: "exit-ok" - name: "Exit OK" - version: "1.0.0" -steps: - - id: fine - type: shell - run: "exit 0" -""" - - _WF_FAIL = """ -schema_version: "1.0" -workflow: - id: "exit-fail" - name: "Exit Fail" - version: "1.0.0" -steps: - - id: boom - type: shell - run: "exit 1" -""" - - def _write(self, tmp_path, content): - path = tmp_path / "wf.yml" - path.write_text(content, encoding="utf-8") - return path - - def test_run_completed_exits_zero(self, tmp_path, monkeypatch): - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_OK))]) - assert result.exit_code == 0 - assert "Status: completed" in result.stdout - - def test_run_failed_exits_nonzero(self, tmp_path, monkeypatch): - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_FAIL))]) - assert "Status: failed" in result.stdout - assert result.exit_code == 1 - - def test_run_failed_exits_nonzero_with_json(self, tmp_path, monkeypatch): - import json as _json - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(tmp_path) - runner = CliRunner() - result = runner.invoke( - app, - ["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"], - ) - assert result.exit_code == 1, result.stdout - payload = _json.loads(result.stdout) - assert payload["status"] == "failed" - - def test_resume_failed_run_exits_nonzero(self, tmp_path, monkeypatch): - # End-to-end coverage for the `workflow resume` exit-code mapping: - # resuming a run whose outcome is still `failed` must exit non-zero, - # mirroring `workflow run`. Resume re-executes the failed step, which - # fails again, so the resumed outcome stays `failed`. - import json as _json - from typer.testing import CliRunner - from specify_cli import app - - monkeypatch.chdir(tmp_path) - (tmp_path / ".specify").mkdir() # `workflow resume` requires a project - runner = CliRunner() - run = runner.invoke( - app, - ["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"], - ) - assert run.exit_code == 1, run.stdout - run_id = _json.loads(run.stdout)["run_id"] - - resumed = runner.invoke(app, ["workflow", "resume", run_id, "--json"]) - assert resumed.exit_code == 1, resumed.stdout - payload = _json.loads(resumed.stdout) - assert payload["status"] == "failed"