99from __future__ import annotations
1010
1111import os
12+ import re
1213import shutil
1314import zipfile
1415from pathlib import Path
@@ -181,19 +182,24 @@ def extension_list(
181182 all_extensions : bool = typer .Option (False , "--all" , help = "Show both installed and available" ),
182183):
183184 """List installed extensions."""
184- from . import ExtensionManager
185+ from . import ExtensionManager , ExtensionCatalog , ExtensionError
185186
186187 project_root = _require_specify_project ()
187188 manager = ExtensionManager (project_root )
188189 installed = manager .list_installed ()
189190
190- if not installed and not (available or all_extensions ):
191+ # Default (no flags) lists installed; --all also lists installed.
192+ # --available alone lists only catalog extensions, not installed.
193+ show_installed = all_extensions or not available
194+ show_available = available or all_extensions
195+
196+ if not installed and not show_available :
191197 console .print ("[yellow]No extensions installed.[/yellow]" )
192198 console .print ("\n Install an extension with:" )
193199 console .print (" specify extension add <extension-name>" )
194200 return
195201
196- if installed :
202+ if show_installed and installed :
197203 console .print ("\n [bold cyan]Installed Extensions:[/bold cyan]\n " )
198204
199205 for ext in installed :
@@ -206,9 +212,36 @@ def extension_list(
206212 console .print (f" Commands: { ext ['command_count' ]} | Hooks: { ext ['hook_count' ]} | Priority: { ext ['priority' ]} | Status: { 'Enabled' if ext ['enabled' ] else 'Disabled' } " )
207213 console .print ()
208214
209- if available or all_extensions :
210- console .print ("\n Install an extension:" )
211- console .print (" [cyan]specify extension add <name>[/cyan]" )
215+ if show_available :
216+ # Query the catalog and show extensions that are not already installed.
217+ catalog = ExtensionCatalog (project_root )
218+ installed_ids = {ext ["id" ] for ext in installed }
219+
220+ try :
221+ results = catalog .search ()
222+ except ExtensionError as e :
223+ console .print (f"\n [red]Error:[/red] Could not query extension catalog: { e } " )
224+ console .print ("[dim]The catalog may be temporarily unavailable. Try again later.[/dim]" )
225+ raise typer .Exit (1 )
226+
227+ available_exts = [ext for ext in results if ext .get ("id" ) not in installed_ids ]
228+
229+ console .print ("\n [bold cyan]Available Extensions:[/bold cyan]\n " )
230+ if not available_exts :
231+ console .print (" [dim]No additional extensions available in the catalog.[/dim]" )
232+ else :
233+ for ext in available_exts :
234+ verified_badge = " [green]✓ Verified[/green]" if ext .get ("verified" ) else ""
235+ console .print (f" [bold]{ ext ['name' ]} [/bold] (v{ ext ['version' ]} ){ verified_badge } " )
236+ console .print (f" [dim]{ ext ['id' ]} [/dim]" )
237+ console .print (f" { ext .get ('description' , '' )} " )
238+ install_allowed = ext .get ("_install_allowed" , True )
239+ if install_allowed :
240+ console .print (f" [cyan]Install:[/cyan] specify extension add { ext ['id' ]} " )
241+ else :
242+ catalog_name = ext .get ("_catalog_name" , "" )
243+ console .print (f" [yellow]Discovery only — not installable from '{ catalog_name } '[/yellow]" )
244+ console .print ()
212245
213246
214247@catalog_app .command ("list" )
@@ -463,7 +496,13 @@ def extension_add(
463496 # Download ZIP to temp location
464497 download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
465498 download_dir .mkdir (parents = True , exist_ok = True )
466- zip_path = download_dir / f"{ extension } -url-download.zip"
499+ # Sanitize the extension label before using it in a filename:
500+ # the raw argument may contain path separators (e.g. "../x") that
501+ # would let the download escape download_dir and overwrite
502+ # arbitrary files (path traversal). Keep only safe characters and
503+ # fall back to a fixed stem if nothing usable remains.
504+ safe_label = re .sub (r"[^A-Za-z0-9._-]" , "_" , extension ).strip ("._" ) or "extension"
505+ zip_path = download_dir / f"{ safe_label } -url-download.zip"
467506
468507 try :
469508 from specify_cli .authentication .http import open_url as _open_url
0 commit comments