Skip to content

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

Open
mnriem wants to merge 9 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 9 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. The pre-built wheel is also published as a GitHub release asset, providing a clean 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, probes pwsh then powershell via shutil.which()
  • 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

4. 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: specify_cli-VERSION-py3-none-any.whl attached to every release

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

6. Documentation

  • docs/installation.md: new "Enterprise / Air-Gapped Installation" section with --offline examples
  • README.md: "Option 3: Enterprise / Air-Gapped Installation"

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 21 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 wheel offline → specify init --offline)

Testing

576 passed

Includes 21 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.

Comment on lines +1016 to +1020
shell = shutil.which("pwsh") or shutil.which("powershell")
if not shell:
raise FileNotFoundError(
"Neither 'pwsh' (PowerShell 7) nor 'powershell' (Windows PowerShell) "
"found on PATH. Install PowerShell to use offline scaffolding."
Comment on lines +1133 to +1158
# Run the release script for this single agent + script type
env = os.environ.copy()
if os.name == "nt":
cmd = [
shell_cmd, "-File", str(release_script),
"-Version", "v0.0.0",
"-Agents", ai_assistant,
"-Scripts", script_type,
]
else:
cmd = [shell_cmd, str(release_script), "v0.0.0"]
env["AGENTS"] = ai_assistant
env["SCRIPTS"] = script_type

result = subprocess.run(
cmd, cwd=str(tmp), env=env,
capture_output=True, text=True,
)

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
Comment on lines +310 to +315
local allowed=" $1 "; shift # space-delimited allowed values
for it in "$@"; do
case "$allowed" in
*" $it "*) ;;
*) echo "Error: unknown $type '$it' (allowed:$allowed)" >&2; invalid=1 ;;
esac
Comment on lines +29 to +36
[tool.hatch.build.targets.wheel.force-include]
# Bundle core assets so `specify init` works without network access (air-gapped / enterprise)
"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"
".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"
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.

Comment on lines +29 to +45
[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"

Comment on lines +1014 to +1022
if os.name == "nt":
name = "create-release-packages.ps1"
shell = shutil.which("pwsh")
if not shell:
raise FileNotFoundError(
"'pwsh' (PowerShell 7) not found on PATH. "
"The bundled release script requires PowerShell 7+. "
"Install from https://aka.ms/powershell to use offline scaffolding."
)
Comment on lines +1161 to +1164
result = subprocess.run(
cmd, cwd=str(tmp), env=env,
capture_output=True, text=True,
)
Comment on lines +798 to 806
# Alias normalisation should have happened regardless of scaffold path used.
# Either scaffold_from_core_pack or download_and_extract_template may be called
# depending on whether bundled assets are present; check the one that was called.
if mock_scaffold.called:
assert mock_scaffold.call_args.args[1] == "kiro-cli"
else:
assert mock_download.called
assert mock_download.call_args.args[1] == "kiro-cli"

Comment on lines +187 to +193
@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}'"

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