feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803
feat(cli): embed core pack in wheel for offline/air-gapped deployment#1803mnriem wants to merge 9 commits intogithub:mainfrom
Conversation
…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
There was a problem hiding this comment.
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-githubto 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
There was a problem hiding this comment.
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 withcreate-release-packages.shand 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
There was a problem hiding this comment.
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--offlineis 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
--offlineinit path isn't covered by CLI-level tests. Consider adding aCliRunnertest that invokesspecify init ... --offlinewithscaffold_from_core_packmocked and assertsdownload_and_extract_templateis 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>
There was a problem hiding this comment.
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/, butcreate-release-packages.shclears.genreleases/*at the start of its run. With the current step order, the wheel will be deleted before the release is created, socreate-github-release.shwon't be able to attach it. Build the wheel aftercreate-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 checksPath(__file__).parent / "core_pack"and otherwise returnsNone. 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
There was a problem hiding this comment.
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-*.zipartifacts in the repo. Please add teardown cleanup (e.g., delete the specific ZIPs or the.genreleasescontents 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
There was a problem hiding this comment.
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.
src/specify_cli/__init__.py
Outdated
| 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." |
| # 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 |
| 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 |
| [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" |
- 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
There was a problem hiding this comment.
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.
| [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" | ||
|
|
| 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." | ||
| ) |
| result = subprocess.run( | ||
| cmd, cwd=str(tmp), env=env, | ||
| capture_output=True, text=True, | ||
| ) |
| # 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" | ||
|
|
| @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}'" | ||
|
|
Summary
Closes #1711
Addresses #1752
Embeds templates, commands, and scripts inside the
specify-cliPython wheel so thatspecify init --offlineworks 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:
.whlwas available as a release assetspecify init(feat(cli): Embed core template pack in CLI package for air-gapped deployment #1711) —api.github.comis blocked; the init command unconditionally callsdownload_template_from_github()to fetch a release ZIPA 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)2. New
--offlineflag forspecify initBy default,
specify initdownloads project files from the latest GitHub release (unchanged behavior). The new--offlineflag opts in to using bundled assets instead:If
--offlineis 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, probespwshthenpowershellviashutil.which()scaffold_from_core_pack()— invokes the bundledcreate-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 directory4. Wheel published as release asset
release.yml: build step added (python -m build --wheel) — runs aftercreate-release-packages.shso the script'srm -rfdoesn't wipe the wheelcreate-github-release.sh:specify_cli-VERSION-py3-none-any.whlattached to every release5. Shell script improvements
GENRELEASES_DIRis now overridable via environment variable (defaults to.genreleases), enabling tests to write to temp dirs instead of polluting the repovalidate_subset()hardened against glob injection incasepatterns6. Documentation
docs/installation.md: new "Enterprise / Air-Gapped Installation" section with--offlineexamplesREADME.md: "Option 3: Enterprise / Air-Gapped Installation"Acceptance criteria from #1711
specify init --offlinescaffolds from embedded assets with no network callsspecify init(no--offline) retains current GitHub-download behaviorpip install specify-cliincludes all core templates, commands, and scriptsforce-includein pyproject.toml)create-release-packages.shcontinues to workspecify init --offline)Testing
Includes 21 parity tests (one per agent) verifying byte-for-byte equivalence between
scaffold_from_core_pack()output and the canonical release script ZIP.