From bf3398042624057945b9e563977468c05e7fc079 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:46:48 -0500 Subject: [PATCH] feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): embed core pack in wheel + offline-first init (#1711, #1752) Bundle templates, commands, and scripts inside the specify-cli wheel so that `specify init` works without any network access by default. Changes: - pyproject.toml: add hatchling force-include for core_pack assets; bump version to 0.2.1 - __init__.py: add _locate_core_pack(), _generate_agent_commands() (Python port of generate_commands() shell function), and scaffold_from_core_pack(); modify init() to scaffold from bundled assets by default; add --from-github flag to opt back in to the GitHub download path - release.yml: build wheel during CI release job - create-github-release.sh: attach .whl as a release asset - docs/installation.md: add Enterprise/Air-Gapped Installation section - README.md: add Option 3 enterprise install with accurate offline story Closes #1711 Addresses #1752 * fix(tests): update kiro alias test for offline-first scaffold path * feat(cli): invoke bundled release script at runtime for offline scaffold - Embed release scripts (bash + PowerShell) in wheel via pyproject.toml - Replace Python _generate_agent_commands() with subprocess invocation of the canonical create-release-packages.sh, guaranteeing byte-for-byte parity between 'specify init --offline' and GitHub release ZIPs - Fix macOS bash 3.2 compat in release script: replace cp --parents, local -n (nameref), and mapfile with POSIX-safe alternatives - Fix _TOML_AGENTS: remove qwen (uses markdown per release script) - Rename --from-github to --offline (opt-in to bundled assets) - Add _locate_release_script() for cross-platform script discovery - Update tests: remove bash 4+/GNU coreutils requirements, handle Kimi directory-per-skill layout, 576 tests passing - Update CHANGELOG and docs/installation.md * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(offline): error out if --offline fails instead of falling back to network - _locate_core_pack() docstring now accurately describes that it only finds wheel-bundled core_pack/; source-checkout fallback lives in callers - init() --offline + no bundled assets now exits with a clear error (previously printed a warning and silently fell back to GitHub download) - init() scaffold failure under --offline now exits with an error instead of retrying via download_and_extract_template Addresses reviewer comment: https://github.com/github/spec-kit/pull/1803 * fix(offline): address PR review comments - fix(shell): harden validate_subset against glob injection in case patterns - fix(shell): make GENRELEASES_DIR overridable via env var for test isolation - fix(cli): probe pwsh then powershell on Windows instead of hardcoding pwsh - fix(cli): remove unreachable fallback branch when --offline fails - fix(cli): improve --offline error message with common failure causes - fix(release): move wheel build step after create-release-packages.sh - fix(docs): add --offline to installation.md air-gapped example - fix(tests): remove unused genreleases_dir param from _run_release_script - fix(tests): rewrite parity test to run one agent at a time with isolated temp dirs, preventing cross-agent interference from rm -rf * fix(offline): address second round of review comments - fix(shell): replace case-pattern membership with explicit loop + == check for unambiguous glob-safety in validate_subset() - fix(cli): require pwsh (PowerShell 7) only; drop powershell (PS5) fallback since the bundled script uses #requires -Version 7.0 - fix(cli): add bash and zip preflight checks in scaffold_from_core_pack() with clear error messages if either is missing - fix(build): list individual template files in pyproject.toml force-include to avoid duplicating templates/commands/ in the wheel * fix(offline): address third round of review comments - Add 120s timeout to subprocess.run in scaffold_from_core_pack to prevent indefinite hangs during offline scaffolding - Add test_pyproject_force_include_covers_all_templates to catch missing template files in wheel bundling - Tighten kiro alias test to assert specific scaffold path (download vs offline) * fix(offline): address Copilot review round 4 - fix(offline): use handle_vscode_settings() merge for --here --offline to prevent data loss on existing .vscode/settings.json - fix(release): glob wheel filename in create-github-release.sh instead of hardcoding version, preventing upload failures on version mismatch - docs(release): add comment noting pyproject.toml version is synced by release-trigger.yml before the tag is pushed * fix(offline): address review round 5 + offline bundle ZIP - fix(offline): pwsh-only, no powershell.exe fallback; clarify error message - fix(offline): tighten _has_bundled to check scripts dir for source checkouts - feat(release): build specify-bundle-v*.zip with all deps at release time - feat(release): attach offline bundle ZIP to GitHub release assets - docs: simplify air-gapped install to single ZIP download from releases - docs: add Windows PowerShell 7+ (pwsh) requirement note * fix(tests): session-scoped scaffold cache + timeout + dead code removal - Add timeout=300 and returncode check to _run_release_script() to fail fast with clear output on script hangs or failures - Remove unused import specify_cli, _SOURCE_TEMPLATES, bundled_project fixture - Add session-scoped scaffolded_sh/scaffolded_ps fixtures that scaffold once per agent and reuse the output directory across all invariant tests - Reduces test_core_pack_scaffold runtime from ~175s to ~51s (3.4x faster) - Parity tests still scaffold independently for isolation * fix(offline): remove wheel from release, update air-gapped docs to use pip download * fix(tests): handle codex skills layout and iflow agent in scaffold tests Codex now uses create_skills() with hyphenated separator (speckit-plan/SKILL.md) instead of generate_commands(). Update _SKILL_AGENTS, _expected_ext, and _list_command_files to handle both codex ('-') and kimi ('.') skill agents. Also picks up iflow as a new testable agent automatically via AGENT_CONFIG. * fix(offline): require wheel core_pack for --offline, remove source-checkout fallback --offline now strictly requires _locate_core_pack() to find the wheel's bundled core_pack/ directory. Source-checkout fallbacks are no longer accepted at the init() level — if core_pack/ is missing, the CLI errors out with a clear message pointing to the installation docs. scaffold_from_core_pack() retains its internal source-checkout fallbacks so parity tests can call it directly from a source checkout. * fix(offline): remove stale [Unreleased] CHANGELOG section, scope httpx.Client to download path - Remove entire [Unreleased] section — CHANGELOG is auto-generated at release - Move httpx.Client into use_github branch with context manager so --offline path doesn't allocate an unused network client * fix(offline): remove dead --from-github flag, fix typer.Exit handling, add page templates validation - Remove unused --from-github CLI option and docstring example - Add (typer.Exit, SystemExit) re-raise before broad except Exception to prevent duplicate error panel on offline scaffold failure - Validate page templates directory exists in scaffold_from_core_pack() to fail fast on incomplete wheel installs - Fix ruff lint: remove unused shutil import, remove f-prefix on strings without placeholders in test_core_pack_scaffold.py * docs(offline): add v0.6.0 deprecation notice with rationale - Help text: note bundled assets become default in v0.6.0 - Docstring: explain why GitHub download is being retired (no network dependency, no proxy/firewall issues, guaranteed version match) - Runtime nudge: when bundled assets are available but user takes the GitHub download path, suggest --offline with rationale - docs/installation.md: add deprecation notice with full rationale * fix(offline): allow --offline in source checkouts, fix CHANGELOG truncation - Simplify use_github logic: use_github = not offline (let scaffold_from_core_pack handle fallback to source-checkout paths) - Remove hard-fail when core_pack/ is absent — scaffold_from_core_pack already falls back to repo-root templates/scripts/commands - Fix truncated 'skill…' → 'skills' in CHANGELOG.md * fix(offline): sandbox GENRELEASES_DIR and clean up on failure - Pin GENRELEASES_DIR to temp dir in scaffold_from_core_pack() so a user-exported value cannot redirect output or cause rm -rf outside the sandbox - Clean up partial project directory on --offline scaffold failure (same behavior as the GitHub-download failure path) * fix(tests): use shutil.which for bash discovery, add ps parity tests - _find_bash() now tries shutil.which('bash') first so non-standard install locations (Nix, custom CI images) are found - Parametrize parity test over both 'sh' and 'ps' script types to ensure PowerShell variant stays byte-for-byte identical to release script output (353 scaffold tests, 810 total) * fix(tests): parse pyproject.toml with tomllib, remove unused fixture - Use tomllib to parse force-include keys from the actual TOML table instead of raw substring search (avoids false positives) - Remove unused source_template_stems fixture from test_scaffold_command_dir_location * fix: guard GENRELEASES_DIR against unsafe values, update docstring - Add safety check in create-release-packages.sh: reject empty, '/', '.', '..' values for GENRELEASES_DIR before rm -rf - Strip trailing slash to avoid path surprises - Update scaffold_from_core_pack() docstring to accurately describe all failure modes (not just 'assets not found') * fix: harden GENRELEASES_DIR guard, cache parity tests, safe iterdir - Reject '..' path segments in GENRELEASES_DIR to prevent traversal - Session-cache both scaffold and release-script results in parity tests — runtime drops from ~74s to ~45s (40% faster) - Guard cmd_dir.iterdir() in assertion message against missing dirs * fix(tests): exclude YAML frontmatter source metadata from path rewrite check The codex and kimi SKILL.md files have 'source: templates/commands/...' in their YAML frontmatter — this is provenance metadata, not a runtime path that needs rewriting. Strip frontmatter before checking for bare scripts/ and templates/ paths. * fix(offline): surface scaffold failure detail in error output When --offline scaffold fails, look up the tracker's 'scaffold' step detail and print it alongside the generic error message so users see the specific root cause (e.g. missing zip/pwsh, script stderr). --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/create-release-packages.sh | 49 +- CHANGELOG.md | 65 +- README.md | 20 +- docs/installation.md | 72 +- docs/upgrade.md | 10 +- pyproject.toml | 17 + src/specify_cli/__init__.py | 329 +++++++++- tests/test_ai_skills.py | 11 +- tests/test_core_pack_scaffold.py | 613 ++++++++++++++++++ 9 files changed, 1082 insertions(+), 104 deletions(-) create mode 100644 tests/test_core_pack_scaffold.py diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index c2bd792f..d07e4a2d 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -26,9 +26,27 @@ 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}" + +# Guard against unsafe GENRELEASES_DIR values before cleaning +if [[ -z "$GENRELEASES_DIR" ]]; then + echo "GENRELEASES_DIR must not be empty" >&2 + exit 1 +fi +case "$GENRELEASES_DIR" in + '/'|'.'|'..') + echo "Refusing to use unsafe GENRELEASES_DIR value: $GENRELEASES_DIR" >&2 + exit 1 + ;; +esac +if [[ "$GENRELEASES_DIR" == *".."* ]]; then + echo "Refusing to use GENRELEASES_DIR containing '..' path segments: $GENRELEASES_DIR" >&2 + exit 1 +fi + mkdir -p "$GENRELEASES_DIR" -rm -rf "$GENRELEASES_DIR"/* || true +rm -rf "${GENRELEASES_DIR%/}/"* || true rewrite_paths() { sed -E \ @@ -228,7 +246,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) @@ -325,34 +343,35 @@ build_variant() { ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow 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 43f93b6c..efc967fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +- chore: bump version to 0.3.2 - Add conduct extension to community catalog (#1908) - feat(extensions): add verify-tasks extension to community catalog (#1871) - feat(presets): add enable/disable toggle and update semantics (#1891) @@ -20,11 +21,11 @@ - Feature/spec kit add pi coding agent pullrequest (#1853) - feat: register spec-kit-learn extension (#1883) - ## [0.3.1] - 2026-03-17 ### Changed +- chore: bump version to 0.3.1 - docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878) - fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867) - feat: add Trae IDE support as a new agent (#1817) @@ -40,52 +41,21 @@ - feat(extensions): add Archive and Reconcile extensions to community catalog (#1844) - feat: Add DocGuard CDD enforcement extension to community catalog (#1838) - ## [0.3.0] - 2026-03-13 ### Changed -- No changes have been documented for this release yet. - - -- make c ignores consistent with c++ (#1747) -- chore: bump version to 0.1.13 (#1746) -- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690) -- feat: add verify extension to community catalog (#1726) -- Add Retrospective Extension to community catalog README table (#1741) -- fix(scripts): add empty description validation and branch checkout error handling (#1559) -- fix: correct Copilot extension command registration (#1724) -- fix(implement): remove Makefile from C ignore patterns (#1558) -- Add sync extension to community catalog (#1728) -- fix(checklist): clarify file handling behavior for append vs create (#1556) -- fix(clarify): correct conflicting question limit from 10 to 5 (#1557) -- chore: bump version to 0.1.12 (#1737) -- fix: use RELEASE_PAT so tag push triggers release workflow (#1736) -- fix: release-trigger uses release branch + PR instead of direct push to main (#1733) -- fix: Split release process to sync pyproject.toml version with git tags (#1732) - - -## [Unreleased] - -### Added - -- 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 -- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py` -- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info` -- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management -- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system -- `--preset` option for `specify init` to install presets during initialization -- Priority-based preset resolution: presets with lower priority number win (`--priority` flag) -- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts -- Template resolution priority stack: overrides → presets → extensions → core -- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`) -- Preset scaffold directory (`presets/scaffold/`) -- Scripts updated to use template resolution instead of hardcoded paths -- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init -- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations -- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) +- chore: bump version to 0.3.0 +- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787) +- fix: match 'Last updated' timestamp with or without bold markers (#1836) +- Add specify doctor command for project health diagnostics (#1828) +- fix: harden bash scripts against shell injection and improve robustness (#1809) +- fix: clean up command templates (specify, analyze) (#1810) +- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730) +- fix(cli): deprecate explicit command support for agy (#1798) (#1808) +- Add /selftest.extension core extension to test other extensions (#1758) +- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776) +- Add Java brownfield walkthrough to community walkthroughs (#1820) ## [0.2.1] - 2026-03-11 @@ -312,12 +282,3 @@ - Add pytest and Python linting (ruff) to CI (#1637) - feat: add pull request template for better contribution guidelines (#1634) - -## [0.0.99] - 2026-02-19 - -- Feat/ai skills (#1632) - -## [0.0.98] - 2026-02-19 - -- chore(deps): bump actions/stale from 9 to 10 (#1623) -- feat: add dependabot configuration for pip and GitHub Actions updates (#1622) diff --git a/README.md b/README.md index 7f217548..1d93ed4a 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,13 @@ Choose your preferred installation method: #### Option 1: Persistent Installation (Recommended) -Install once and use everywhere: +Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): ```bash +# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag) +uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z + +# Or install latest from main (may include unreleased changes) uv tool install specify-cli --from git+https://github.com/github/spec-kit.git ``` @@ -73,7 +77,7 @@ specify check To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade: ```bash -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z ``` #### Option 2: One-time Usage @@ -81,13 +85,13 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki Run directly without installing: ```bash -# Create new project -uvx --from git+https://github.com/github/spec-kit.git specify init +# Create new project (pinned to a stable release — replace vX.Y.Z with the latest tag) +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init # Or initialize in existing project -uvx --from git+https://github.com/github/spec-kit.git specify init . --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai claude # or -uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude ``` **Benefits of persistent installation:** @@ -97,6 +101,10 @@ 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 access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) guide for step-by-step instructions on using `pip download` to create portable, OS-specific wheel bundles on a connected machine. + ### 2. Establish project principles Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. diff --git a/docs/installation.md b/docs/installation.md index 7cb7b1ff..5d560b6e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,18 +12,22 @@ ### Initialize a New Project -The easiest way to get started is to initialize a new project: +The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): ```bash +# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag) +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init + +# Or install latest from main (may include unreleased changes) uvx --from git+https://github.com/github/spec-kit.git specify init ``` Or initialize in the current directory: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init . +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . # or use the --here flag -uvx --from git+https://github.com/github/spec-kit.git specify init --here +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here ``` ### Specify AI Agent @@ -31,11 +35,11 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here You can proactively specify your AI agent during initialization: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude -uvx --from git+https://github.com/github/spec-kit.git specify init --ai gemini -uvx --from git+https://github.com/github/spec-kit.git specify init --ai copilot -uvx --from git+https://github.com/github/spec-kit.git specify init --ai codebuddy -uvx --from git+https://github.com/github/spec-kit.git specify init --ai pi +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai gemini +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai codebuddy +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai pi ``` ### Specify Script Type (Shell vs PowerShell) @@ -51,8 +55,8 @@ Auto behavior: Force a specific script type: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --script sh -uvx --from git+https://github.com/github/spec-kit.git specify init --script ps +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --script sh +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --script ps ``` ### Ignore Agent Tools Check @@ -60,7 +64,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --ai claude --ignore-agent-tools +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude --ignore-agent-tools ``` ## Verification @@ -75,6 +79,52 @@ 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 create a portable wheel bundle on a connected machine and transfer it to the air-gapped target. + +**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)** + +```bash +# Clone the repository +git clone https://github.com/github/spec-kit.git +cd spec-kit + +# Build the wheel +pip install build +python -m build --wheel --outdir dist/ + +# Download the wheel and all its runtime dependencies +pip download -d dist/ dist/specify_cli-*.whl +``` + +> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version. + +**Step 2: Transfer the `dist/` directory to the air-gapped machine** + +Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method. + +**Step 3: Install on the air-gapped machine** + +```bash +pip install --no-index --find-links=./dist specify-cli +``` + +**Step 4: Initialize a project (no network required)** + +```bash +# Initialize a project — no GitHub access needed +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. + +> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets 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. No action will be needed — `specify init` will simply work without network access out of the box. + +> **Note:** Python 3.11+ is required. + +> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell. + ### Git Credential Manager on Linux If you're having issues with Git authentication on Linux, you can install Git Credential Manager: diff --git a/docs/upgrade.md b/docs/upgrade.md index 74bf6192..cd5cc124 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -8,7 +8,7 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| -| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git` | Get latest CLI features without touching project files | +| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | | **Project Files** | `specify init --here --force --ai ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | @@ -20,16 +20,18 @@ The CLI tool (`specify`) is separate from your project files. Upgrade it to get ### If you installed with `uv tool install` +Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag): + ```bash -uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git +uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z ``` ### If you use one-shot `uvx` commands -No upgrade needed—`uvx` always fetches the latest version. Just run your commands as normal: +Specify the desired release tag: ```bash -uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot ``` ### Verify the upgrade diff --git a/pyproject.toml b/pyproject.toml index 8c40ff37..f3ca76dd 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 ae64d19c..d2bf63ee 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -315,6 +315,9 @@ AI_ASSISTANT_ALIASES = { "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.""" @@ -1095,6 +1098,241 @@ 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+ (pwsh), " + "not Windows PowerShell 5.x (powershell.exe). " + "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. Returns False if offline scaffolding failed for + any reason, including missing or unreadable assets, missing required tools + (bash, pwsh, zip), release-script failure or timeout, or unexpected runtime + exceptions. When ``--offline`` is active the caller should treat False as + a hard error rather than falling back to a network download. + """ + # --- 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" + if not templates_dir.is_dir(): + if tracker: + tracker.error("scaffold", "page templates not found") + return False + + # 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() + # Pin GENRELEASES_DIR inside the temp dir so a user-exported + # value cannot redirect output or cause rm -rf outside the sandbox. + env["GENRELEASES_DIR"] = str(tmp / ".genreleases") + 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) + # When scaffolding into an existing directory (--here), + # use the same merge semantics as the GitHub-download path. + if is_current_dir and dest.name == "settings.json" and dest.parent.name == ".vscode": + handle_vscode_settings(item, dest, rel, verbose=False, tracker=tracker) + else: + 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": @@ -1487,20 +1725,31 @@ 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). Bundled assets will become the default in v0.6.0 and this flag will be removed."), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), ): """ - 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). + + 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. + 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 @@ -1517,6 +1766,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 """ @@ -1689,12 +1939,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. + # When --offline is set, scaffold_from_core_pack() will try the wheel's + # core_pack/ first, then fall back to source-checkout paths. If neither + # location has the required assets it returns False and we error out. + _core = _locate_core_pack() + + use_github = not offline + + if use_github and _core is not None: + console.print( + "[yellow]Note:[/yellow] Bundled assets are available in this install. " + "Use [bold]--offline[/bold] to skip the GitHub download — faster, " + "no network required, and guaranteed version match.\n" + "This will become the default in v0.6.0." + ) + + 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"), ]: @@ -1716,9 +1991,28 @@ def init( try: verify = not skip_tls 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: + with httpx.Client(verify=local_ssl_context) as local_client: + 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" + "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" + "Remove --offline to attempt a GitHub download instead." + ) + # Surface the specific failure reason from the tracker + for step in tracker.steps: + if step["key"] == "scaffold" and step["detail"]: + console.print(f"[red]Detail:[/red] {step['detail']}") + break + # Clean up partial project directory (same as the GitHub-download failure path) + if not here and project_path.exists(): + shutil.rmtree(project_path) + raise typer.Exit(1) # For generic agent, rename placeholder directory to user-specified path if selected_ai == "generic" and ai_commands_dir: @@ -1799,6 +2093,7 @@ def init( "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, + "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), }) @@ -1834,7 +2129,13 @@ def init( except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") + # Scaffold path has no zip archive to clean up + if not use_github: + tracker.skip("cleanup", "not needed (no download)") + tracker.complete("final", "project ready") + except (typer.Exit, SystemExit): + raise except Exception as e: tracker.error("final", str(e)) console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 08017430..0bccd48d 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -1050,10 +1050,12 @@ class TestCliValidation: 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, [ @@ -1069,9 +1071,14 @@ class TestCliValidation: ) 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 00000000..92848bb1 --- /dev/null +++ b/tests/test_core_pack_scaffold.py @@ -0,0 +1,613 @@ +""" +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 tomllib +import zipfile +from pathlib import Path + +import pytest +import yaml + +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.""" + # Prefer PATH lookup so non-standard install locations (Nix, CI) are found. + on_path = shutil.which("bash") + if on_path: + return on_path + 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, + timeout=300, + ) + + if result.returncode != 0: + pytest.fail( + f"Release script failed with exit code {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + 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/) + + +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 + + +# Agents whose commands are laid out as //SKILL.md. +# Maps agent -> separator used in skill directory names. +_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "."} + + +def _expected_ext(agent: str) -> str: + if agent in _TOML_AGENTS: + return "toml" + if agent == "copilot": + return "agent.md" + if agent in _SKILL_AGENTS: + return "SKILL.md" + return "md" + + +def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]: + """List generated command files, handling skills-based directory layouts.""" + if agent in _SKILL_AGENTS: + sep = _SKILL_AGENTS[agent] + return sorted(cmd_dir.glob(f"speckit{sep}*/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(scope="session") +def scaffolded_sh(tmp_path_factory): + """Session-scoped cache: scaffold once per agent with script_type='sh'.""" + cache = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp(f"scaffold_sh_{agent}") + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" + cache[agent] = project + return cache[agent] + return _get + + +@pytest.fixture(scope="session") +def scaffolded_ps(tmp_path_factory): + """Session-scoped cache: scaffold once per agent with script_type='ps'.""" + cache = {} + def _get(agent: str) -> Path: + if agent not in cache: + project = tmp_path_factory.mktemp(f"scaffold_ps_{agent}") + ok = scaffold_from_core_pack(project, agent, "ps") + assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" + cache[agent] = project + return cache[agent] + return _get + + +# --------------------------------------------------------------------------- +# 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(agent, scaffolded_sh): + """scaffold_from_core_pack copies at least one script into .specify/scripts/.""" + project = scaffolded_sh(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(agent, scaffolded_sh): + """scaffold_from_core_pack copies at least one page template into .specify/templates/.""" + project = scaffolded_sh(agent) + + 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(agent, scaffolded_sh): + """Command files land in the directory declared by AGENT_CONFIG.""" + project = scaffolded_sh(agent) + + 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(agent, scaffolded_sh, source_template_stems): + """One command file is generated per source template for every agent.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + + if cmd_dir.is_dir(): + dir_listing = list(cmd_dir.iterdir()) + else: + dir_listing = f"" + + 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: {dir_listing}" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems): + """Each source template stem maps to a corresponding speckit.. file.""" + project = scaffolded_sh(agent) + + cmd_dir = _expected_cmd_dir(project, agent) + for stem in source_template_stems: + if agent in _SKILL_AGENTS: + sep = _SKILL_AGENTS[agent] + expected = cmd_dir / f"speckit{sep}{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(agent, scaffolded_sh): + """{SCRIPT} must not appear in any generated command file.""" + project = scaffolded_sh(agent) + + 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(agent, scaffolded_sh): + """__AGENT__ must not appear in any generated command file.""" + project = scaffolded_sh(agent) + + 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(agent, scaffolded_sh): + """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" + project = scaffolded_sh(agent) + + 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(agent, scaffolded_sh): + """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 = scaffolded_sh(agent) + + 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 in _SKILL_AGENTS: + sep = _SKILL_AGENTS[agent] + stem = f.parent.name.removeprefix(f"speckit{sep}") + 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(agent, scaffolded_sh): + """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants. + + YAML frontmatter 'source:' metadata fields are excluded — they reference + the original template path for provenance, not a runtime path. + """ + project = scaffolded_sh(agent) + + 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") + + # Strip YAML frontmatter before checking — source: metadata is not a runtime path + body = content + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + body = parts[2] + + # 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(scaffolded_sh, source_template_stems): + """Copilot: a speckit..prompt.md companion is created for every .agent.md file.""" + project = scaffolded_sh("copilot") + + 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(scaffolded_sh, source_template_stems): + """Copilot companion .prompt.md files must reference their parent .agent.md.""" + project = scaffolded_sh("copilot") + + 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(agent, scaffolded_ps, source_template_stems): + """scaffold_from_core_pack with script_type='ps' creates correct files.""" + project = scaffolded_ps(agent) + + 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.fixture(scope="session") +def release_script_trees(tmp_path_factory): + """Session-scoped cache: run release script once per (agent, script_type).""" + cache: dict[tuple[str, str], dict[str, bytes]] = {} + bash = _find_bash() + + def _get(agent: str, script_type: str) -> dict[str, bytes] | None: + if bash is None: + return None + key = (agent, script_type) + if key not in cache: + tmp = tmp_path_factory.mktemp(f"release_{agent}_{script_type}") + gen_dir = tmp / "genreleases" + gen_dir.mkdir() + zip_path = _run_release_script(agent, script_type, bash, gen_dir) + extracted = tmp / "extracted" + extracted.mkdir() + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(extracted) + cache[key] = _collect_relative_files(extracted) + return cache[key] + return _get + + +@pytest.mark.parametrize("script_type", ["sh", "ps"]) +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_parity_bundled_vs_release_script(agent, script_type, scaffolded_sh, scaffolded_ps, release_script_trees): + """scaffold_from_core_pack() file tree is identical to the ZIP produced by + create-release-packages.sh for every agent and script type. + + 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. + + Both sides are session-cached: each agent/script_type combination is + scaffolded and release-scripted only once across all tests. + """ + script_tree = release_script_trees(agent, script_type) + if script_tree is None: + pytest.skip("bash required to run create-release-packages.sh") + + # Reuse session-cached scaffold output + if script_type == "sh": + bundled_dir = scaffolded_sh(agent) + else: + bundled_dir = scaffolded_ps(agent) + + bundled_tree = _collect_relative_files(bundled_dir) + + only_bundled = set(bundled_tree) - set(script_tree) + only_script = set(script_tree) - set(bundled_tree) + + assert not only_bundled, ( + f"Agent '{agent}' ({script_type}): files only in bundled output (not in release ZIP):\n " + + "\n ".join(sorted(only_bundled)) + ) + assert not only_script, ( + f"Agent '{agent}' ({script_type}): 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}' ({script_type}): 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" + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + force_include = pyproject.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("force-include", {}) + + missing = [ + name for name in repo_template_files + if f"templates/{name}" not in force_include + ] + assert not missing, ( + "Template files not listed in pyproject.toml force-include " + "(offline scaffolding will miss them):\n " + + "\n ".join(missing) + )