-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Add --extension flag to specify init for opting into extensions at init time
#2396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -961,6 +961,99 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: | |
| } | ||
|
|
||
|
|
||
| def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str: | ||
| """Install a single extension during ``specify init``. | ||
|
|
||
| Handles bundled extension names, local directory paths, and HTTPS URLs. | ||
| Returns a short status message on success. | ||
| Raises ``ValueError`` on failure so the caller can convert it to a | ||
| tracker error without aborting the entire init. | ||
| """ | ||
| from urllib.parse import urlparse | ||
| from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError | ||
|
|
||
| manager = ExtensionManager(project_path) | ||
|
|
||
| # --- URL --- | ||
| parsed = urlparse(ext_spec) | ||
| if parsed.scheme in ("http", "https"): | ||
| 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("URL must use HTTPS (HTTP is only allowed for localhost)") | ||
|
|
||
| import urllib.request | ||
| import urllib.error as _urllib_error | ||
| download_dir = project_path / ".specify" / "extensions" / ".cache" / "downloads" | ||
| download_dir.mkdir(parents=True, exist_ok=True) | ||
| import re as _re | ||
| safe_name = _re.sub(r"[^a-z0-9-]", "-", (parsed.path.split("/")[-1] or "download").lower())[:64] | ||
| zip_path = download_dir / f"{safe_name}-init-download.zip" | ||
| try: | ||
| with urllib.request.urlopen(ext_spec, timeout=60) as _resp: | ||
| zip_path.write_bytes(_resp.read()) | ||
| manifest = manager.install_from_zip(zip_path, speckit_version) | ||
| except _urllib_error.URLError as exc: | ||
| raise ValueError(f"Failed to download from {ext_spec}: {exc}") from exc | ||
| finally: | ||
| zip_path.unlink(missing_ok=True) | ||
| return f"{manifest.name} v{manifest.version} installed" | ||
|
|
||
| # --- Local path --- | ||
| if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute(): | ||
| source_path = Path(ext_spec).expanduser().resolve() | ||
| if not source_path.exists(): | ||
| raise ValueError(f"Directory not found: {source_path}") | ||
| if not (source_path / "extension.yml").exists(): | ||
| raise ValueError(f"No extension.yml found in {source_path}") | ||
| manifest = manager.install_from_directory(source_path, speckit_version) | ||
| return f"{manifest.name} v{manifest.version} installed" | ||
|
Comment on lines
+965
to
+1009
|
||
|
|
||
| # --- Bundled extension name or catalog ID --- | ||
| bundled_path = _locate_bundled_extension(ext_spec) | ||
| if bundled_path is not None: | ||
| if manager.registry.is_installed(ext_spec): | ||
| return "already installed" | ||
| manifest = manager.install_from_directory(bundled_path, speckit_version) | ||
| return f"{manifest.name} v{manifest.version} installed" | ||
|
|
||
| # Fall back to catalog | ||
| catalog = ExtensionCatalog(project_path) | ||
| ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add") | ||
| if catalog_error: | ||
| raise ValueError(f"Could not query extension catalog: {catalog_error}") | ||
| if not ext_info: | ||
| raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog") | ||
|
|
||
| resolved_id = ext_info["id"] | ||
| if resolved_id != ext_spec: | ||
| bundled_path = _locate_bundled_extension(resolved_id) | ||
| if bundled_path is not None: | ||
| if manager.registry.is_installed(resolved_id): | ||
| return "already installed" | ||
| manifest = manager.install_from_directory(bundled_path, speckit_version) | ||
| return f"{manifest.name} v{manifest.version} installed" | ||
|
|
||
| if ext_info.get("bundled") and not ext_info.get("download_url"): | ||
| from .extensions import REINSTALL_COMMAND | ||
| raise ValueError( | ||
| f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. " | ||
| f"Try reinstalling spec-kit: {REINSTALL_COMMAND}" | ||
| ) | ||
|
|
||
| if not ext_info.get("_install_allowed", True): | ||
| catalog_name = ext_info.get("_catalog_name", "community") | ||
| raise ValueError( | ||
| f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog" | ||
| ) | ||
|
|
||
| zip_path = catalog.download_extension(resolved_id) | ||
| try: | ||
| manifest = manager.install_from_zip(zip_path, speckit_version) | ||
| finally: | ||
| zip_path.unlink(missing_ok=True) | ||
| return f"{manifest.name} v{manifest.version} installed" | ||
|
|
||
|
|
||
| @app.command() | ||
| def init( | ||
| 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( | |
| branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), | ||
| integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), | ||
| integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), | ||
| extensions: list[str] | None = typer.Option(None, "--extension", help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable."), | ||
| ): | ||
| """ | ||
| Initialize a new Specify project. | ||
|
|
@@ -1019,6 +1113,10 @@ def init( | |
| specify init --here --integration gemini | ||
| specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir | ||
| specify init my-project --integration claude --preset healthcare-compliance # With preset | ||
| specify init my-project --integration copilot --extension git # With bundled extension | ||
| specify init my-project --extension git --extension selftest # Multiple extensions | ||
| specify init my-project --extension ./my-extensions/custom-ext # Local path extension | ||
| specify init my-project --extension https://example.com/extensions/my-ext.zip # URL extension | ||
| """ | ||
|
|
||
| show_banner() | ||
|
|
@@ -1262,10 +1360,15 @@ def init( | |
| ("constitution", "Constitution setup"), | ||
| ("git", "Install git extension"), | ||
| ("workflow", "Install bundled workflow"), | ||
| ("final", "Finalize"), | ||
| ]: | ||
| tracker.add(key, label) | ||
|
|
||
| if extensions: | ||
| for i, ext_spec in enumerate(extensions): | ||
| tracker.add(f"extension-{i}", f"Install extension: {ext_spec}") | ||
|
|
||
| tracker.add("final", "Finalize") | ||
|
|
||
| with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: | ||
| tracker.attach_refresh(lambda: live.update(tracker.render())) | ||
| try: | ||
|
|
@@ -1470,6 +1573,18 @@ def init( | |
| except Exception as preset_err: | ||
| console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") | ||
|
|
||
| # Install extensions specified via --extension | ||
| if extensions: | ||
| speckit_ver = get_speckit_version() | ||
| for i, ext_spec in enumerate(extensions): | ||
| tracker.start(f"extension-{i}") | ||
|
Comment on lines
+1576
to
+1580
|
||
| try: | ||
| status_msg = _install_extension_during_init(project_path, ext_spec, speckit_ver) | ||
| tracker.complete(f"extension-{i}", status_msg) | ||
| except Exception as ext_err: | ||
| sanitized_ext = str(ext_err).replace('\n', ' ').strip() | ||
| tracker.error(f"extension-{i}", f"failed: {sanitized_ext[:120]}") | ||
|
|
||
| tracker.complete("final", "project ready") | ||
| except (typer.Exit, SystemExit): | ||
| raise | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For
--extension https://…installs, this usesurllib.request.urlopen()directly. Elsewhere (e.g.,ExtensionCatalog._open_url) the codebase usesspecify_cli._github_http.open_github_url()to attach GitHub auth when appropriate and strip it on redirects (preventing token leakage) while still supporting private GitHub URLs. Consider reusing that helper here for consistent behavior and safer redirect handling.