Skip to content

Add --extension flag to specify init for opting into extensions at init time#2396

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/add-extension-flag-to-specify-init
Draft

Add --extension flag to specify init for opting into extensions at init time#2396
Copilot wants to merge 3 commits intomainfrom
copilot/add-extension-flag-to-specify-init

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 28, 2026

No way to install extensions during specify init — the git extension is hardcoded to auto-install, and everything else requires a separate specify extension add post-init. This adds a repeatable --extension flag 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_init helper

New function that auto-detects source type and delegates accordingly:

  • Bundled name (git, selftest) — checks bundled package first, falls back to catalog; skips if already installed
  • Local path — triggers on ./, ../, /, ~/, .\, ..\ prefixes or any Path.is_absolute() match (Windows-safe)
  • HTTPS URL — downloads ZIP, enforces HTTPS (localhost HTTP allowed), cleans up after install
  • Raises ValueError on failure; caller converts to tracker error without aborting init

specify init updates

  • Added --extension as a repeatable list[str] | None typer option
  • Tracker steps pre-registered as extension-{i} before the Live context; installed after workflow, before final step
  • Extension failures are non-fatal (recorded in tracker, init continues)

Usage

# Bundled extension by name
specify init my-project --integration copilot --extension git

# Multiple extensions
specify init my-project --extension git --extension selftest

# Local path
specify init my-project --extension ./my-extensions/custom-ext

# URL
specify init my-project --extension https://example.com/extensions/my-ext.zip

Tests

Five new tests in TestExtensionFlag: bundled name, multiple extensions, local absolute path, unknown extension (graceful error, no abort), and combined with --preset.

Copilot AI requested review from Copilot and removed request for Copilot April 28, 2026 17:58
Copilot AI linked an issue Apr 28, 2026 that may be closed by this pull request
4 tasks
Copilot AI requested review from Copilot and removed request for Copilot April 28, 2026 18:09
Copilot AI requested review from Copilot and removed request for Copilot April 28, 2026 18:11
Copilot AI changed the title [WIP] Add --extension flag to specify init for extensions Add --extension flag to specify init for opting into extensions at init time Apr 28, 2026
Copilot AI requested a review from mnriem April 28, 2026 18:12
@mnriem mnriem requested a review from Copilot April 28, 2026 19:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 init with a repeatable --extension option 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 includes ExtensionError, ValidationError, and CompatibilityError but none of them are used in this helper. Either remove these imports or use them to normalize errors (e.g., catch and re-raise as ValueError) 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

Comment on lines +965 to +1009
"""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"
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +984 to +992
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:
Copy link

Copilot AI Apr 28, 2026

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 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.

Suggested change
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:

Copilot uses AI. Check for mistakes.
Comment on lines +1001 to +1009
# --- 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"
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +1576 to +1580
# Install extensions specified via --extension
if extensions:
speckit_ver = get_speckit_version()
for i, ext_spec in enumerate(extensions):
tracker.start(f"extension-{i}")
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add --extension flag to specify init for opting into extensions at init time

3 participants