Skip to content

feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803

Open
mnriem wants to merge 16 commits intogithub:mainfrom
mnriem:fix/offline-install-1752
Open

feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803
mnriem wants to merge 16 commits intogithub:mainfrom
mnriem:fix/offline-install-1752

Conversation

@mnriem
Copy link
Collaborator

@mnriem mnriem commented Mar 11, 2026

Summary

Closes #1711
Addresses #1752

Embeds templates, commands, and scripts inside the specify-cli Python wheel so that specify init --offline works with zero network access. A pre-built offline bundle ZIP (wheel + all dependencies) is also published as a GitHub release asset, providing a single-file installation path for enterprise/air-gapped environments.


Problem

Two related air-gapped blockers were addressed together:

  1. Cannot install CLI (Unable to install CLI as wheel access is blocked by corp policy #1752) — PyPI is blocked (403); no .whl was available as a release asset
  2. Cannot run specify init (feat(cli): Embed core template pack in CLI package for air-gapped deployment #1711) — api.github.com is blocked; the init command unconditionally calls download_template_from_github() to fetch a release ZIP

A user who solved problem 1 (offline pip install) would immediately hit problem 2 on first use.


Solution

1. Bundle core assets in the wheel (pyproject.toml)

[tool.hatch.build.targets.wheel.force-include]
"templates"           = "specify_cli/core_pack/templates"
"templates/commands"  = "specify_cli/core_pack/commands"
"scripts/bash"        = "specify_cli/core_pack/scripts/bash"
"scripts/powershell"  = "specify_cli/core_pack/scripts/powershell"

2. New --offline flag for specify init

By default, specify init downloads project files from the latest GitHub release (unchanged behavior). The new --offline flag opts in to using bundled assets instead:

# Default — downloads from GitHub:
specify init my-project --ai claude

# Opt-in offline — uses bundled assets, no network access:
specify init my-project --ai claude --offline

If --offline is specified but bundled assets cannot be found or scaffolding fails, the CLI errors out with a clear message rather than silently falling back to a network download.

3. Offline scaffold via release script (scaffold_from_core_pack)

  • _locate_core_pack() — finds bundled assets (wheel install) or returns None
  • _locate_release_script() — finds the platform-appropriate release script; on Windows requires pwsh (PowerShell 7+); Windows PowerShell 5.x (powershell.exe) is not supported
  • scaffold_from_core_pack() — invokes the bundled create-release-packages.sh (or .ps1) in a temp directory to generate the exact same output as the GitHub release ZIPs, then copies the result to the project directory
  • When scaffolding into an existing directory (--here), .vscode/settings.json is merged via handle_vscode_settings() (JSONC-safe) instead of overwritten

4. Offline dependency bundle (specify-bundle-v*.zip)

The release workflow builds a single ZIP containing the specify-cli wheel and all runtime dependencies (~2.5 MB). Air-gapped users download one file, unzip, and install with pip install --no-index. No pip download step required on the user's side.

5. Wheel published as release asset

  • release.yml: build step added (python -m build --wheel) — runs after create-release-packages.sh so the script's rm -rf doesn't wipe the wheel
  • create-github-release.sh: wheel found via glob (specify_cli-*-py3-none-any.whl) to avoid version mismatch issues; offline bundle ZIP attached conditionally

6. Shell script improvements

  • GENRELEASES_DIR is now overridable via environment variable (defaults to .genreleases), enabling tests to write to temp dirs instead of polluting the repo
  • validate_subset() hardened against glob injection in case patterns

7. Documentation

  • docs/installation.md: new "Enterprise / Air-Gapped Installation" section — download one ZIP, unzip, install
  • README.md: "Option 3: Enterprise / Air-Gapped Installation" with simplified instructions

Acceptance criteria from #1711

Criterion Status
specify init --offline scaffolds from embedded assets with no network calls
All supported agents produce correct command files (Markdown, TOML, agent.md) ✅ (byte-for-byte parity verified for all agents)
Default specify init (no --offline) retains current GitHub-download behavior
pip install specify-cli includes all core templates, commands, and scripts ✅ (force-include in pyproject.toml)
Existing create-release-packages.sh continues to work
Air-gapped deployment works end-to-end ✅ (install from bundle ZIP → specify init --offline)
Offline bundle ZIP published as release asset
--here --offline merges settings.json instead of overwriting
Windows requires pwsh (PowerShell 7+) only

Testing

684 passed

Includes parity tests (one per agent) verifying byte-for-byte equivalence between scaffold_from_core_pack() output and the canonical release script ZIP.

…github#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 github#1711
Addresses github#1752
Copilot AI review requested due to automatic review settings March 11, 2026 14:00
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes specify init work offline by bundling the core template pack (templates/commands/scripts) inside the specify-cli wheel, and updates the release workflow/docs to support air-gapped installation via a published .whl release asset.

Changes:

  • Bundle core templates/commands/scripts into the wheel and scaffold from those assets by default (with --from-github to force network download).
  • Add runtime generation of agent-specific command files (md/toml/agent.md + Copilot prompt companions).
  • Publish the wheel as a GitHub release asset and document enterprise/air-gapped install steps.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/specify_cli/__init__.py Adds core-pack discovery, offline scaffolding, and agent command generation; updates init to be offline-first with --from-github.
pyproject.toml Bumps version and force-includes core assets into the wheel.
docs/installation.md Adds enterprise/air-gapped installation instructions and offline init guidance.
README.md Documents the new air-gapped installation option via wheel.
CHANGELOG.md Notes offline-first init, --from-github, and wheel release asset.
.github/workflows/scripts/create-github-release.sh Attaches the built wheel to GitHub releases.
.github/workflows/release.yml Adds a wheel build step to the release workflow.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…-1752

# Conflicts:
#	CHANGELOG.md
#	pyproject.toml
#	src/specify_cli/__init__.py
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/specify_cli/init.py:1016

  • The _generate_agent_commands() docstring states TOML output is for "Gemini/Qwen/Tabnine", but Qwen is configured/packaged as Markdown commands (not TOML). Please update the docstring to match the actual supported formats so it stays consistent with create-release-packages.sh and the existing tests.
    """Generate agent-specific command files from Markdown command templates.

    Python equivalent of the generate_commands() shell function in
    .github/workflows/scripts/create-release-packages.sh.  Handles Markdown,
    TOML (Gemini/Qwen/Tabnine), and .agent.md (Copilot) output formats.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- 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
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (2)

src/specify_cli/init.py:1534

  • PR description/docs indicate init should be offline-first with an explicit opt-in to GitHub (e.g. --from-github), but the current docstring/implementation says GitHub is the default and --offline is opt-in. Please align the CLI flags/defaults with the intended UX (or update the PR/docs accordingly) to avoid confusing air-gapped users.

    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).

src/specify_cli/init.py:1526

  • The new --offline init path isn't covered by CLI-level tests. Consider adding a CliRunner test that invokes specify init ... --offline with scaffold_from_core_pack mocked and asserts download_and_extract_template is not called (and that failures don't trigger network attempts when offline is requested).
    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)"),

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 16, 2026 20:46
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (3)

tests/test_core_pack_scaffold.py:216

  • These tests call scaffold_from_core_pack() separately in many parametrized test cases, and that function spawns the release script each time. With ~N agents this becomes O(N * tests) subprocess invocations and can significantly slow CI. Consider a session-scoped fixture that scaffolds once per agent (per script type) into tmp dirs and reuses the resulting trees across invariant tests.
@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

.github/workflows/release.yml:46

  • The new wheel build writes into .genreleases/, but create-release-packages.sh clears .genreleases/* at the start of its run. With the current step order, the wheel will be deleted before the release is created, so create-github-release.sh won't be able to attach it. Build the wheel after create-release-packages.sh, or output the wheel to a different directory that isn’t wiped (or adjust the script to not delete unrelated artifacts).
      - name: Build Python wheel
        if: steps.check_release.outputs.exists == 'false'
        run: |
          pip install build
          python -m build --wheel --outdir .genreleases/

      - name: Create release package variants
        if: steps.check_release.outputs.exists == 'false'
        run: |
          chmod +x .github/workflows/scripts/create-release-packages.sh
          .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }}

src/specify_cli/init.py:1002

  • _locate_core_pack()’s docstring claims it falls back to source-checkout/editable install paths, but the implementation only checks Path(__file__).parent / "core_pack" and otherwise returns None. Either implement the documented fallback behavior here (e.g., detect repo-root assets) or update the docstring so callers don’t rely on behavior that doesn’t exist.
def _locate_core_pack() -> Path | None:
    """Return the filesystem path to the bundled core_pack directory.

    Works for wheel installs (hatchling force-include puts the directory next to
    __init__.py as specify_cli/core_pack/) and for source-checkout / editable
    installs (falls back to the repo-root templates/ and scripts/ trees).
    Returns None only when neither location exists.
    """
    # 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

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

… 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: github#1803
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (1)

tests/test_core_pack_scaffold.py:503

  • This fixture runs the release script for every agent and extracts ZIPs, but it does not clean up the generated .genreleases/spec-kit-template-*.zip artifacts in the repo. Please add teardown cleanup (e.g., delete the specific ZIPs or the .genreleases contents after extraction) so local test runs don’t accumulate large build outputs.
    tmp = tmp_path_factory.mktemp("release_script")
    extracted: dict[str, Path] = {}

    for agent in _TESTABLE_AGENTS:
        zip_path = _run_release_script(agent, "sh", tmp, bash)
        dest = tmp / f"extracted-{agent}"
        dest.mkdir()
        with zipfile.ZipFile(zip_path) as zf:
            zf.extractall(dest)
        extracted[agent] = dest


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- 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
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

mnriem added 2 commits March 16, 2026 17:29
- 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
Copilot AI review requested due to automatic review settings March 16, 2026 22:32
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

mnriem added 3 commits March 16, 2026 18:00
- 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)
Copilot AI review requested due to automatic review settings March 17, 2026 19:16
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- 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
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- 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
Copilot AI review requested due to automatic review settings March 17, 2026 20:51
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

tests/test_core_pack_scaffold.py:276

  • Several tests call scaffold_from_core_pack() but never assert it succeeded (or that the expected command directory exists). If scaffolding fails, cmd_dir.rglob(...) yields no files and the test can pass vacuously, masking regressions. Add an explicit assert ok and/or assert cmd_dir.is_dir() before iterating files here (and apply the same pattern to the other invariant tests in this 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, (

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- 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
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

else:
repo_root = Path(__file__).parent.parent.parent
templates_dir = repo_root / "templates"

Comment on lines +53 to +54
pip download -d .genreleases/specify-bundle/ .genreleases/specify_cli-*.whl
cd .genreleases && zip -r specify-bundle-${{ steps.version.outputs.tag }}.zip specify-bundle/

The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub.

> **Note:** Python 3.11+ is required. All dependencies are pure-Python wheels, so the bundle works on any platform without recompilation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(cli): Embed core template pack in CLI package for air-gapped deployment

2 participants