diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 3b60b6d52d..2104d9809e 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -207,22 +207,30 @@ 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 . import ExtensionManager + """List installed extensions, and available catalog extensions with --available/--all.""" + from . import ExtensionManager, ExtensionCatalog, ExtensionError project_root = _require_specify_project() manager = ExtensionManager(project_root) installed = manager.list_installed() - if not installed and not (available or all_extensions): + # Default (no flags) lists installed; --all also lists installed. + # --available alone lists only catalog extensions, not installed. + show_installed = all_extensions or not available + show_available = available or all_extensions + + if not installed and not show_available: console.print("[yellow]No extensions installed.[/yellow]") console.print("\nInstall an extension with:") console.print(" specify extension add ") return - if installed: + if show_installed: console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") + if not installed: + console.print(" [dim]No extensions installed.[/dim]") + console.print() for ext in installed: status_icon = "✓" if ext["enabled"] else "✗" status_color = "green" if ext["enabled"] else "red" @@ -233,9 +241,39 @@ def extension_list( console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") console.print() - if available or all_extensions: - console.print("\nInstall an extension:") - console.print(" [cyan]specify extension add [/cyan]") + if show_available: + # Query the catalog and show extensions that are not already installed. + catalog = ExtensionCatalog(project_root) + installed_ids = {ext["id"] for ext in installed} + + try: + results = catalog.search() + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(e))}") + console.print("[dim]The catalog may be temporarily unavailable. Try again later.[/dim]") + raise typer.Exit(1) + + available_exts = [ext for ext in results if ext.get("id") not in installed_ids] + + console.print("\n[bold cyan]Available Extensions:[/bold cyan]\n") + if not available_exts: + console.print(" [dim]No additional extensions available in the catalog.[/dim]") + else: + for ext in available_exts: + # Catalog fields are untrusted (remote/community catalogs); escape + # before embedding in Rich markup to prevent markup injection. + safe_id = _escape_markup(str(ext.get("id", ""))) + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f" [bold]{_escape_markup(str(ext['name']))}[/bold] (v{_escape_markup(str(ext['version']))}){verified_badge}") + console.print(f" [dim]{safe_id}[/dim]") + console.print(f" {_escape_markup(str(ext.get('description', '')))}") + install_allowed = ext.get("_install_allowed", True) + if install_allowed: + console.print(f" [cyan]Install:[/cyan] specify extension add {safe_id}") + else: + catalog_name = _escape_markup(str(ext.get("_catalog_name", ""))) + console.print(f" [yellow]Discovery only — not installable from '{catalog_name}'[/yellow]") + console.print() @catalog_app.command("list") diff --git a/tests/test_extension_add_path_traversal.py b/tests/test_extension_add_path_traversal.py new file mode 100644 index 0000000000..e426e34b1e --- /dev/null +++ b/tests/test_extension_add_path_traversal.py @@ -0,0 +1,69 @@ +"""Security test for `specify extension add