@@ -961,6 +961,99 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
961961}
962962
963963
964+ def _install_extension_during_init (project_path : Path , ext_spec : str , speckit_version : str ) -> str :
965+ """Install a single extension during ``specify init``.
966+
967+ Handles bundled extension names, local directory paths, and HTTPS URLs.
968+ Returns a short status message on success.
969+ Raises ``ValueError`` on failure so the caller can convert it to a
970+ tracker error without aborting the entire init.
971+ """
972+ from urllib .parse import urlparse
973+ from .extensions import ExtensionManager , ExtensionCatalog , ExtensionError , ValidationError , CompatibilityError
974+
975+ manager = ExtensionManager (project_path )
976+
977+ # --- URL ---
978+ parsed = urlparse (ext_spec )
979+ if parsed .scheme in ("http" , "https" ):
980+ is_localhost = parsed .hostname in ("localhost" , "127.0.0.1" , "::1" )
981+ if parsed .scheme != "https" and not (parsed .scheme == "http" and is_localhost ):
982+ raise ValueError ("URL must use HTTPS (HTTP is only allowed for localhost)" )
983+
984+ import urllib .request
985+ import urllib .error as _urllib_error
986+ download_dir = project_path / ".specify" / "extensions" / ".cache" / "downloads"
987+ download_dir .mkdir (parents = True , exist_ok = True )
988+ import re as _re
989+ safe_name = _re .sub (r"[^a-z0-9-]" , "-" , (parsed .path .split ("/" )[- 1 ] or "download" ).lower ())[:64 ]
990+ zip_path = download_dir / f"{ safe_name } -init-download.zip"
991+ try :
992+ with urllib .request .urlopen (ext_spec , timeout = 60 ) as _resp :
993+ zip_path .write_bytes (_resp .read ())
994+ manifest = manager .install_from_zip (zip_path , speckit_version )
995+ except _urllib_error .URLError as exc :
996+ raise ValueError (f"Failed to download from { ext_spec } : { exc } " ) from exc
997+ finally :
998+ zip_path .unlink (missing_ok = True )
999+ return f"{ manifest .name } v{ manifest .version } installed"
1000+
1001+ # --- Local path ---
1002+ if ext_spec .startswith (("./" , "../" , "/" , "~/" , ".\\ " , "..\\ " )):
1003+ source_path = Path (ext_spec ).expanduser ().resolve ()
1004+ if not source_path .exists ():
1005+ raise ValueError (f"Directory not found: { source_path } " )
1006+ if not (source_path / "extension.yml" ).exists ():
1007+ raise ValueError (f"No extension.yml found in { source_path } " )
1008+ manifest = manager .install_from_directory (source_path , speckit_version )
1009+ return f"{ manifest .name } v{ manifest .version } installed"
1010+
1011+ # --- Bundled extension name or catalog ID ---
1012+ bundled_path = _locate_bundled_extension (ext_spec )
1013+ if bundled_path is not None :
1014+ if manager .registry .is_installed (ext_spec ):
1015+ return "already installed"
1016+ manifest = manager .install_from_directory (bundled_path , speckit_version )
1017+ return f"{ manifest .name } v{ manifest .version } installed"
1018+
1019+ # Fall back to catalog
1020+ catalog = ExtensionCatalog (project_path )
1021+ ext_info , catalog_error = _resolve_catalog_extension (ext_spec , catalog , "add" )
1022+ if catalog_error :
1023+ raise ValueError (f"Could not query extension catalog: { catalog_error } " )
1024+ if not ext_info :
1025+ raise ValueError (f"Extension '{ ext_spec } ' not found in bundled extensions or catalog" )
1026+
1027+ resolved_id = ext_info ["id" ]
1028+ if resolved_id != ext_spec :
1029+ bundled_path = _locate_bundled_extension (resolved_id )
1030+ if bundled_path is not None :
1031+ if manager .registry .is_installed (resolved_id ):
1032+ return "already installed"
1033+ manifest = manager .install_from_directory (bundled_path , speckit_version )
1034+ return f"{ manifest .name } v{ manifest .version } installed"
1035+
1036+ if ext_info .get ("bundled" ) and not ext_info .get ("download_url" ):
1037+ from .extensions import REINSTALL_COMMAND
1038+ raise ValueError (
1039+ f"Extension '{ resolved_id } ' is bundled with spec-kit but not found in the installed package. "
1040+ f"Try reinstalling spec-kit: { REINSTALL_COMMAND } "
1041+ )
1042+
1043+ if not ext_info .get ("_install_allowed" , True ):
1044+ catalog_name = ext_info .get ("_catalog_name" , "community" )
1045+ raise ValueError (
1046+ f"Extension '{ ext_spec } ' is in the '{ catalog_name } ' catalog but installation is not allowed from that catalog"
1047+ )
1048+
1049+ zip_path = catalog .download_extension (resolved_id )
1050+ try :
1051+ manifest = manager .install_from_zip (zip_path , speckit_version )
1052+ finally :
1053+ zip_path .unlink (missing_ok = True )
1054+ return f"{ manifest .name } v{ manifest .version } installed"
1055+
1056+
9641057@app .command ()
9651058def init (
9661059 project_name : str = typer .Argument (None , help = "Name for your new project directory (optional if using --here, or use '.' for current directory)" ),
@@ -980,6 +1073,7 @@ def init(
9801073 branch_numbering : str = typer .Option (None , "--branch-numbering" , help = "Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)" ),
9811074 integration : str = typer .Option (None , "--integration" , help = "Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai." ),
9821075 integration_options : str = typer .Option (None , "--integration-options" , help = 'Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")' ),
1076+ extensions : list [str ] | None = typer .Option (None , "--extension" , help = "Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable." ),
9831077):
9841078 """
9851079 Initialize a new Specify project.
@@ -1019,6 +1113,10 @@ def init(
10191113 specify init --here --integration gemini
10201114 specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
10211115 specify init my-project --integration claude --preset healthcare-compliance # With preset
1116+ specify init my-project --integration copilot --extension git # With bundled extension
1117+ specify init my-project --extension git --extension selftest # Multiple extensions
1118+ specify init my-project --extension ./my-extensions/custom-ext # Local path extension
1119+ specify init my-project --extension https://example.com/extensions/my-ext.tar.gz # URL extension
10221120 """
10231121
10241122 show_banner ()
@@ -1262,10 +1360,15 @@ def init(
12621360 ("constitution" , "Constitution setup" ),
12631361 ("git" , "Install git extension" ),
12641362 ("workflow" , "Install bundled workflow" ),
1265- ("final" , "Finalize" ),
12661363 ]:
12671364 tracker .add (key , label )
12681365
1366+ if extensions :
1367+ for i , ext_spec in enumerate (extensions ):
1368+ tracker .add (f"extension-{ i } " , f"Install extension: { ext_spec } " )
1369+
1370+ tracker .add ("final" , "Finalize" )
1371+
12691372 with Live (tracker .render (), console = console , refresh_per_second = 8 , transient = True ) as live :
12701373 tracker .attach_refresh (lambda : live .update (tracker .render ()))
12711374 try :
@@ -1470,6 +1573,18 @@ def init(
14701573 except Exception as preset_err :
14711574 console .print (f"[yellow]Warning:[/yellow] Failed to install preset: { preset_err } " )
14721575
1576+ # Install extensions specified via --extension
1577+ if extensions :
1578+ speckit_ver = get_speckit_version ()
1579+ for i , ext_spec in enumerate (extensions ):
1580+ tracker .start (f"extension-{ i } " )
1581+ try :
1582+ status_msg = _install_extension_during_init (project_path , ext_spec , speckit_ver )
1583+ tracker .complete (f"extension-{ i } " , status_msg )
1584+ except Exception as ext_err :
1585+ sanitized_ext = str (ext_err ).replace ('\n ' , ' ' ).strip ()
1586+ tracker .error (f"extension-{ i } " , f"failed: { sanitized_ext [:120 ]} " )
1587+
14731588 tracker .complete ("final" , "project ready" )
14741589 except (typer .Exit , SystemExit ):
14751590 raise
0 commit comments