Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines +984 to +992
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.
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
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 +1001 to +1009
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.

# --- 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)"),
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
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.
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
Expand Down
103 changes: 103 additions & 0 deletions tests/integrations/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,106 @@ def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
assert "__SPECKIT_COMMAND_" not in content


class TestExtensionFlag:
"""Tests for the --extension flag on specify init."""

def _run_init(self, tmp_path, args, project_name="ext-test"):
from unittest.mock import patch
from typer.testing import CliRunner
from specify_cli import app

project = tmp_path / project_name
project.mkdir(exist_ok=True)
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
# Patch get_speckit_version to return a stable (non-dev) version so that
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
with patch("specify_cli.get_speckit_version", return_value="0.8.2"):
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
] + args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
return project, result

def test_bundled_extension_installed(self, tmp_path):
"""--extension git installs the bundled git extension."""
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")

assert result.exit_code == 0, f"init failed:\n{result.output}"

ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not found"
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"

# Tracker should show extension step as done
normalized = _normalize_cli_output(result.output)
assert "Install extension: git" in normalized

def test_multiple_extensions_installed(self, tmp_path):
"""--extension can be specified multiple times."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--extension", "selftest"],
project_name="ext-multi",
)

assert result.exit_code == 0, f"init failed:\n{result.output}"

ext_dir_git = project / ".specify" / "extensions" / "git"
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
assert ext_dir_git.exists(), "git extension not installed"
assert ext_dir_selftest.exists(), "selftest extension not installed"

def test_local_path_extension_installed(self, tmp_path):
"""--extension /abs/path installs from a local absolute directory path."""
from specify_cli import _locate_bundled_extension

# Use the bundled git extension directory as our "local" extension source
bundled_git = _locate_bundled_extension("git")
assert bundled_git is not None, "bundled git extension not found; cannot run test"

# Pass the absolute path directly (starts with "/")
project, result = self._run_init(
tmp_path,
["--extension", str(bundled_git)],
project_name="ext-local",
)

assert result.exit_code == 0, f"init failed:\n{result.output}"

ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "extension from local path not installed"

def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
"""An unknown extension name records a tracker error but does not abort init."""
project, result = self._run_init(
tmp_path,
["--extension", "nonexistent-xyz-ext"],
project_name="ext-unknown",
)

assert result.exit_code == 0, "init should not abort on unknown extension"
normalized = _normalize_cli_output(result.output)
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"

def test_extension_flag_works_with_preset(self, tmp_path):
"""--extension and --preset can be combined."""
project, result = self._run_init(
tmp_path,
["--extension", "git", "--preset", "lean"],
project_name="ext-preset",
)

assert result.exit_code == 0, f"init failed:\n{result.output}"

ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension not installed alongside preset"