diff --git a/README.md b/README.md index 0fb388a84..744bee19a 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. ## 🤖 Supported AI Agents + | Agent | Support | Notes | | ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | [Qoder CLI](https://qoder.com/cli) | ✅ | | @@ -331,9 +332,9 @@ Community projects that extend, visualize, or build on Spec Kit: ## Available Slash Commands -After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`. +After running `specify init --integration `, your AI coding agent will have access to these slash commands for structured development. Integrations choose the appropriate command format for each agent, including native skills where supported. The legacy `--ai` and `--ai-skills` options remain available as deprecated aliases. -#### Core Commands +### Core Commands Essential commands for the Spec-Driven Development workflow: @@ -346,7 +347,7 @@ Essential commands for the Spec-Driven Development workflow: | `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution | | `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | -#### Optional Commands +### Optional Commands Additional commands for enhanced quality and validation: @@ -386,17 +387,21 @@ specify init [PROJECT_NAME] | Argument/Option | Type | Description | | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | -| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | +| `--integration` | Option | Integration to use. Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli`, `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` | +| `--integration-options`| Option | Options passed to the selected integration, such as `--integration-options="--commands-dir .myagent/commands/"` for `generic` | +| `--ai` | Option | Deprecated legacy alias for `--integration` | +| `--ai-commands-dir` | Option | Deprecated legacy option for generic command directories. Use `--integration generic --integration-options="--commands-dir "` instead | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | -| `--no-git` | Flag | Skip git repository initialization | +| `--no-git` | Flag | Deprecated. Skip git repository initialization and default git-extension installation | | `--here` | Flag | Initialize project in the current directory instead of creating a new one | | `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) | | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | -| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | +| `--ai-skills` | Flag | Deprecated legacy option for integrations that support skills. Skills are now selected by the integration. | +| `--extension` | Option | Install an extension during initialization. Repeatable; accepts bundled IDs, local paths, or archive URLs. | +| `--preset` | Option | Install a preset during initialization | | `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`, …, `1000`, … — expands beyond 3 digits automatically) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | ### Examples @@ -406,10 +411,13 @@ specify init [PROJECT_NAME] specify init my-project # Initialize with specific AI assistant -specify init my-project --ai claude +specify init my-project --integration claude + +# Install a bundled extension during initialization +specify init my-project --integration copilot --extension git # Initialize with Cursor support -specify init my-project --ai cursor-agent +specify init my-project --integration cursor-agent # Initialize with Qoder support specify init my-project --ai qodercli diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0bbf42ad5..7268069c5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -646,6 +646,270 @@ def _locate_bundled_preset(preset_id: str) -> Path | None: return None +def _looks_like_local_path(value: str) -> bool: + """Return True when *value* appears to be a filesystem path.""" + return ( + value.startswith((".", "~", os.sep)) + or "/" in value + or "\\" in value + ) + + +def _is_url(value: str) -> bool: + """Return True when *value* is an HTTP(S) URL.""" + from urllib.parse import urlparse + + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _validate_extension_url(url: str) -> None: + """Validate an extension download URL before fetching it.""" + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + from .extensions import ExtensionError + + raise ExtensionError( + "Extension URLs must use HTTPS. HTTP is only allowed for localhost URLs." + ) + + +MAX_EXTENSION_ARCHIVE_BYTES = 100 * 1024 * 1024 +DOWNLOAD_CHUNK_BYTES = 1024 * 1024 + + +def _install_extension_archive( + manager: Any, + archive_path: Path, + speckit_version: str, + priority: int = 10, +) -> Any: + """Install an extension from a ZIP or tar archive.""" + import tarfile + from .extensions import ValidationError + + if zipfile.is_zipfile(archive_path): + return manager.install_from_zip( + archive_path, speckit_version, priority=priority + ) + + if not tarfile.is_tarfile(archive_path): + raise ValidationError("Extension archive must be a ZIP, .tar.gz, or .tgz file") + + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + temp_path_resolved = temp_path.resolve() + + with tarfile.open(archive_path, "r:*") as tf: + for member in tf.getmembers(): + if member.issym() or member.islnk(): + raise ValidationError( + f"Unsafe link in TAR archive: {member.name}" + ) + if not (member.isfile() or member.isdir()): + raise ValidationError( + f"Unsupported TAR member type in archive: {member.name}" + ) + member_path = (temp_path / member.name).resolve() + try: + member_path.relative_to(temp_path_resolved) + except ValueError: + raise ValidationError( + f"Unsafe path in TAR archive: {member.name} " + "(potential path traversal)" + ) from None + + tf.extractall(temp_path) + + extension_dir = temp_path + manifest_path = extension_dir / "extension.yml" + if not manifest_path.exists(): + subdirs = [d for d in temp_path.iterdir() if d.is_dir()] + if len(subdirs) == 1: + extension_dir = subdirs[0] + manifest_path = extension_dir / "extension.yml" + + if not manifest_path.exists(): + raise ValidationError("No extension.yml found in archive") + + return manager.install_from_directory( + extension_dir, speckit_version, priority=priority + ) + + +def _download_and_install_extension_url( + manager: Any, + project_path: Path, + source_url: str, + speckit_version: str, + priority: int = 10, +) -> Any: + """Download and install an extension archive from a validated URL.""" + import urllib.error + import urllib.request + from urllib.parse import urlparse + from .extensions import ExtensionError + + _validate_extension_url(source_url) + + download_dir = project_path / ".specify" / "extensions" / ".cache" / "downloads" + download_dir.mkdir(parents=True, exist_ok=True) + + url_path = urlparse(source_url).path + if url_path.endswith(".tar.gz"): + suffix = ".tar.gz" + elif url_path.endswith(".tgz"): + suffix = ".tgz" + else: + suffix = Path(url_path).suffix or ".zip" + + archive_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + prefix="init-extension-", + suffix=suffix, + dir=download_dir, + delete=False, + ) as fh: + archive_path = Path(fh.name) + + console.print("[yellow]Warning:[/yellow] Installing extension from external URL.") + console.print("Only install extensions from sources you trust.") + console.print(f"Downloading from {source_url}...") + + try: + with urllib.request.urlopen(source_url, timeout=60) as response: + content_length = response.headers.get("Content-Length") + try: + content_length_bytes = ( + int(content_length) if content_length is not None else None + ) + except ValueError: + content_length_bytes = None + + if ( + content_length_bytes + and content_length_bytes > MAX_EXTENSION_ARCHIVE_BYTES + ): + raise ExtensionError( + "Extension archive is too large; maximum allowed size " + f"is {MAX_EXTENSION_ARCHIVE_BYTES // (1024 * 1024)} MiB." + ) + + downloaded = 0 + with archive_path.open("wb") as fh: + while True: + chunk = response.read(DOWNLOAD_CHUNK_BYTES) + if not chunk: + break + downloaded += len(chunk) + if downloaded > MAX_EXTENSION_ARCHIVE_BYTES: + raise ExtensionError( + "Extension archive is too large; maximum allowed " + f"size is {MAX_EXTENSION_ARCHIVE_BYTES // (1024 * 1024)} MiB." + ) + fh.write(chunk) + except urllib.error.URLError as exc: + raise ExtensionError( + f"Failed to download extension from {source_url}: {exc}" + ) from exc + + return _install_extension_archive( + manager, archive_path, speckit_version, priority=priority + ) + finally: + if archive_path and archive_path.exists(): + archive_path.unlink() + + +def _install_extension_for_init( + project_path: Path, + source: str, + speckit_version: str, + priority: int = 10, +) -> Any: + """Install an init-time extension from a name, local path, or URL.""" + from .extensions import ( + ExtensionCatalog, + ExtensionError, + ExtensionManager, + REINSTALL_COMMAND, + ) + + manager = ExtensionManager(project_path) + + if priority < 1: + raise ExtensionError("Priority must be a positive integer (1 or higher)") + + if _is_url(source): + return _download_and_install_extension_url( + manager, project_path, source, speckit_version, priority=priority + ) + + source_path = Path(source).expanduser() + if source_path.exists(): + source_path = source_path.resolve() + if not source_path.is_dir(): + raise ExtensionError(f"Extension path is not a directory: {source_path}") + if not (source_path / "extension.yml").exists(): + raise ExtensionError(f"No extension.yml found in {source_path}") + return manager.install_from_directory( + source_path, speckit_version, priority=priority + ) + + if _looks_like_local_path(source): + raise ExtensionError(f"Extension path not found: {source_path}") + + bundled_path = _locate_bundled_extension(source) + if bundled_path is not None: + return manager.install_from_directory( + bundled_path, speckit_version, priority=priority + ) + + catalog = ExtensionCatalog(project_path) + ext_info, catalog_error = _resolve_catalog_extension(source, catalog, "add") + if catalog_error: + raise ExtensionError(f"Could not query extension catalog: {catalog_error}") + if not ext_info: + raise ExtensionError( + f"Extension '{source}' not found in catalog. " + "Run 'specify extension search' to browse available extensions." + ) + + resolved_id = ext_info["id"] + if resolved_id != source: + bundled_path = _locate_bundled_extension(resolved_id) + if bundled_path is not None: + return manager.install_from_directory( + bundled_path, speckit_version, priority=priority + ) + + if ext_info.get("bundled") and not ext_info.get("download_url"): + raise ExtensionError( + f"Extension '{resolved_id}' is bundled with spec-kit but could not " + "be found in the installed package. This usually means the spec-kit " + f"installation is incomplete or corrupted. Try reinstalling: {REINSTALL_COMMAND}" + ) + + if not ext_info.get("_install_allowed", True): + catalog_name = ext_info.get("_catalog_name", "community") + raise ExtensionError( + f"'{source}' is available in the '{catalog_name}' catalog but " + "installation is not allowed from that catalog." + ) + + zip_path = catalog.download_extension(resolved_id) + try: + return manager.install_from_zip(zip_path, speckit_version, priority=priority) + finally: + if zip_path.exists(): + zip_path.unlink() + + def _install_shared_infra( project_path: Path, script_type: str, @@ -873,19 +1137,20 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: @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)"), - ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), - ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), + ai_assistant: str = typer.Option(None, "--ai", help=f"Deprecated; use --integration. {AI_ASSISTANT_HELP}"), + ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help='Deprecated; use --integration generic --integration-options="--commands-dir ".'), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), - no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), + no_git: bool = typer.Option(False, "--no-git", help="Deprecated; skip git repository initialization and default git-extension installation"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True), github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), - ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + ai_skills: bool = typer.Option(False, "--ai-skills", help="Deprecated; skills are selected by the integration"), offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), + extensions: Optional[list[str]] = typer.Option(None, "--extension", help="Install an extension during initialization (repeatable; accepts bundled IDs, local paths, or archive URLs)"), 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")'), @@ -893,45 +1158,40 @@ def init( """ Initialize a new Specify project. - By default, project files are downloaded from the latest GitHub release. - Use --offline to scaffold from assets bundled inside the specify-cli - package instead (no internet access required, ideal for air-gapped or - enterprise environments). - - NOTE: Starting with v0.6.0, bundled assets will be used by default and - the --offline flag will be removed. The GitHub download path will be - retired because bundled assets eliminate the need for network access, - avoid proxy/firewall issues, and guarantee that templates always match - the installed CLI version. + Project files are scaffolded from assets bundled inside the specify-cli + package, so initialization does not need release ZIP downloads. This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download template from GitHub (or use bundled assets with --offline) + 3. Install the selected integration and shared Spec Kit assets 4. Initialize a fresh git repository (if not --no-git and no existing repo) - 5. Optionally set up AI assistant commands + 5. Optionally install presets and extensions Examples: specify init my-project - specify init my-project --ai claude - specify init my-project --ai copilot --no-git + specify init my-project --integration claude + specify init my-project --integration copilot --no-git specify init --ignore-agent-tools my-project - specify init . --ai claude # Initialize in current directory + specify init . --integration claude # Initialize in current directory specify init . # Initialize in current directory (interactive AI selection) - specify init --here --ai claude # Alternative syntax for current directory - specify init --here --ai codex --ai-skills - specify init --here --ai codebuddy - specify init --here --ai vibe # Initialize with Mistral Vibe support + specify init --here --integration claude # Alternative syntax for current directory + specify init --here --integration codex + specify init --here --integration codebuddy + specify init --here --integration vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty - specify init my-project --ai claude # Claude installs skills by default - specify init --here --ai gemini --ai-skills - specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent + specify init my-project --integration claude # Claude installs skills by default + specify init --here --integration gemini + specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Unsupported agent specify init my-project --offline # Use bundled assets (no network access) - specify init my-project --ai claude --preset healthcare-compliance # With preset + specify init my-project --integration claude --preset healthcare-compliance # With preset + specify init my-project --integration copilot --extension git # With extension """ show_banner() + used_ai_flag = ai_assistant is not None + init_extensions = list(dict.fromkeys(extensions or [])) # Detect when option values are likely misinterpreted flags (parameter ordering issue) if ai_assistant and ai_assistant.startswith("--"): @@ -955,6 +1215,18 @@ def init( console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") raise typer.Exit(1) + if used_ai_flag and ai_assistant: + console.print( + "[yellow]Warning:[/yellow] --ai is deprecated and will be removed in v1.0.0. " + f"Use --integration {ai_assistant} instead." + ) + + if no_git: + console.print( + "[yellow]Warning:[/yellow] --no-git is deprecated and will be removed in v1.0.0. " + "The git extension will no longer be enabled by default; use --extension git to opt in." + ) + # Resolve the integration — either from --integration or --ai from .integrations import INTEGRATION_REGISTRY, get_integration if integration: @@ -1155,14 +1427,21 @@ def init( tracker.add("integration", "Install integration") tracker.add("shared-infra", "Install shared infrastructure") - for key, label in [ + init_steps = [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), + ] + if init_extensions: + init_steps.append(("extensions", "Install requested extensions")) + init_steps.extend([ ("git", "Install git extension"), ("final", "Finalize"), - ]: + ]) + for key, label in init_steps: tracker.add(key, label) + git_default_notice = False + with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: @@ -1211,6 +1490,44 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + # Persist the CLI options so later operations (e.g. extension/preset + # add) can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE extension/preset install so skills-aware + # registration can read the selected integration. + init_opts = { + "ai": selected_ai, + "integration": resolved_integration.key, + "branch_numbering": branch_numbering or "sequential", + "here": here, + "preset": preset, + "extensions": init_extensions, + "script": selected_script, + "speckit_version": get_speckit_version(), + } + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist): + init_opts["ai_skills"] = True + save_init_options(project_path, init_opts) + + if init_extensions: + tracker.start("extensions") + installed_extensions = [] + try: + for extension_source in init_extensions: + manifest = _install_extension_for_init( + project_path, + extension_source, + get_speckit_version(), + ) + installed_extensions.append(manifest.id) + except Exception as ext_err: + sanitized_ext = str(ext_err).replace('\n', ' ').strip() + tracker.error("extensions", sanitized_ext[:120]) + raise + tracker.complete("extensions", ", ".join(installed_extensions)) + if not no_git: tracker.start("git") git_messages = [] @@ -1244,6 +1561,7 @@ def init( manager.install_from_directory( bundled_path, get_speckit_version() ) + git_default_notice = True git_messages.append("extension installed") else: git_has_error = True @@ -1265,25 +1583,6 @@ def init( # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) - # Persist the CLI options so later operations (e.g. preset add) - # can adapt their behaviour without re-scanning the filesystem. - # Must be saved BEFORE preset install so _get_skills_dir() works. - init_opts = { - "ai": selected_ai, - "integration": resolved_integration.key, - "branch_numbering": branch_numbering or "sequential", - "here": here, - "preset": preset, - "script": selected_script, - "speckit_version": get_speckit_version(), - } - # Ensure ai_skills is set for SkillsIntegration so downstream - # tools (extensions, presets) emit SKILL.md overrides correctly. - from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist): - init_opts["ai_skills"] = True - save_init_options(project_path, init_opts) - # Install preset if specified if preset: try: @@ -1356,6 +1655,19 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") + if git_default_notice: + git_notice = Panel( + "The git extension is currently enabled by default. Starting with " + "v1.0.0, it will require explicit opt-in via " + "[cyan]specify init --extension git[/cyan] or post-init with " + "[cyan]specify extension add git[/cyan].", + title="[yellow]Git Extension Default Changing[/yellow]", + border_style="yellow", + padding=(1, 2), + ) + console.print() + console.print(git_notice) + # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 1e23e35a7..5a7622157 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -2,10 +2,52 @@ import json import os +import tarfile +from io import BytesIO +import pytest import yaml +def _write_test_extension(root, ext_id="sample-ext"): + """Create a minimal extension fixture.""" + ext_dir = root / ext_id + commands_dir = ext_dir / "commands" + commands_dir.mkdir(parents=True) + + manifest = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": "Sample Extension", + "version": "1.0.0", + "description": "Sample extension for init tests", + "author": "Test", + "repository": "https://github.com/example/sample-ext", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.{ext_id}.hello", + "file": "commands/hello.md", + "description": "Say hello", + } + ] + }, + } + (ext_dir / "extension.yml").write_text( + yaml.safe_dump(manifest, sort_keys=False), + encoding="utf-8", + ) + (commands_dir / "hello.md").write_text( + "---\ndescription: Say hello\n---\n\nHello from $ARGUMENTS\n", + encoding="utf-8", + ) + return ext_dir + + class TestInitIntegrationFlag: def test_integration_and_ai_mutually_exclusive(self, tmp_path): from typer.testing import CliRunner @@ -75,6 +117,9 @@ def test_ai_copilot_auto_promotes(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 + assert "--ai is deprecated" in result.output + assert "--integration" in result.output + assert "copilot instead" in result.output assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): @@ -151,6 +196,159 @@ def test_shared_infra_skips_existing_files(self, tmp_path): assert (templates_dir / "plan-template.md").exists() +class TestInitExtensionFlag: + """Tests for installing extensions during specify init.""" + + def test_extension_flag_installs_local_extension(self, tmp_path): + """--extension accepts a local extension directory during init.""" + from typer.testing import CliRunner + from specify_cli import app + + extension_dir = _write_test_extension(tmp_path) + project = tmp_path / "with-extension" + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(project), + "--integration", "copilot", + "--extension", str(extension_dir), + "--script", "sh", + "--no-git", + ], catch_exceptions=False) + + assert result.exit_code == 0, result.output + installed = project / ".specify" / "extensions" / "sample-ext" + assert installed.exists() + assert (installed / "extension.yml").exists() + assert ( + project / ".github" / "agents" / "speckit.sample-ext.hello.agent.md" + ).exists() + + opts = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert opts["extensions"] == [str(extension_dir)] + + def test_extension_flag_is_repeatable(self, tmp_path): + """Multiple --extension values are installed in order.""" + from typer.testing import CliRunner + from specify_cli import app + + ext_one = _write_test_extension(tmp_path, "alpha-ext") + ext_two = _write_test_extension(tmp_path, "beta-ext") + project = tmp_path / "repeatable-extension" + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(project), + "--integration", "copilot", + "--extension", str(ext_one), + "--extension", str(ext_two), + "--script", "sh", + "--no-git", + ], catch_exceptions=False) + + assert result.exit_code == 0, result.output + assert (project / ".specify" / "extensions" / "alpha-ext").exists() + assert (project / ".specify" / "extensions" / "beta-ext").exists() + + def test_extension_git_explicit_opt_in_works_with_no_git(self, tmp_path): + """Explicit --extension git installs the bundled git extension even with --no-git.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "explicit-git" + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(project), + "--integration", "copilot", + "--extension", "git", + "--script", "sh", + "--no-git", + ], catch_exceptions=False) + + assert result.exit_code == 0, result.output + assert (project / ".specify" / "extensions" / "git").exists() + assert not (project / ".git").exists() + + def test_tar_extension_archive_rejects_special_members(self, tmp_path): + """TAR extension archives reject non-file and non-directory members.""" + from specify_cli import _install_extension_archive + from specify_cli.extensions import ValidationError + + archive_path = tmp_path / "unsafe-extension.tar" + manifest = b"extension:\n id: test-ext\n name: Test\n version: 1.0.0\n" + + with tarfile.open(archive_path, "w") as tf: + manifest_info = tarfile.TarInfo("extension.yml") + manifest_info.size = len(manifest) + tf.addfile(manifest_info, BytesIO(manifest)) + + fifo_info = tarfile.TarInfo("unsafe-fifo") + fifo_info.type = tarfile.FIFOTYPE + tf.addfile(fifo_info) + + with pytest.raises(ValidationError, match="Unsupported TAR member type"): + _install_extension_archive(object(), archive_path, "0.0.0") + + def test_extension_url_downloads_in_bounded_chunks(self, tmp_path, monkeypatch): + """URL extension downloads stream to disk instead of reading all bytes.""" + import urllib.request + import specify_cli + + payload = b"archive-bytes" + read_sizes = [] + + class FakeResponse: + headers = {"Content-Length": str(len(payload))} + + def __init__(self): + self.offset = 0 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, size=-1): + read_sizes.append(size) + if self.offset >= len(payload): + return b"" + end = min(self.offset + size, len(payload)) + chunk = payload[self.offset:end] + self.offset = end + return chunk + + def fake_urlopen(url, timeout): + assert url == "https://example.com/extension.zip" + assert timeout == 60 + return FakeResponse() + + def fake_install(manager, archive_path, speckit_version, priority=10): + assert archive_path.read_bytes() == payload + assert speckit_version == "0.0.0" + assert priority == 10 + return "installed" + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(specify_cli, "_install_extension_archive", fake_install) + + result = specify_cli._download_and_install_extension_url( + object(), + tmp_path, + "https://example.com/extension.zip", + "0.0.0", + ) + + assert result == "installed" + assert read_sizes == [ + specify_cli.DOWNLOAD_CHUNK_BYTES, + specify_cli.DOWNLOAD_CHUNK_BYTES, + ] + + class TestForceExistingDirectory: """Tests for --force merging into an existing named directory.""" @@ -238,6 +436,8 @@ def test_git_extension_auto_installed(self, tmp_path): assert "hooks" in hooks_data assert "before_specify" in hooks_data["hooks"] assert "before_constitution" in hooks_data["hooks"] + assert "Git Extension Default Changing" in result.output + assert "specify init --extension git" in result.output def test_no_git_skips_extension(self, tmp_path): """With --no-git, the git extension is NOT installed.""" @@ -258,6 +458,7 @@ def test_no_git_skips_extension(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" + assert "--no-git is deprecated" in result.output # Git extension should NOT be installed ext_dir = project / ".specify" / "extensions" / "git"