diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d5f5aba2d5..7b0f89a1fd 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -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: + 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" + + # --- 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.zip # 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 diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index df48323ed2..729f0a894a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -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"