diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e29592cc0..a340982c08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,12 @@ jobs: chmod +x .github/workflows/scripts/create-release-packages.sh .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }} + - name: Build Python wheel + if: steps.check_release.outputs.exists == 'false' + run: | + pip install build + python -m build --wheel --outdir .genreleases/ + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' id: release_notes diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index b577783845..d7ec36afc9 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -16,6 +16,7 @@ VERSION="$1" VERSION_NO_V=${VERSION#v} gh release create "$VERSION" \ + .genreleases/specify_cli-"$VERSION_NO_V"-py3-none-any.whl \ .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 77dac397ab..0f70704637 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -26,7 +26,8 @@ fi echo "Building release packages for $NEW_VERSION" # Create and use .genreleases directory for all build artifacts -GENRELEASES_DIR=".genreleases" +# Override via GENRELEASES_DIR env var (e.g. for tests writing to a temp dir) +GENRELEASES_DIR="${GENRELEASES_DIR:-.genreleases}" mkdir -p "$GENRELEASES_DIR" rm -rf "$GENRELEASES_DIR"/* || true @@ -218,7 +219,7 @@ build_variant() { esac fi - [[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" -exec cp --parents {} "$SPEC_DIR"/ \; ; echo "Copied templates -> .specify/templates"; } + [[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" | while IFS= read -r f; do d="$SPEC_DIR/$(dirname "$f")"; mkdir -p "$d"; cp "$f" "$d/"; done; echo "Copied templates -> .specify/templates"; } case $agent in claude) @@ -306,34 +307,35 @@ build_variant() { ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic) ALL_SCRIPTS=(sh ps) -norm_list() { - tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?"\n":"") $i);out=1}}}END{printf("\n")}' -} - validate_subset() { - local type=$1; shift; local -n allowed=$1; shift; local items=("$@") + local type=$1; shift + local allowed_str="$1"; shift local invalid=0 - for it in "${items[@]}"; do + for it in "$@"; do local found=0 - for a in "${allowed[@]}"; do [[ $it == "$a" ]] && { found=1; break; }; done + for a in $allowed_str; do + if [[ "$it" == "$a" ]]; then found=1; break; fi + done if [[ $found -eq 0 ]]; then - echo "Error: unknown $type '$it' (allowed: ${allowed[*]})" >&2 + echo "Error: unknown $type '$it' (allowed: $allowed_str)" >&2 invalid=1 fi done return $invalid } +read_list() { tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?" ":"") $i);out=1}}}END{printf("\n")}'; } + if [[ -n ${AGENTS:-} ]]; then - mapfile -t AGENT_LIST < <(printf '%s' "$AGENTS" | norm_list) - validate_subset agent ALL_AGENTS "${AGENT_LIST[@]}" || exit 1 + read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)" + validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1 else AGENT_LIST=("${ALL_AGENTS[@]}") fi if [[ -n ${SCRIPTS:-} ]]; then - mapfile -t SCRIPT_LIST < <(printf '%s' "$SCRIPTS" | norm_list) - validate_subset script ALL_SCRIPTS "${SCRIPT_LIST[@]}" || exit 1 + read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)" + validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1 else SCRIPT_LIST=("${ALL_SCRIPTS[@]}") fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fab8a3d01..6353bc5322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(cli): embed core templates/commands/scripts in wheel for air-gapped deployment; `specify init --offline` uses bundled assets without network access (#1711) +- feat(cli): add `--offline` flag to `specify init` to scaffold from bundled assets instead of downloading from GitHub (for air-gapped/enterprise environments) +- feat(cli): embed release scripts (bash + PowerShell) in wheel and invoke at runtime for guaranteed parity with GitHub release ZIPs +- feat(release): build and publish `specify_cli-*.whl` Python wheel as a release asset for enterprise/offline installation (#1752) - feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks - feat(presets): Pluggable preset system with preset catalog and template resolver - Preset manifest (`preset.yml`) with validation for artifact, command, and script types diff --git a/README.md b/README.md index b3c6235e5b..75df3a9567 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,17 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c - Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall` - Cleaner shell configuration +#### Option 3: Enterprise / Air-Gapped Installation + +If your environment blocks PyPI access, download the pre-built `specify_cli-*.whl` wheel from the [releases page](https://github.com/github/spec-kit/releases/latest) and install it directly: + +```bash +pip install specify_cli-*.whl +specify init my-project --ai claude --offline # runs without contacting api.github.com +``` + +The wheel bundles all templates, commands, and scripts, so `specify init` can run without any network access after install when you pass `--offline`. See the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) section for fully offline (no-PyPI) instructions. + ### 2. Establish project principles Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant. diff --git a/docs/installation.md b/docs/installation.md index 6daff24315..14d6adda24 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -74,6 +74,51 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. ## Troubleshooting +### Enterprise / Air-Gapped Installation + +If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can install Specify using the pre-built wheel from the GitHub releases page. + +**Step 1: Download the wheel** + +Go to the [Spec Kit releases page](https://github.com/github/spec-kit/releases/latest) and download the `specify_cli-*.whl` file. + +**Step 2: Install the wheel** + +```bash +pip install specify_cli-*.whl +``` + +**Step 3: Initialize a project (no network required)** + +```bash +specify init my-project --ai claude --offline +``` + +The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub — no connection to `api.github.com` needed. + +**If you also need runtime dependencies offline** (fully air-gapped machines with no access to any PyPI), use a connected machine with the same OS and Python version to download them first: + +```bash +# On a connected machine (same OS and Python version as the target): +pip download -d vendor specify_cli-*.whl + +# Transfer the wheel and vendor/ directory to the target machine + +# On the target machine: +pip install --no-index --find-links=./vendor specify_cli-*.whl +``` + +> **Note:** Python 3.11+ is required. The wheel is a pure-Python artifact, so it works on any platform without recompilation. + +**Using bundled assets (offline / air-gapped):** + +If you want to scaffold from the templates bundled inside the specify-cli +package instead of downloading from GitHub, use: + +```bash +specify init my-project --ai claude --offline +``` + ### Git Credential Manager on Linux If you're having issues with Git authentication on Linux, you can install Git Credential Manager: diff --git a/pyproject.toml b/pyproject.toml index df200d480e..37723caa24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,23 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/specify_cli"] +[tool.hatch.build.targets.wheel.force-include] +# Bundle core assets so `specify init` works without network access (air-gapped / enterprise) +# Page templates (exclude commands/ — bundled separately below to avoid duplication) +"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md" +"templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" +"templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" +"templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" +"templates/spec-template.md" = "specify_cli/core_pack/templates/spec-template.md" +"templates/tasks-template.md" = "specify_cli/core_pack/templates/tasks-template.md" +"templates/vscode-settings.json" = "specify_cli/core_pack/templates/vscode-settings.json" +# Command templates +"templates/commands" = "specify_cli/core_pack/commands" +"scripts/bash" = "specify_cli/core_pack/scripts/bash" +"scripts/powershell" = "specify_cli/core_pack/scripts/powershell" +".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh" +".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1" + [project.optional-dependencies] test = [ "pytest>=7.0", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ff2364d29a..32e1842d3f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -295,6 +295,9 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "kiro": "kiro-cli", } +# Agents that use TOML command format (others use Markdown) +_TOML_AGENTS = frozenset({"gemini", "tabnine"}) + def _build_ai_assistant_help() -> str: """Build the --ai help text from AGENT_CONFIG so it stays in sync with runtime config.""" @@ -1075,6 +1078,225 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path +def _locate_core_pack() -> Path | None: + """Return the filesystem path to the bundled core_pack directory, or None. + + Only present in wheel installs: hatchling's force-include copies + templates/, scripts/ etc. into specify_cli/core_pack/ at build time. + + Source-checkout and editable installs do NOT have this directory. + Callers that need to work in both environments must check the repo-root + trees (templates/, scripts/) as a fallback when this returns None. + """ + # Wheel install: core_pack is a sibling directory of this file + candidate = Path(__file__).parent / "core_pack" + if candidate.is_dir(): + return candidate + return None + + +def _locate_release_script() -> tuple[Path, str]: + """Return (script_path, shell_cmd) for the platform-appropriate release script. + + Checks the bundled core_pack first, then falls back to the source checkout. + Returns the bash script on Unix and the PowerShell script on Windows. + Raises FileNotFoundError if neither can be found. + """ + if os.name == "nt": + name = "create-release-packages.ps1" + shell = shutil.which("pwsh") + if not shell: + raise FileNotFoundError( + "'pwsh' (PowerShell 7) not found on PATH. " + "The bundled release script requires PowerShell 7+. " + "Install from https://aka.ms/powershell to use offline scaffolding." + ) + else: + name = "create-release-packages.sh" + shell = "bash" + + # Wheel install: core_pack/release_scripts/ + candidate = Path(__file__).parent / "core_pack" / "release_scripts" / name + if candidate.is_file(): + return candidate, shell + + # Source-checkout fallback + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / ".github" / "workflows" / "scripts" / name + if candidate.is_file(): + return candidate, shell + + raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout") + + +def scaffold_from_core_pack( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool = False, + *, + tracker: StepTracker | None = None, +) -> bool: + """Scaffold a project from bundled core_pack assets — no network access required. + + Invokes the bundled create-release-packages script (bash on Unix, PowerShell + on Windows) to generate the full project scaffold for a single agent. This + guarantees byte-for-byte parity between ``specify init`` and the GitHub + release ZIPs because both use the exact same script. + + Returns True on success, False if the required assets cannot be located + (caller should fall back to download_and_extract_template). + """ + # --- Locate asset sources --- + core = _locate_core_pack() + + # Command templates + if core and (core / "commands").is_dir(): + commands_dir = core / "commands" + else: + repo_root = Path(__file__).parent.parent.parent + commands_dir = repo_root / "templates" / "commands" + if not commands_dir.is_dir(): + if tracker: + tracker.error("scaffold", "command templates not found") + return False + + # Scripts directory (parent of bash/ and powershell/) + if core and (core / "scripts").is_dir(): + scripts_dir = core / "scripts" + else: + repo_root = Path(__file__).parent.parent.parent + scripts_dir = repo_root / "scripts" + if not scripts_dir.is_dir(): + if tracker: + tracker.error("scaffold", "scripts directory not found") + return False + + # Page templates (spec-template.md, plan-template.md, vscode-settings.json, etc.) + if core and (core / "templates").is_dir(): + templates_dir = core / "templates" + else: + repo_root = Path(__file__).parent.parent.parent + templates_dir = repo_root / "templates" + + # Release script + try: + release_script, shell_cmd = _locate_release_script() + except FileNotFoundError as exc: + if tracker: + tracker.error("scaffold", str(exc)) + return False + + # Preflight: verify required external tools are available + if os.name != "nt": + if not shutil.which("bash"): + msg = "'bash' not found on PATH. Required for offline scaffolding." + if tracker: + tracker.error("scaffold", msg) + return False + if not shutil.which("zip"): + msg = "'zip' not found on PATH. Required for offline scaffolding. Install with: apt install zip / brew install zip" + if tracker: + tracker.error("scaffold", msg) + return False + + if tracker: + tracker.start("scaffold", "applying bundled assets") + + try: + if not is_current_dir: + project_path.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + + # Set up a repo-like directory layout in the temp dir so the + # release script finds templates/commands/, scripts/, etc. + tmpl_cmds = tmp / "templates" / "commands" + tmpl_cmds.mkdir(parents=True) + for f in commands_dir.iterdir(): + if f.is_file(): + shutil.copy2(f, tmpl_cmds / f.name) + + # Page templates (needed for vscode-settings.json etc.) + if templates_dir.is_dir(): + tmpl_root = tmp / "templates" + for f in templates_dir.iterdir(): + if f.is_file(): + shutil.copy2(f, tmpl_root / f.name) + + # Scripts (bash/ and powershell/) + for subdir in ("bash", "powershell"): + src = scripts_dir / subdir + if src.is_dir(): + dst = tmp / "scripts" / subdir + dst.mkdir(parents=True, exist_ok=True) + for f in src.iterdir(): + if f.is_file(): + shutil.copy2(f, dst / f.name) + + # Run the release script for this single agent + script type + env = os.environ.copy() + if os.name == "nt": + cmd = [ + shell_cmd, "-File", str(release_script), + "-Version", "v0.0.0", + "-Agents", ai_assistant, + "-Scripts", script_type, + ] + else: + cmd = [shell_cmd, str(release_script), "v0.0.0"] + env["AGENTS"] = ai_assistant + env["SCRIPTS"] = script_type + + try: + result = subprocess.run( + cmd, cwd=str(tmp), env=env, + capture_output=True, text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + msg = "release script timed out after 120 seconds" + if tracker: + tracker.error("scaffold", msg) + else: + console.print(f"[red]Error:[/red] {msg}") + return False + + if result.returncode != 0: + msg = result.stderr.strip() or result.stdout.strip() or "unknown error" + if tracker: + tracker.error("scaffold", f"release script failed: {msg}") + else: + console.print(f"[red]Release script failed:[/red] {msg}") + return False + + # Copy the generated files to the project directory + build_dir = tmp / ".genreleases" / f"sdd-{ai_assistant}-package-{script_type}" + if not build_dir.is_dir(): + if tracker: + tracker.error("scaffold", "release script produced no output") + return False + + for item in build_dir.rglob("*"): + if item.is_file(): + rel = item.relative_to(build_dir) + dest = project_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, dest) + + if tracker: + tracker.complete("scaffold", "bundled assets applied") + return True + + except Exception as e: + if tracker: + tracker.error("scaffold", str(e)) + else: + console.print(f"[red]Error scaffolding from bundled assets:[/red] {e}") + return False + + def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": @@ -1432,19 +1654,24 @@ def init( debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required)"), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), ): """ - Initialize a new Specify project from the latest template. - + 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). + This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Download the appropriate template from GitHub - 4. Extract the template to a new project directory or current directory - 5. Initialize a fresh git repository (if not --no-git and no existing repo) - 6. Optionally set up AI assistant commands - + 3. Download template from GitHub (or use bundled assets with --offline) + 4. Initialize a fresh git repository (if not --no-git and no existing repo) + 5. Optionally set up AI assistant commands + Examples: specify init my-project specify init my-project --ai claude @@ -1461,6 +1688,7 @@ def init( specify init my-project --ai claude --ai-skills # Install agent skills specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-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 """ @@ -1636,12 +1864,37 @@ def init( tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) + + # Determine whether to use bundled assets or download from GitHub (default). + # --offline opts in to bundled assets; without it, always use GitHub. + _core = _locate_core_pack() + _repo_commands = Path(__file__).parent.parent.parent / "templates" / "commands" + _has_bundled = (_core is not None) or _repo_commands.is_dir() + + if offline and not _has_bundled: + console.print( + "\n[red]Error:[/red] --offline was specified but no bundled assets were found.\n" + " • Wheel install: reinstall the specify-cli wheel (core_pack/ must be present).\n" + " • Source checkout: run from the repo root so templates/ and scripts/ are accessible.\n" + "Remove --offline to attempt a GitHub download instead." + ) + raise typer.Exit(1) + + use_github = not (offline and _has_bundled) + + if use_github: + for key, label in [ + ("fetch", "Fetch latest release"), + ("download", "Download template"), + ("extract", "Extract template"), + ("zip-list", "Archive contents"), + ("extracted-summary", "Extraction summary"), + ]: + tracker.add(key, label) + else: + tracker.add("scaffold", "Apply bundled assets") + for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ]: @@ -1665,7 +1918,19 @@ def init( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + if use_github: + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + else: + scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) + if not scaffold_ok: + # --offline explicitly requested: never attempt a network download + console.print( + "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" + "Check the output above for the specific error.\n" + "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" + "Remove --offline to attempt a GitHub download instead." + ) + raise typer.Exit(1) # For generic agent, rename placeholder directory to user-specified path if selected_ai == "generic" and ai_commands_dir: @@ -1723,6 +1988,10 @@ def init( else: tracker.skip("git", "--no-git flag") + # Scaffold path has no zip archive to clean up + if not use_github: + tracker.skip("cleanup", "not needed (no download)") + # 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. @@ -1732,6 +2001,7 @@ def init( "ai_commands_dir": ai_commands_dir, "here": here, "preset": preset, + "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), }) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index e09320cc0b..4340b8c2f0 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -872,10 +872,12 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): target = tmp_path / "kiro-alias-proj" with patch("specify_cli.download_and_extract_template") as mock_download, \ + patch("specify_cli.scaffold_from_core_pack", create=True) as mock_scaffold, \ patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_constitution_from_template"), \ patch("specify_cli.is_git_repo", return_value=False), \ patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + mock_scaffold.return_value = True result = runner.invoke( app, [ @@ -891,9 +893,14 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): ) assert result.exit_code == 0 - assert mock_download.called - # download_and_extract_template(project_path, ai_assistant, script_type, ...) + # Without --offline, the download path should be taken. + assert mock_download.called, ( + "Expected download_and_extract_template to be called (default non-offline path)" + ) assert mock_download.call_args.args[1] == "kiro-cli" + assert not mock_scaffold.called, ( + "scaffold_from_core_pack should not be called without --offline" + ) def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py new file mode 100644 index 0000000000..c22db615e8 --- /dev/null +++ b/tests/test_core_pack_scaffold.py @@ -0,0 +1,561 @@ +""" +Validation tests for offline/air-gapped scaffolding (PR #1803). + +For every supported AI agent (except "generic") the scaffold output is verified +against invariants and compared byte-for-byte with the canonical output produced +by create-release-packages.sh. + +Since scaffold_from_core_pack() now invokes the release script at runtime, the +parity test (section 9) runs the script independently and compares the results +to ensure the integration is correct. + +Per-agent invariants verified +────────────────────────────── + • Command files are written to the directory declared in AGENT_CONFIG + • File count matches the number of source templates + • Extension is correct: .toml (TOML agents), .agent.md (copilot), .md (rest) + • No unresolved placeholders remain ({SCRIPT}, {ARGS}, __AGENT__) + • Argument token is correct: {{args}} for TOML agents, $ARGUMENTS for others + • Path rewrites applied: scripts/ → .specify/scripts/ etc. + • TOML files have "description" and "prompt" fields + • Markdown files have parseable YAML frontmatter + • Copilot: companion speckit.*.prompt.md files are generated in prompts/ + • .specify/scripts/ contains at least one script file + • .specify/templates/ contains at least one template file + +Parity invariant +──────────────── + Every file produced by scaffold_from_core_pack() must be byte-for-byte + identical to the same file in the ZIP produced by the release script. +""" + +import os +import re +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest +import yaml + +import specify_cli +from specify_cli import ( + AGENT_CONFIG, + _TOML_AGENTS, + _locate_core_pack, + scaffold_from_core_pack, +) + +_REPO_ROOT = Path(__file__).parent.parent +_RELEASE_SCRIPT = _REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh" + + +def _find_bash() -> str | None: + """Return the path to a usable bash on this machine, or None.""" + candidates = [ + "/opt/homebrew/bin/bash", + "/usr/local/bin/bash", + "/bin/bash", + "/usr/bin/bash", + ] + for candidate in candidates: + try: + result = subprocess.run( + [candidate, "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return candidate + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + return None + + +def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Path) -> Path: + """Run create-release-packages.sh for *agent*/*script_type* and return the + path to the generated ZIP. *output_dir* receives the build artifacts so + the repo working tree stays clean.""" + env = os.environ.copy() + env["AGENTS"] = agent + env["SCRIPTS"] = script_type + env["GENRELEASES_DIR"] = str(output_dir) + + result = subprocess.run( + [bash, str(_RELEASE_SCRIPT), "v0.0.0"], + capture_output=True, text=True, + cwd=str(_REPO_ROOT), + env=env, + ) + + zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip" + zip_path = output_dir / zip_pattern + if not zip_path.exists(): + pytest.fail( + f"Release script did not produce expected ZIP: {zip_path}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return zip_path + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Number of source command templates (one per .md file in templates/commands/) +_SOURCE_TEMPLATES: list[str] = [] + + +def _commands_dir() -> Path: + """Return the command templates directory (source-checkout or core_pack).""" + core = _locate_core_pack() + if core and (core / "commands").is_dir(): + return core / "commands" + # Source-checkout fallback + repo_root = Path(__file__).parent.parent + return repo_root / "templates" / "commands" + + +def _get_source_template_stems() -> list[str]: + """Return the stems of source command template files (e.g. ['specify', 'plan', ...]).""" + return sorted(p.stem for p in _commands_dir().glob("*.md")) + + +def _expected_cmd_dir(project_path: Path, agent: str) -> Path: + """Return the expected command-files directory for a given agent.""" + cfg = AGENT_CONFIG[agent] + folder = (cfg.get("folder") or "").rstrip("/") + subdir = cfg.get("commands_subdir", "commands") + if folder: + return project_path / folder / subdir + return project_path / ".speckit" / subdir + + +def _expected_ext(agent: str) -> str: + if agent in _TOML_AGENTS: + return "toml" + if agent == "copilot": + return "agent.md" + if agent == "kimi": + return "SKILL.md" # Kimi uses skills//SKILL.md + return "md" + + +def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]: + """List generated command files, handling Kimi's directory-per-skill layout.""" + if agent == "kimi": + # Kimi: .kimi/skills/speckit.*/SKILL.md + return sorted(cmd_dir.glob("speckit.*/SKILL.md")) + ext = _expected_ext(agent) + return sorted(cmd_dir.glob(f"speckit.*.{ext}")) + + +def _collect_relative_files(root: Path) -> dict[str, bytes]: + """Walk *root* and return {relative_posix_path: file_bytes}.""" + result: dict[str, bytes] = {} + for p in root.rglob("*"): + if p.is_file(): + result[p.relative_to(root).as_posix()] = p.read_bytes() + return result + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def source_template_stems() -> list[str]: + return _get_source_template_stems() + + +@pytest.fixture +def bundled_project(tmp_path): + """Run scaffold_from_core_pack for each test; caller picks agent.""" + return tmp_path + + +# --------------------------------------------------------------------------- +# Parametrize over all agents except "generic" +# --------------------------------------------------------------------------- + +_TESTABLE_AGENTS = [a for a in AGENT_CONFIG if a != "generic"] + + +# --------------------------------------------------------------------------- +# 1. Bundled scaffold — directory structure +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_creates_specify_scripts(tmp_path, agent): + """scaffold_from_core_pack copies at least one script into .specify/scripts/.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" + + scripts_dir = project / ".specify" / "scripts" / "bash" + assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'" + assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'" + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_creates_specify_templates(tmp_path, agent): + """scaffold_from_core_pack copies at least one page template into .specify/templates/.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + tpl_dir = project / ".specify" / "templates" + assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'" + assert any(tpl_dir.iterdir()), ".specify/templates/ is empty" + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_dir_location(tmp_path, agent, source_template_stems): + """Command files land in the directory declared by AGENT_CONFIG.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + cmd_dir = _expected_cmd_dir(project, agent) + assert cmd_dir.is_dir(), ( + f"Command dir '{cmd_dir.relative_to(project)}' not created for agent '{agent}'" + ) + + +# --------------------------------------------------------------------------- +# 2. Bundled scaffold — file count +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_count(tmp_path, agent, source_template_stems): + """One command file is generated per source template for every agent.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + assert len(generated) == len(source_template_stems), ( + f"Agent '{agent}': expected {len(source_template_stems)} command files " + f"({_expected_ext(agent)}), found {len(generated)}. Dir: {list(cmd_dir.iterdir())}" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_names(tmp_path, agent, source_template_stems): + """Each source template stem maps to a corresponding speckit.. file.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + cmd_dir = _expected_cmd_dir(project, agent) + for stem in source_template_stems: + if agent == "kimi": + expected = cmd_dir / f"speckit.{stem}" / "SKILL.md" + else: + ext = _expected_ext(agent) + expected = cmd_dir / f"speckit.{stem}.{ext}" + assert expected.is_file(), ( + f"Agent '{agent}': expected file '{expected.name}' not found in '{cmd_dir}'" + ) + + +# --------------------------------------------------------------------------- +# 3. Bundled scaffold — content invariants +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_script_placeholder(tmp_path, agent): + """{SCRIPT} must not appear in any generated command file.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, ( + f"Unresolved {{SCRIPT}} in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_agent_placeholder(tmp_path, agent): + """__AGENT__ must not appear in any generated command file.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "__AGENT__" not in content, ( + f"Unresolved __AGENT__ in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_args_placeholder(tmp_path, agent): + """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "{ARGS}" not in content, ( + f"Unresolved {{ARGS}} in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +# Build a set of template stems that actually contain {ARGS} in their source. +_TEMPLATES_WITH_ARGS: frozenset[str] = frozenset( + p.stem + for p in _commands_dir().glob("*.md") + if "{ARGS}" in p.read_text(encoding="utf-8") +) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_argument_token_format(tmp_path, agent): + """For templates that carry an {ARGS} token: + - TOML agents must emit {{args}} + - Markdown agents must emit $ARGUMENTS + Templates without {ARGS} (e.g. implement, plan) are skipped. + """ + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + + for f in _list_command_files(cmd_dir, agent): + # Recover the stem from the file path + if agent == "kimi": + stem = f.parent.name.removeprefix("speckit.") + else: + ext = _expected_ext(agent) + stem = f.name.removeprefix("speckit.").removesuffix(f".{ext}") + if stem not in _TEMPLATES_WITH_ARGS: + continue # this template has no argument token + + content = f.read_text(encoding="utf-8") + if agent in _TOML_AGENTS: + assert "{{args}}" in content, ( + f"TOML agent '{agent}': expected '{{{{args}}}}' in '{f.name}'" + ) + else: + assert "$ARGUMENTS" in content, ( + f"Markdown agent '{agent}': expected '$ARGUMENTS' in '{f.name}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_path_rewrites_applied(tmp_path, agent): + """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if not f.is_file(): + continue + content = f.read_text(encoding="utf-8") + # Should not contain bare (non-.specify/) script paths + assert not re.search(r'(?= 3, f"Incomplete frontmatter in '{f.name}'" + fm = yaml.safe_load(parts[1]) + assert fm is not None, f"Empty frontmatter in '{f.name}'" + assert "description" in fm, ( + f"'description' key missing from frontmatter in '{f.name}' for agent '{agent}'" + ) + + +# --------------------------------------------------------------------------- +# 6. Copilot-specific: companion .prompt.md files +# --------------------------------------------------------------------------- + +def test_copilot_companion_prompt_files(tmp_path, source_template_stems): + """Copilot: a speckit..prompt.md companion is created for every .agent.md file.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, "copilot", "sh") + assert ok + + prompts_dir = project / ".github" / "prompts" + assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot" + + for stem in source_template_stems: + prompt_file = prompts_dir / f"speckit.{stem}.prompt.md" + assert prompt_file.is_file(), ( + f"Companion prompt file '{prompt_file.name}' missing for copilot" + ) + + +def test_copilot_prompt_file_content(tmp_path, source_template_stems): + """Copilot companion .prompt.md files must reference their parent .agent.md.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, "copilot", "sh") + + prompts_dir = project / ".github" / "prompts" + for stem in source_template_stems: + f = prompts_dir / f"speckit.{stem}.prompt.md" + content = f.read_text(encoding="utf-8") + assert f"agent: speckit.{stem}" in content, ( + f"Companion '{f.name}' does not reference 'speckit.{stem}'" + ) + + +# --------------------------------------------------------------------------- +# 7. PowerShell script variant +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_powershell_variant(tmp_path, agent, source_template_stems): + """scaffold_from_core_pack with script_type='ps' creates correct files.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "ps") + assert ok + + scripts_dir = project / ".specify" / "scripts" / "powershell" + assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'" + assert any(scripts_dir.iterdir()), ".specify/scripts/powershell/ is empty" + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + assert len(generated) == len(source_template_stems) + + +# --------------------------------------------------------------------------- +# 8. Parity: bundled vs. real create-release-packages.sh ZIP +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_parity_bundled_vs_release_script(tmp_path, agent): + """scaffold_from_core_pack() file tree is identical to the ZIP produced by + create-release-packages.sh for every agent (sh variant). + + This is the true end-to-end parity check: the Python offline path must + produce exactly the same artifacts as the canonical shell release script. + + Each agent is tested independently: generate the release ZIP, generate + the bundled scaffold, compare. This avoids cross-agent interference + from the release script's rm -rf at startup. + """ + bash = _find_bash() + if bash is None: + pytest.skip("bash required to run create-release-packages.sh") + + # --- Release script path --- + gen_dir = tmp_path / "genreleases" + gen_dir.mkdir() + zip_path = _run_release_script(agent, "sh", bash, gen_dir) + script_dir = tmp_path / "extracted" + script_dir.mkdir() + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(script_dir) + + # --- Bundled path --- + bundled_dir = tmp_path / "bundled" + ok = scaffold_from_core_pack(bundled_dir, agent, "sh") + assert ok + + bundled_tree = _collect_relative_files(bundled_dir) + script_tree = _collect_relative_files(script_dir) + + only_bundled = set(bundled_tree) - set(script_tree) + only_script = set(script_tree) - set(bundled_tree) + + assert not only_bundled, ( + f"Agent '{agent}': files only in bundled output (not in release ZIP):\n " + + "\n ".join(sorted(only_bundled)) + ) + assert not only_script, ( + f"Agent '{agent}': files only in release ZIP (not in bundled output):\n " + + "\n ".join(sorted(only_script)) + ) + + for name in bundled_tree: + assert bundled_tree[name] == script_tree[name], ( + f"Agent '{agent}': file '{name}' content differs between " + f"bundled output and release script ZIP" + ) + + +# --------------------------------------------------------------------------- +# Section 10 – pyproject.toml force-include covers all template files +# --------------------------------------------------------------------------- + +def test_pyproject_force_include_covers_all_templates(): + """Every file in templates/ (excluding commands/) must be listed in + pyproject.toml's [tool.hatch.build.targets.wheel.force-include] section. + + This prevents new template files from being silently omitted from the + wheel, which would break ``specify init --offline``. + """ + templates_dir = _REPO_ROOT / "templates" + # Collect all files directly in templates/ (not in subdirectories like commands/) + repo_template_files = sorted( + f.name for f in templates_dir.iterdir() + if f.is_file() + ) + assert repo_template_files, "Expected at least one template file in templates/" + + pyproject_path = _REPO_ROOT / "pyproject.toml" + pyproject_text = pyproject_path.read_text() + + missing = [ + name for name in repo_template_files + if f"templates/{name}" not in pyproject_text + ] + assert not missing, ( + f"Template files not listed in pyproject.toml force-include " + f"(offline scaffolding will miss them):\n " + + "\n ".join(missing) + )