Add --extension flag to specify init for opting into extensions at init time#2396
Add --extension flag to specify init for opting into extensions at init time#2396
--extension flag to specify init for opting into extensions at init time#2396Conversation
…t time Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ddeae546-8287-421f-bc5d-1636515bf99a Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ddeae546-8287-421f-bc5d-1636515bf99a Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
--extension flag to specify init for opting into extensions at init time
There was a problem hiding this comment.
Pull request overview
Adds a repeatable --extension flag to specify init so users can opt into installing extensions (bundled IDs, local paths, or URLs) as part of project initialization, instead of requiring a separate specify extension add step.
Changes:
- Introduces
_install_extension_during_init()to install an extension from a name/ID, local directory path, or URL. - Extends
specify initwith a repeatable--extensionoption and tracker steps to install requested extensions non-fatally. - Adds CLI-level tests covering bundled, multiple, local-path, unknown extension handling, and interaction with
--preset.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds the init-time extension install helper and wires --extension into specify init with tracker integration. |
tests/integrations/test_cli.py |
Adds TestExtensionFlag to validate init-time extension installation and failure behavior. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:974
- In
_install_extension_during_init, the import list includesExtensionError,ValidationError, andCompatibilityErrorbut none of them are used in this helper. Either remove these imports or use them to normalize errors (e.g., catch and re-raise asValueError) to match the function’s contract and keep the helper clean.
from urllib.parse import urlparse
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
- Files reviewed: 2/2 changed files
- Comments generated: 4
| """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" |
There was a problem hiding this comment.
The docstring says this helper “Raises ValueError on failure”, but several calls inside it can raise ExtensionError / ValidationError / CompatibilityError (e.g., install_from_directory / install_from_zip) and those currently propagate. Either adjust the docstring/typing to reflect the real exceptions or catch the extension-specific exceptions and re-raise as ValueError so callers get consistent behavior.
This issue also appears on line 972 of the same file.
| 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: |
There was a problem hiding this comment.
For --extension https://… installs, this uses urllib.request.urlopen() directly. Elsewhere (e.g., ExtensionCatalog._open_url) the codebase uses specify_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.
| 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: | |
| import urllib.error as _urllib_error | |
| import re as _re | |
| from ._github_http import open_github_url | |
| download_dir = project_path / ".specify" / "extensions" / ".cache" / "downloads" | |
| download_dir.mkdir(parents=True, exist_ok=True) | |
| 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 open_github_url(ext_spec, timeout=60) as _resp: |
| # --- 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" |
There was a problem hiding this comment.
This helper skips already-installed extensions only for the bundled-name path. For local-path and URL installs, ExtensionManager.install_from_directory/zip will raise ExtensionError if the extension is already installed, which will mark the init step as failed even though it’s a benign state (and can happen if the same extension is passed twice, or if init already installed it). Consider detecting “already installed” for these branches too (e.g., read the manifest id first or catch ExtensionError and treat it as a skip/already-installed status).
| # Install extensions specified via --extension | ||
| if extensions: | ||
| speckit_ver = get_speckit_version() | ||
| for i, ext_spec in enumerate(extensions): | ||
| tracker.start(f"extension-{i}") |
There was a problem hiding this comment.
ensure_executable_scripts() runs before this new --extension install loop, but it explicitly scans .specify/extensions for .sh scripts. Installing extensions after that means extension-provided scripts won’t get execute bits set during init (and the chmod tracker step becomes misleading). Move the chmod step to after extension installs, or rerun ensure_executable_scripts() after the loop.
No way to install extensions during
specify init— the git extension is hardcoded to auto-install, and everything else requires a separatespecify extension addpost-init. This adds a repeatable--extensionflag so users can opt into extensions at init time (prerequisite for git no longer being enabled by default in 1.0.0).Changes
_install_extension_during_inithelperNew function that auto-detects source type and delegates accordingly:
git,selftest) — checks bundled package first, falls back to catalog; skips if already installed./,../,/,~/,.\,..\prefixes or anyPath.is_absolute()match (Windows-safe)ValueErroron failure; caller converts to tracker error without aborting initspecify initupdates--extensionas a repeatablelist[str] | Nonetyper optionextension-{i}before the Live context; installed after workflow, before final stepUsage
Tests
Five new tests in
TestExtensionFlag: bundled name, multiple extensions, local absolute path, unknown extension (graceful error, no abort), and combined with--preset.