Skip to content

Commit a1543bc

Browse files
Copilotmnriem
andauthored
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>
1 parent e3cf9d7 commit a1543bc

2 files changed

Lines changed: 219 additions & 1 deletion

File tree

src/specify_cli/__init__.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,99 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
961961
}
962962

963963

964+
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
965+
"""Install a single extension during ``specify init``.
966+
967+
Handles bundled extension names, local directory paths, and HTTPS URLs.
968+
Returns a short status message on success.
969+
Raises ``ValueError`` on failure so the caller can convert it to a
970+
tracker error without aborting the entire init.
971+
"""
972+
from urllib.parse import urlparse
973+
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
974+
975+
manager = ExtensionManager(project_path)
976+
977+
# --- URL ---
978+
parsed = urlparse(ext_spec)
979+
if parsed.scheme in ("http", "https"):
980+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
981+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
982+
raise ValueError("URL must use HTTPS (HTTP is only allowed for localhost)")
983+
984+
import urllib.request
985+
import urllib.error as _urllib_error
986+
download_dir = project_path / ".specify" / "extensions" / ".cache" / "downloads"
987+
download_dir.mkdir(parents=True, exist_ok=True)
988+
import re as _re
989+
safe_name = _re.sub(r"[^a-z0-9-]", "-", (parsed.path.split("/")[-1] or "download").lower())[:64]
990+
zip_path = download_dir / f"{safe_name}-init-download.zip"
991+
try:
992+
with urllib.request.urlopen(ext_spec, timeout=60) as _resp:
993+
zip_path.write_bytes(_resp.read())
994+
manifest = manager.install_from_zip(zip_path, speckit_version)
995+
except _urllib_error.URLError as exc:
996+
raise ValueError(f"Failed to download from {ext_spec}: {exc}") from exc
997+
finally:
998+
zip_path.unlink(missing_ok=True)
999+
return f"{manifest.name} v{manifest.version} installed"
1000+
1001+
# --- Local path ---
1002+
if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")):
1003+
source_path = Path(ext_spec).expanduser().resolve()
1004+
if not source_path.exists():
1005+
raise ValueError(f"Directory not found: {source_path}")
1006+
if not (source_path / "extension.yml").exists():
1007+
raise ValueError(f"No extension.yml found in {source_path}")
1008+
manifest = manager.install_from_directory(source_path, speckit_version)
1009+
return f"{manifest.name} v{manifest.version} installed"
1010+
1011+
# --- Bundled extension name or catalog ID ---
1012+
bundled_path = _locate_bundled_extension(ext_spec)
1013+
if bundled_path is not None:
1014+
if manager.registry.is_installed(ext_spec):
1015+
return "already installed"
1016+
manifest = manager.install_from_directory(bundled_path, speckit_version)
1017+
return f"{manifest.name} v{manifest.version} installed"
1018+
1019+
# Fall back to catalog
1020+
catalog = ExtensionCatalog(project_path)
1021+
ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add")
1022+
if catalog_error:
1023+
raise ValueError(f"Could not query extension catalog: {catalog_error}")
1024+
if not ext_info:
1025+
raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog")
1026+
1027+
resolved_id = ext_info["id"]
1028+
if resolved_id != ext_spec:
1029+
bundled_path = _locate_bundled_extension(resolved_id)
1030+
if bundled_path is not None:
1031+
if manager.registry.is_installed(resolved_id):
1032+
return "already installed"
1033+
manifest = manager.install_from_directory(bundled_path, speckit_version)
1034+
return f"{manifest.name} v{manifest.version} installed"
1035+
1036+
if ext_info.get("bundled") and not ext_info.get("download_url"):
1037+
from .extensions import REINSTALL_COMMAND
1038+
raise ValueError(
1039+
f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. "
1040+
f"Try reinstalling spec-kit: {REINSTALL_COMMAND}"
1041+
)
1042+
1043+
if not ext_info.get("_install_allowed", True):
1044+
catalog_name = ext_info.get("_catalog_name", "community")
1045+
raise ValueError(
1046+
f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog"
1047+
)
1048+
1049+
zip_path = catalog.download_extension(resolved_id)
1050+
try:
1051+
manifest = manager.install_from_zip(zip_path, speckit_version)
1052+
finally:
1053+
zip_path.unlink(missing_ok=True)
1054+
return f"{manifest.name} v{manifest.version} installed"
1055+
1056+
9641057
@app.command()
9651058
def init(
9661059
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(
9801073
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
9811074
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
9821075
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
1076+
extensions: list[str] | None = typer.Option(None, "--extension", help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable."),
9831077
):
9841078
"""
9851079
Initialize a new Specify project.
@@ -1019,6 +1113,10 @@ def init(
10191113
specify init --here --integration gemini
10201114
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
10211115
specify init my-project --integration claude --preset healthcare-compliance # With preset
1116+
specify init my-project --integration copilot --extension git # With bundled extension
1117+
specify init my-project --extension git --extension selftest # Multiple extensions
1118+
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
1119+
specify init my-project --extension https://example.com/extensions/my-ext.tar.gz # URL extension
10221120
"""
10231121

10241122
show_banner()
@@ -1262,10 +1360,15 @@ def init(
12621360
("constitution", "Constitution setup"),
12631361
("git", "Install git extension"),
12641362
("workflow", "Install bundled workflow"),
1265-
("final", "Finalize"),
12661363
]:
12671364
tracker.add(key, label)
12681365

1366+
if extensions:
1367+
for i, ext_spec in enumerate(extensions):
1368+
tracker.add(f"extension-{i}", f"Install extension: {ext_spec}")
1369+
1370+
tracker.add("final", "Finalize")
1371+
12691372
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
12701373
tracker.attach_refresh(lambda: live.update(tracker.render()))
12711374
try:
@@ -1470,6 +1573,18 @@ def init(
14701573
except Exception as preset_err:
14711574
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
14721575

1576+
# Install extensions specified via --extension
1577+
if extensions:
1578+
speckit_ver = get_speckit_version()
1579+
for i, ext_spec in enumerate(extensions):
1580+
tracker.start(f"extension-{i}")
1581+
try:
1582+
status_msg = _install_extension_during_init(project_path, ext_spec, speckit_ver)
1583+
tracker.complete(f"extension-{i}", status_msg)
1584+
except Exception as ext_err:
1585+
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
1586+
tracker.error(f"extension-{i}", f"failed: {sanitized_ext[:120]}")
1587+
14731588
tracker.complete("final", "project ready")
14741589
except (typer.Exit, SystemExit):
14751590
raise

tests/integrations/test_cli.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,3 +628,106 @@ def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
628628
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
629629
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
630630
assert "__SPECKIT_COMMAND_" not in content
631+
632+
633+
class TestExtensionFlag:
634+
"""Tests for the --extension flag on specify init."""
635+
636+
def _run_init(self, tmp_path, args, project_name="ext-test"):
637+
from unittest.mock import patch
638+
from typer.testing import CliRunner
639+
from specify_cli import app
640+
641+
project = tmp_path / project_name
642+
project.mkdir(exist_ok=True)
643+
old_cwd = os.getcwd()
644+
try:
645+
os.chdir(project)
646+
runner = CliRunner()
647+
# Patch get_speckit_version to return a stable (non-dev) version so that
648+
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
649+
with patch("specify_cli.get_speckit_version", return_value="0.8.2"):
650+
result = runner.invoke(app, [
651+
"init", "--here",
652+
"--integration", "copilot",
653+
"--script", "sh",
654+
"--no-git",
655+
"--ignore-agent-tools",
656+
] + args, catch_exceptions=False)
657+
finally:
658+
os.chdir(old_cwd)
659+
return project, result
660+
661+
def test_bundled_extension_installed(self, tmp_path):
662+
"""--extension git installs the bundled git extension."""
663+
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")
664+
665+
assert result.exit_code == 0, f"init failed:\n{result.output}"
666+
667+
ext_dir = project / ".specify" / "extensions" / "git"
668+
assert ext_dir.exists(), "git extension directory not found"
669+
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"
670+
671+
# Tracker should show extension step as done
672+
normalized = _normalize_cli_output(result.output)
673+
assert "Install extension: git" in normalized
674+
675+
def test_multiple_extensions_installed(self, tmp_path):
676+
"""--extension can be specified multiple times."""
677+
project, result = self._run_init(
678+
tmp_path,
679+
["--extension", "git", "--extension", "selftest"],
680+
project_name="ext-multi",
681+
)
682+
683+
assert result.exit_code == 0, f"init failed:\n{result.output}"
684+
685+
ext_dir_git = project / ".specify" / "extensions" / "git"
686+
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
687+
assert ext_dir_git.exists(), "git extension not installed"
688+
assert ext_dir_selftest.exists(), "selftest extension not installed"
689+
690+
def test_local_path_extension_installed(self, tmp_path):
691+
"""--extension /abs/path installs from a local absolute directory path."""
692+
from specify_cli import _locate_bundled_extension
693+
694+
# Use the bundled git extension directory as our "local" extension source
695+
bundled_git = _locate_bundled_extension("git")
696+
assert bundled_git is not None, "bundled git extension not found; cannot run test"
697+
698+
# Pass the absolute path directly (starts with "/")
699+
project, result = self._run_init(
700+
tmp_path,
701+
["--extension", str(bundled_git)],
702+
project_name="ext-local",
703+
)
704+
705+
assert result.exit_code == 0, f"init failed:\n{result.output}"
706+
707+
ext_dir = project / ".specify" / "extensions" / "git"
708+
assert ext_dir.exists(), "extension from local path not installed"
709+
710+
def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
711+
"""An unknown extension name records a tracker error but does not abort init."""
712+
project, result = self._run_init(
713+
tmp_path,
714+
["--extension", "nonexistent-xyz-ext"],
715+
project_name="ext-unknown",
716+
)
717+
718+
assert result.exit_code == 0, "init should not abort on unknown extension"
719+
normalized = _normalize_cli_output(result.output)
720+
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"
721+
722+
def test_extension_flag_works_with_preset(self, tmp_path):
723+
"""--extension and --preset can be combined."""
724+
project, result = self._run_init(
725+
tmp_path,
726+
["--extension", "git", "--preset", "lean"],
727+
project_name="ext-preset",
728+
)
729+
730+
assert result.exit_code == 0, f"init failed:\n{result.output}"
731+
732+
ext_dir = project / ".specify" / "extensions" / "git"
733+
assert ext_dir.exists(), "git extension not installed alongside preset"

0 commit comments

Comments
 (0)