Add --extension flag to specify init for installing extensions at init 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>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-28 18:09:29 +00:00
committed by GitHub
parent e3cf9d76e8
commit a1543bc1f0
2 changed files with 219 additions and 1 deletions

View File

@@ -961,6 +961,99 @@ SKILL_DESCRIPTIONS = {
}
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(("./", "../", "/", "~/", ".\\", "..\\")):
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"
# --- 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.tar.gz # 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}")
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

View File

@@ -628,3 +628,106 @@ class TestSharedInfraCommandRefs:
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"