@@ -879,7 +879,7 @@ def catalog_add(
879879 })
880880
881881 config ["catalogs" ] = catalogs
882- config_path .write_text (yaml .dump (config , default_flow_style = False , sort_keys = False , allow_unicode = True ), encoding = "utf-8" )
882+ config_path .write_text (yaml .safe_dump (config , default_flow_style = False , sort_keys = False , allow_unicode = True ), encoding = "utf-8" )
883883
884884 install_label = "install allowed" if install_allowed else "discovery only"
885885 console .print (f"\n [green]✓[/green] Added catalog '[bold]{ name } [/bold]' ({ install_label } )" )
@@ -919,7 +919,7 @@ def catalog_remove(
919919 raise typer .Exit (1 )
920920
921921 config ["catalogs" ] = catalogs
922- config_path .write_text (yaml .dump (config , default_flow_style = False , sort_keys = False , allow_unicode = True ), encoding = "utf-8" )
922+ config_path .write_text (yaml .safe_dump (config , default_flow_style = False , sort_keys = False , allow_unicode = True ), encoding = "utf-8" )
923923
924924 console .print (f"[green]✓[/green] Removed catalog '{ name } '" )
925925 if not catalogs :
@@ -987,8 +987,8 @@ def extension_add(
987987 raise typer .Exit (0 )
988988
989989 try :
990- with console . status ( f"[cyan]Installing extension: { extension } [/cyan]" ) :
991- if dev :
990+ if dev :
991+ with console . status ( f"[cyan]Installing extension: { extension } [/cyan]" ) :
992992 # Install from local directory
993993 source_path = Path (extension ).expanduser ().resolve ()
994994 if not source_path .exists ():
@@ -1010,12 +1010,13 @@ def extension_add(
10101010 force = force
10111011 )
10121012
1013- elif from_url :
1014- # Install from URL (ZIP file)
1015- import urllib .error
1013+ elif from_url :
1014+ # Install from URL (ZIP file)
1015+ import urllib .error
10161016
1017- console .print (f"Downloading from { safe_url } ..." )
1017+ console .print (f"Downloading from { safe_url } ..." )
10181018
1019+ with console .status (f"[cyan]Installing extension: { extension } [/cyan]" ):
10191020 # Download ZIP to temp location
10201021 download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
10211022 download_dir .mkdir (parents = True , exist_ok = True )
@@ -1038,66 +1039,86 @@ def extension_add(
10381039 if zip_path .exists ():
10391040 zip_path .unlink ()
10401041
1041- else :
1042- # Try bundled extensions first (shipped with spec-kit)
1043- bundled_path = _locate_bundled_extension (extension )
1044- if bundled_path is not None :
1042+ else :
1043+ # Try bundled extensions first (shipped with spec-kit)
1044+ bundled_path = _locate_bundled_extension (extension )
1045+ if bundled_path is not None :
1046+ with console .status (f"[cyan]Installing extension: { extension } [/cyan]" ):
10451047 manifest = manager .install_from_directory (
10461048 bundled_path , speckit_version , priority = priority , force = force
10471049 )
1048- else :
1049- # Install from catalog (also resolves display names to IDs)
1050- catalog = ExtensionCatalog (project_root )
1050+ else :
1051+ # Install from catalog (also resolves display names to IDs)
1052+ catalog = ExtensionCatalog (project_root )
10511053
1052- # Check if extension exists in catalog (supports both ID and display name)
1053- ext_info , catalog_error = _resolve_catalog_extension (extension , catalog , "add" )
1054- if catalog_error :
1055- console .print (f"[red]Error:[/red] Could not query extension catalog: { catalog_error } " )
1056- raise typer .Exit (1 )
1057- if not ext_info :
1058- console .print (f"[red]Error:[/red] Extension '{ extension } ' not found in catalog" )
1059- console .print ("\n Search available extensions:" )
1060- console .print (" specify extension search" )
1061- raise typer .Exit (1 )
1054+ # Check if extension exists in catalog (supports both ID and display name)
1055+ ext_info , catalog_error = _resolve_catalog_extension (extension , catalog , "add" )
1056+ if catalog_error :
1057+ console .print (f"[red]Error:[/red] Could not query extension catalog: { catalog_error } " )
1058+ raise typer .Exit (1 )
1059+ if not ext_info :
1060+ console .print (f"[red]Error:[/red] Extension '{ extension } ' not found in catalog" )
1061+ console .print ("\n Search available extensions:" )
1062+ console .print (" specify extension search" )
1063+ raise typer .Exit (1 )
10621064
1063- # If catalog resolved a display name to an ID, check bundled again
1064- resolved_id = ext_info ['id' ]
1065- if resolved_id != extension :
1066- bundled_path = _locate_bundled_extension (resolved_id )
1067- if bundled_path is not None :
1065+ # If catalog resolved a display name to an ID, check bundled again
1066+ resolved_id = ext_info ['id' ]
1067+ if resolved_id != extension :
1068+ bundled_path = _locate_bundled_extension (resolved_id )
1069+ if bundled_path is not None :
1070+ with console .status (f"[cyan]Installing extension: { extension } [/cyan]" ):
10681071 manifest = manager .install_from_directory (
10691072 bundled_path , speckit_version , priority = priority , force = force
10701073 )
10711074
1072- if bundled_path is None :
1073- # Bundled extensions without a download URL must come from the local package
1074- if ext_info .get ("bundled" ) and not ext_info .get ("download_url" ):
1075- console .print (
1076- f"[red]Error:[/red] Extension '{ ext_info ['id' ]} ' is bundled with spec-kit "
1077- f"but could not be found in the installed package."
1078- )
1079- console .print (
1080- "\n This usually means the spec-kit installation is incomplete or corrupted."
1081- )
1082- console .print ("Try reinstalling spec-kit:" )
1083- console .print (f" { REINSTALL_COMMAND } " )
1084- raise typer .Exit (1 )
1085-
1086- # Enforce install_allowed policy
1087- if not ext_info .get ("_install_allowed" , True ):
1088- catalog_name = ext_info .get ("_catalog_name" , "community" )
1089- console .print (
1090- f"[red]Error:[/red] '{ extension } ' is available in the "
1091- f"'{ catalog_name } ' catalog but installation is not allowed from that catalog."
1092- )
1093- console .print (
1094- f"\n To enable installation, add '{ extension } ' to an approved catalog "
1095- f"(install_allowed: true) in .specify/extension-catalogs.yml."
1075+ if bundled_path is None :
1076+ # Bundled extensions without a download URL must come from the local package
1077+ if ext_info .get ("bundled" ) and not ext_info .get ("download_url" ):
1078+ console .print (
1079+ f"[red]Error:[/red] Extension '{ ext_info ['id' ]} ' is bundled with spec-kit "
1080+ f"but could not be found in the installed package."
1081+ )
1082+ console .print (
1083+ "\n This usually means the spec-kit installation is incomplete or corrupted."
1084+ )
1085+ console .print ("Try reinstalling spec-kit:" )
1086+ console .print (f" { REINSTALL_COMMAND } " )
1087+ raise typer .Exit (1 )
1088+
1089+ # Enforce install_allowed policy
1090+ if not ext_info .get ("_install_allowed" , True ):
1091+ catalog_name = ext_info .get ("_catalog_name" , "community" )
1092+ console .print ()
1093+ console .print (
1094+ Panel (
1095+ f"[bold]'{ ext_info ['name' ]} ' is available in the '{ catalog_name } ' catalog "
1096+ f"but installation is not allowed from that catalog.[/bold]\n \n "
1097+ f"Approve installation from '{ catalog_name } ' for this project?\n "
1098+ "This will update .specify/extension-catalogs.yml so future installs "
1099+ "from that catalog are allowed." ,
1100+ title = "[bold yellow]Catalog Approval Required[/bold yellow]" ,
1101+ border_style = "yellow" ,
1102+ padding = (1 , 2 ),
10961103 )
1097- raise typer .Exit (1 )
1104+ )
1105+ console .print ()
1106+ try :
1107+ confirm = typer .confirm ("Approve catalog and continue?" , default = False )
1108+ except (typer .Abort , KeyboardInterrupt ):
1109+ console .print ("Cancelled" )
1110+ raise typer .Exit (0 )
1111+ if not confirm :
1112+ console .print ("Cancelled" )
1113+ raise typer .Exit (0 )
1114+ approved_catalog = catalog .approve_catalog_install (catalog_name )
1115+ console .print (
1116+ f"[green]✓[/green] Approved catalog '[bold]{ approved_catalog .name } [/bold]' for installation"
1117+ )
10981118
1099- # Download extension ZIP (use resolved ID, not original argument which may be display name)
1100- extension_id = ext_info ['id' ]
1119+ # Download extension ZIP (use resolved ID, not original argument which may be display name)
1120+ extension_id = ext_info ['id' ]
1121+ with console .status (f"[cyan]Installing extension: { ext_info ['name' ]} [/cyan]" ):
11011122 console .print (f"Downloading { ext_info ['name' ]} v{ ext_info .get ('version' , 'unknown' )} ..." )
11021123 zip_path = catalog .download_extension (extension_id )
11031124
@@ -1286,8 +1307,9 @@ def extension_search(
12861307 else :
12871308 console .print (f"\n [yellow]⚠[/yellow] Not directly installable from '{ catalog_name } '." )
12881309 console .print (
1289- f" Add to an approved catalog with install_allowed: true, "
1290- f"or install from a ZIP URL: specify extension add { ext ['id' ]} --from <zip-url>"
1310+ f" Run [cyan]specify extension add { ext ['id' ]} [/cyan] to approve "
1311+ f"the catalog and install, or use a ZIP URL: "
1312+ f"specify extension add { ext ['id' ]} --from <zip-url>"
12911313 )
12921314 console .print ()
12931315
@@ -1486,8 +1508,13 @@ def _print_extension_info(ext_info: dict, manager):
14861508 console .print ("[yellow]Not installed[/yellow]" )
14871509 console .print (
14881510 f"\n [yellow]⚠[/yellow] '{ ext_info ['id' ]} ' is available in the '{ catalog_name } ' catalog "
1489- f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
1490- f"with install_allowed: true to enable installation."
1511+ f"but installation is not currently allowed from that catalog."
1512+ )
1513+ console .print (
1514+ f"\n [cyan]Install:[/cyan] specify extension add { ext_info ['id' ]} "
1515+ )
1516+ console .print (
1517+ "[dim]You will be prompted to approve the catalog before installation proceeds.[/dim]"
14911518 )
14921519
14931520
0 commit comments