From ba2edbae7e6bb1ca68e0d732399a8aaa821616c0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:57:47 -0500 Subject: [PATCH 01/25] 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 --- .github/workflows/release.yml | 6 + .../scripts/create-github-release.sh | 1 + CHANGELOG.md | 3 + README.md | 11 + docs/installation.md | 44 +++ pyproject.toml | 7 + src/specify_cli/__init__.py | 296 +++++++++++++++++- 7 files changed, 355 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e29592cc0..103bdcb67f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - 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: | diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 4a67d8dfef..8d2de36827 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -16,6 +16,7 @@ VERSION="$1" VERSION_NO_V=${VERSION#v} gh release create "$VERSION" \ + .genreleases/specify_cli-"$VERSION_NO_V"-py3-none-any.whl \ .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f93b6ccf..c7509f0285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,9 @@ - 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(cli): embed core templates/commands/scripts in wheel for air-gapped deployment; `specify init` now works offline by default (#1711) +- feat(cli): add `--from-github` flag to `specify init` to force download from GitHub releases instead of using bundled assets +- feat(release): build and publish `specify_cli-*.whl` Python wheel as a release asset for enterprise/offline installation (#1752) - feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) ## [0.2.1] - 2026-03-11 diff --git a/README.md b/README.md index 7f2175482e..a350410e15 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,17 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c - Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall` - Cleaner shell configuration +#### Option 3: Enterprise / Air-Gapped Installation + +If your environment blocks PyPI access, download the pre-built `specify_cli-*.whl` wheel from the [releases page](https://github.com/github/spec-kit/releases/latest) and install it directly: + +```bash +pip install specify_cli-*.whl +specify init my-project --ai claude # works fully offline — no api.github.com needed +``` + +The wheel bundles all templates, commands, and scripts, so `specify init` works without any network access after install. See the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) section for fully offline (no-PyPI) instructions. + ### 2. Establish project principles Launch your AI assistant in the project directory. 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 7cb7b1ff9b..39a21fb634 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -75,6 +75,50 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. ## Troubleshooting +### Enterprise / Air-Gapped Installation + +If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can install Specify using the pre-built wheel from the GitHub releases page. + +**Step 1: Download the wheel** + +Go to the [Spec Kit releases page](https://github.com/github/spec-kit/releases/latest) and download the `specify_cli-*.whl` file. + +**Step 2: Install the wheel** + +```bash +pip install specify_cli-*.whl +``` + +**Step 3: Initialize a project (no network required)** + +```bash +specify init my-project --ai claude +``` + +The CLI bundles all templates, commands, and scripts inside the wheel, so `specify init` works completely offline — no connection to `api.github.com` needed. + +**If you also need runtime dependencies offline** (fully air-gapped machines with no access to any PyPI), use a connected machine with the same OS and Python version to download them first: + +```bash +# On a connected machine (same OS and Python version as the target): +pip download -d vendor specify_cli-*.whl + +# Transfer the wheel and vendor/ directory to the target machine + +# On the target machine: +pip install --no-index --find-links=./vendor specify_cli-*.whl +``` + +> **Note:** Python 3.11+ is required. The wheel is a pure-Python artifact, so it works on any platform without recompilation. + +**Getting the latest templates without upgrading the CLI:** + +If you want to pull freshly generated command files from the latest GitHub release instead of the bundled copy, use: + +```bash +specify init my-project --ai claude --from-github +``` + ### Git Credential Manager on Linux If you're having issues with Git authentication on Linux, you can install Git Credential Manager: diff --git a/pyproject.toml b/pyproject.toml index 8c40ff3730..baf96bc44c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,13 @@ 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) +"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" + [project.optional-dependencies] test = [ "pytest>=7.0", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index da96c07e8a..7eb90702f8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -315,6 +315,9 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "kiro": "kiro-cli", } +# Agents that use TOML command format (others use Markdown) +_TOML_AGENTS = frozenset({"gemini", "qwen", "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,235 @@ 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. + + 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 + + +def _generate_agent_commands( + template_dir: Path, + output_dir: Path, + agent: str, + script_type: str, +) -> int: + """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. + + Returns the number of command files written. + """ + import re + + if agent in _TOML_AGENTS: + ext = "toml" + arg_format = "{{args}}" + elif agent == "copilot": + ext = "agent.md" + arg_format = "$ARGUMENTS" + else: + ext = "md" + arg_format = "$ARGUMENTS" + + output_dir.mkdir(parents=True, exist_ok=True) + count = 0 + + for template_file in sorted(template_dir.glob("*.md")): + raw = template_file.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + + # Parse YAML frontmatter + frontmatter: dict = {} + body: str = raw + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + try: + frontmatter = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + frontmatter = {} + body = parts[2] + + description = str(frontmatter.get("description", "")).strip() + + # Extract script command for this script variant + scripts_section = frontmatter.get("scripts") or {} + script_command = str(scripts_section.get(script_type, "")).strip() + if not script_command: + script_command = f"(missing script command for {script_type})" + + # Extract optional per-agent script command + agent_scripts_section = frontmatter.get("agent_scripts") or {} + agent_script_command = str(agent_scripts_section.get(script_type, "")).strip() + + # Build cleaned frontmatter (drop scripts/agent_scripts: not in generated outputs) + clean_fm = {k: v for k, v in frontmatter.items() if k not in ("scripts", "agent_scripts")} + + # Reconstruct the full content with clean frontmatter + body + if clean_fm: + fm_yaml = yaml.dump(clean_fm, default_flow_style=False, allow_unicode=True, sort_keys=False).rstrip() + full_content = f"---\n{fm_yaml}\n---\n{body}" + else: + full_content = body + + # Apply placeholder substitutions (must happen before path rewriting so + # script paths like scripts/bash/... are then rewritten correctly) + full_content = full_content.replace("{SCRIPT}", script_command) + if agent_script_command: + full_content = full_content.replace("{AGENT_SCRIPT}", agent_script_command) + full_content = full_content.replace("{ARGS}", arg_format) + full_content = full_content.replace("__AGENT__", agent) + + # Rewrite bare paths to .specify/-prefixed variants (mirrors rewrite_paths() + # in create-release-packages.sh) + full_content = re.sub(r"/?memory/", ".specify/memory/", full_content) + full_content = re.sub(r"/?scripts/", ".specify/scripts/", full_content) + full_content = re.sub(r"/?templates/", ".specify/templates/", full_content) + # Fix any accidental double-prefix introduced by the substitution + full_content = full_content.replace(".specify/.specify/", ".specify/") + + # Write output file + name = template_file.stem + output_filename = f"speckit.{name}.{ext}" + + if ext == "toml": + # Escape backslashes for multi-line TOML string + escaped = full_content.replace("\\", "\\\\") + toml_body = f'description = "{description}"\n\nprompt = """\n{escaped}\n"""\n' + (output_dir / output_filename).write_text(toml_body, encoding="utf-8") + else: + (output_dir / output_filename).write_text(full_content, encoding="utf-8") + + count += 1 + + # Copilot: generate companion .prompt.md files alongside .agent.md files + if agent == "copilot": + prompts_dir = output_dir.parent / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + for agent_file in output_dir.glob("speckit.*.agent.md"): + # Strip trailing ".agent.md" to get the base name (e.g. "speckit.specify") + base = agent_file.name[: -len(".agent.md")] + prompt_file = prompts_dir / f"{base}.prompt.md" + prompt_file.write_text(f"---\nagent: {base}\n---\n", encoding="utf-8") + + return count + + +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. + + Uses templates/commands/scripts bundled inside the wheel (via hatchling + force-include) or, when running from a source checkout, falls back to the + repo-root trees. Returns True on success, False if the required assets + cannot be located (caller should fall back to download_and_extract_template). + """ + # --- Locate asset sources --- + core = _locate_core_pack() + + # Command templates + if core and (core / "commands").is_dir(): + commands_dir = core / "commands" + else: + # Source-checkout fallback + 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 for the chosen variant (bash/powershell) + scripts_subdir = "bash" if script_type == "sh" else "powershell" + if core and (core / "scripts" / scripts_subdir).is_dir(): + scripts_src = core / "scripts" / scripts_subdir + else: + repo_root = Path(__file__).parent.parent.parent + scripts_src = repo_root / "scripts" / scripts_subdir + if not scripts_src.is_dir(): + if tracker: + tracker.error("scaffold", f"{scripts_subdir} scripts not found") + return False + + # Page templates (spec-template.md, plan-template.md, etc.) + if core and (core / "templates").is_dir(): + templates_src = core / "templates" + else: + repo_root = Path(__file__).parent.parent.parent + templates_src = repo_root / "templates" + # templates_src may still be absent on minimal installs; non-fatal below + + if tracker: + tracker.start("scaffold", "applying bundled assets") + + try: + if not is_current_dir: + project_path.mkdir(parents=True, exist_ok=True) + + specify_dir = project_path / ".specify" + + # Copy scripts + target_scripts = specify_dir / "scripts" / scripts_subdir + target_scripts.mkdir(parents=True, exist_ok=True) + for f in scripts_src.iterdir(): + if f.is_file(): + shutil.copy2(f, target_scripts / f.name) + + # Copy page templates (skip sub-directories like commands/ and vscode-settings.json) + if templates_src.is_dir(): + target_templates = specify_dir / "templates" + target_templates.mkdir(parents=True, exist_ok=True) + for f in templates_src.iterdir(): + if f.is_file() and f.name != "vscode-settings.json": + shutil.copy2(f, target_templates / f.name) + + # Generate agent-specific command files from bundled command templates + agent_cfg = AGENT_CONFIG.get(ai_assistant, {}) + agent_folder = (agent_cfg.get("folder") or "").rstrip("/") + commands_subdir = agent_cfg.get("commands_subdir", "commands") + agent_cmds_dir = ( + project_path / agent_folder / commands_subdir + if agent_folder + else project_path / ".speckit" / commands_subdir + ) + _generate_agent_commands(commands_dir, agent_cmds_dir, ai_assistant, script_type) + + # Copilot-specific: copy .vscode/settings.json + if ai_assistant == "copilot" and templates_src.is_dir(): + vscode_settings_src = templates_src / "vscode-settings.json" + if vscode_settings_src.is_file(): + vscode_dir = project_path / ".vscode" + vscode_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(vscode_settings_src, vscode_dir / "settings.json") + + 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": @@ -1480,18 +1712,22 @@ def init( ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), 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)"), + from_github: bool = typer.Option(False, "--from-github", help="Download the latest template release from GitHub instead of using bundled assets (requires network access to api.github.com)"), ): """ - Initialize a new Specify project from the latest template. - + Initialize a new Specify project. + + By default, project files are scaffolded from assets bundled inside the + specify-cli package — no internet access is required. Use --from-github + to download the latest template release from GitHub instead. + 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. Scaffold the project from bundled assets (or download from GitHub with --from-github) + 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 @@ -1509,6 +1745,7 @@ def init( specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent specify init my-project --ai claude --preset healthcare-compliance # With preset + specify init my-project --from-github # Force download from GitHub releases """ show_banner() @@ -1680,12 +1917,27 @@ 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 (default) or download from GitHub. + # This is decided before the Live() context so the initial step list is stable. + _core = _locate_core_pack() + _repo_commands = Path(__file__).parent.parent.parent / "templates" / "commands" + _has_bundled = (_core is not None) or _repo_commands.is_dir() + use_github = from_github or not _has_bundled + + if use_github: + for key, label in [ + ("fetch", "Fetch latest release"), + ("download", "Download template"), + ("extract", "Extract template"), + ("zip-list", "Archive contents"), + ("extracted-summary", "Extraction summary"), + ]: + tracker.add(key, label) + else: + tracker.add("scaffold", "Apply bundled assets") + for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ]: @@ -1709,7 +1961,21 @@ def init( local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + if use_github: + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + else: + scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) + if not scaffold_ok: + # Unexpected failure — fall back to GitHub download + 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) + download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) # For generic agent, rename placeholder directory to user-specified path if selected_ai == "generic" and ai_commands_dir: @@ -1825,6 +2091,10 @@ 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 Exception as e: tracker.error("final", str(e)) From 19fe6989cb75323ec433e2d296c79c052b4b04ad Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:10:30 -0500 Subject: [PATCH 02/25] fix(tests): update kiro alias test for offline-first scaffold path --- tests/test_ai_skills.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 08017430b1..411a509695 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -1050,10 +1050,12 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): target = tmp_path / "kiro-alias-proj" with patch("specify_cli.download_and_extract_template") as mock_download, \ + patch("specify_cli.scaffold_from_core_pack", create=True) as mock_scaffold, \ patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_constitution_from_template"), \ patch("specify_cli.is_git_repo", return_value=False), \ patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + mock_scaffold.return_value = True result = runner.invoke( app, [ @@ -1069,9 +1071,14 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): ) assert result.exit_code == 0 - assert mock_download.called - # download_and_extract_template(project_path, ai_assistant, script_type, ...) - assert mock_download.call_args.args[1] == "kiro-cli" + # 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" def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" From c2d12c22a70082d2c330ac5553c5b61478c3483a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:34:20 -0500 Subject: [PATCH 03/25] 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 --- .../scripts/create-release-packages.sh | 32 +- CHANGELOG.md | 4 + docs/installation.md | 7 +- pyproject.toml | 2 + src/specify_cli/__init__.py | 286 ++++----- tests/test_core_pack_scaffold.py | 542 ++++++++++++++++++ 6 files changed, 694 insertions(+), 179 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 fedf841474..b22d39865a 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -220,7 +220,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) @@ -317,34 +317,32 @@ 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 sep="$1"; shift # separator-joined allowed values local invalid=0 - for it in "${items[@]}"; do - local found=0 - for a in "${allowed[@]}"; do [[ $it == "$a" ]] && { found=1; break; }; done - if [[ $found -eq 0 ]]; then - echo "Error: unknown $type '$it' (allowed: ${allowed[*]})" >&2 - invalid=1 - fi + for it in "$@"; do + case ",$sep," in + *,"$it",*) ;; + *) echo "Error: unknown $type '$it' (allowed: ${sep//,/ })" >&2; invalid=1 ;; + esac 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")}'; } +join_csv() { local IFS=,; echo "$*"; } + 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 "$(join_csv "${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 "$(join_csv "${ALL_SCRIPTS[@]}")" "${SCRIPT_LIST[@]}" || exit 1 else SCRIPT_LIST=("${ALL_SCRIPTS[@]}") fi diff --git a/CHANGELOG.md b/CHANGELOG.md index c7509f0285..fbec208c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,10 @@ ### Added - feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks +- feat(cli): embed core templates/commands/scripts in wheel for air-gapped deployment; `specify init --offline` uses bundled assets without network access (#1711) +- feat(cli): add `--offline` flag to `specify init` to scaffold from bundled assets instead of downloading from GitHub (for air-gapped/enterprise environments) +- feat(cli): embed release scripts (bash + PowerShell) in wheel and invoke at runtime for guaranteed parity with GitHub release ZIPs +- feat(release): build and publish `specify_cli-*.whl` Python wheel as a release asset for enterprise/offline installation (#1752) - feat(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` diff --git a/docs/installation.md b/docs/installation.md index 39a21fb634..b22094bbe6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -111,12 +111,13 @@ pip install --no-index --find-links=./vendor specify_cli-*.whl > **Note:** Python 3.11+ is required. The wheel is a pure-Python artifact, so it works on any platform without recompilation. -**Getting the latest templates without upgrading the CLI:** +**Using bundled assets (offline / air-gapped):** -If you want to pull freshly generated command files from the latest GitHub release instead of the bundled copy, use: +If you want to scaffold from the templates bundled inside the specify-cli +package instead of downloading from GitHub, use: ```bash -specify init my-project --ai claude --from-github +specify init my-project --ai claude --offline ``` ### Git Credential Manager on Linux diff --git a/pyproject.toml b/pyproject.toml index baf96bc44c..70e01cf703 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ packages = ["src/specify_cli"] "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 = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7eb90702f8..f67ff807e4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -316,7 +316,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) } # Agents that use TOML command format (others use Markdown) -_TOML_AGENTS = frozenset({"gemini", "qwen", "tabnine"}) +_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.""" @@ -1113,113 +1113,32 @@ def _locate_core_pack() -> Path | None: return None -def _generate_agent_commands( - template_dir: Path, - output_dir: Path, - agent: str, - script_type: str, -) -> int: - """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. +def _locate_release_script() -> tuple[Path, str]: + """Return (script_path, shell_cmd) for the platform-appropriate release script. - Returns the number of command files written. + 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. """ - import re - - if agent in _TOML_AGENTS: - ext = "toml" - arg_format = "{{args}}" - elif agent == "copilot": - ext = "agent.md" - arg_format = "$ARGUMENTS" + if os.name == "nt": + name = "create-release-packages.ps1" + shell = "pwsh" else: - ext = "md" - arg_format = "$ARGUMENTS" - - output_dir.mkdir(parents=True, exist_ok=True) - count = 0 - - for template_file in sorted(template_dir.glob("*.md")): - raw = template_file.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") - - # Parse YAML frontmatter - frontmatter: dict = {} - body: str = raw - if raw.startswith("---"): - parts = raw.split("---", 2) - if len(parts) >= 3: - try: - frontmatter = yaml.safe_load(parts[1]) or {} - except yaml.YAMLError: - frontmatter = {} - body = parts[2] + name = "create-release-packages.sh" + shell = "bash" - description = str(frontmatter.get("description", "")).strip() + # Wheel install: core_pack/release_scripts/ + candidate = Path(__file__).parent / "core_pack" / "release_scripts" / name + if candidate.is_file(): + return candidate, shell - # Extract script command for this script variant - scripts_section = frontmatter.get("scripts") or {} - script_command = str(scripts_section.get(script_type, "")).strip() - if not script_command: - script_command = f"(missing script command for {script_type})" + # Source-checkout fallback + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / ".github" / "workflows" / "scripts" / name + if candidate.is_file(): + return candidate, shell - # Extract optional per-agent script command - agent_scripts_section = frontmatter.get("agent_scripts") or {} - agent_script_command = str(agent_scripts_section.get(script_type, "")).strip() - - # Build cleaned frontmatter (drop scripts/agent_scripts: not in generated outputs) - clean_fm = {k: v for k, v in frontmatter.items() if k not in ("scripts", "agent_scripts")} - - # Reconstruct the full content with clean frontmatter + body - if clean_fm: - fm_yaml = yaml.dump(clean_fm, default_flow_style=False, allow_unicode=True, sort_keys=False).rstrip() - full_content = f"---\n{fm_yaml}\n---\n{body}" - else: - full_content = body - - # Apply placeholder substitutions (must happen before path rewriting so - # script paths like scripts/bash/... are then rewritten correctly) - full_content = full_content.replace("{SCRIPT}", script_command) - if agent_script_command: - full_content = full_content.replace("{AGENT_SCRIPT}", agent_script_command) - full_content = full_content.replace("{ARGS}", arg_format) - full_content = full_content.replace("__AGENT__", agent) - - # Rewrite bare paths to .specify/-prefixed variants (mirrors rewrite_paths() - # in create-release-packages.sh) - full_content = re.sub(r"/?memory/", ".specify/memory/", full_content) - full_content = re.sub(r"/?scripts/", ".specify/scripts/", full_content) - full_content = re.sub(r"/?templates/", ".specify/templates/", full_content) - # Fix any accidental double-prefix introduced by the substitution - full_content = full_content.replace(".specify/.specify/", ".specify/") - - # Write output file - name = template_file.stem - output_filename = f"speckit.{name}.{ext}" - - if ext == "toml": - # Escape backslashes for multi-line TOML string - escaped = full_content.replace("\\", "\\\\") - toml_body = f'description = "{description}"\n\nprompt = """\n{escaped}\n"""\n' - (output_dir / output_filename).write_text(toml_body, encoding="utf-8") - else: - (output_dir / output_filename).write_text(full_content, encoding="utf-8") - - count += 1 - - # Copilot: generate companion .prompt.md files alongside .agent.md files - if agent == "copilot": - prompts_dir = output_dir.parent / "prompts" - prompts_dir.mkdir(parents=True, exist_ok=True) - for agent_file in output_dir.glob("speckit.*.agent.md"): - # Strip trailing ".agent.md" to get the base name (e.g. "speckit.specify") - base = agent_file.name[: -len(".agent.md")] - prompt_file = prompts_dir / f"{base}.prompt.md" - prompt_file.write_text(f"---\nagent: {base}\n---\n", encoding="utf-8") - - return count + raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout") def scaffold_from_core_pack( @@ -1232,10 +1151,13 @@ def scaffold_from_core_pack( ) -> bool: """Scaffold a project from bundled core_pack assets — no network access required. - Uses templates/commands/scripts bundled inside the wheel (via hatchling - force-include) or, when running from a source checkout, falls back to the - repo-root trees. Returns True on success, False if the required assets - cannot be located (caller should fall back to download_and_extract_template). + Invokes the bundled create-release-packages script (bash on Unix, PowerShell + on Windows) to generate the full project scaffold for a single agent. This + guarantees byte-for-byte parity between ``specify init`` and the GitHub + release ZIPs because both use the exact same script. + + Returns True on success, False if the required assets cannot be located + (caller should fall back to download_and_extract_template). """ # --- Locate asset sources --- core = _locate_core_pack() @@ -1244,7 +1166,6 @@ def scaffold_from_core_pack( if core and (core / "commands").is_dir(): commands_dir = core / "commands" else: - # Source-checkout fallback repo_root = Path(__file__).parent.parent.parent commands_dir = repo_root / "templates" / "commands" if not commands_dir.is_dir(): @@ -1252,25 +1173,31 @@ def scaffold_from_core_pack( tracker.error("scaffold", "command templates not found") return False - # Scripts for the chosen variant (bash/powershell) - scripts_subdir = "bash" if script_type == "sh" else "powershell" - if core and (core / "scripts" / scripts_subdir).is_dir(): - scripts_src = core / "scripts" / scripts_subdir + # 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_src = repo_root / "scripts" / scripts_subdir - if not scripts_src.is_dir(): + scripts_dir = repo_root / "scripts" + if not scripts_dir.is_dir(): if tracker: - tracker.error("scaffold", f"{scripts_subdir} scripts not found") + tracker.error("scaffold", "scripts directory not found") return False - # Page templates (spec-template.md, plan-template.md, etc.) + # Page templates (spec-template.md, plan-template.md, vscode-settings.json, etc.) if core and (core / "templates").is_dir(): - templates_src = core / "templates" + templates_dir = core / "templates" else: repo_root = Path(__file__).parent.parent.parent - templates_src = repo_root / "templates" - # templates_src may still be absent on minimal installs; non-fatal below + templates_dir = repo_root / "templates" + + # Release script + try: + release_script, shell_cmd = _locate_release_script() + except FileNotFoundError as exc: + if tracker: + tracker.error("scaffold", str(exc)) + return False if tracker: tracker.start("scaffold", "applying bundled assets") @@ -1279,41 +1206,74 @@ def scaffold_from_core_pack( if not is_current_dir: project_path.mkdir(parents=True, exist_ok=True) - specify_dir = project_path / ".specify" - - # Copy scripts - target_scripts = specify_dir / "scripts" / scripts_subdir - target_scripts.mkdir(parents=True, exist_ok=True) - for f in scripts_src.iterdir(): - if f.is_file(): - shutil.copy2(f, target_scripts / f.name) - - # Copy page templates (skip sub-directories like commands/ and vscode-settings.json) - if templates_src.is_dir(): - target_templates = specify_dir / "templates" - target_templates.mkdir(parents=True, exist_ok=True) - for f in templates_src.iterdir(): - if f.is_file() and f.name != "vscode-settings.json": - shutil.copy2(f, target_templates / f.name) - - # Generate agent-specific command files from bundled command templates - agent_cfg = AGENT_CONFIG.get(ai_assistant, {}) - agent_folder = (agent_cfg.get("folder") or "").rstrip("/") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - agent_cmds_dir = ( - project_path / agent_folder / commands_subdir - if agent_folder - else project_path / ".speckit" / commands_subdir - ) - _generate_agent_commands(commands_dir, agent_cmds_dir, ai_assistant, script_type) + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + + # Set up a repo-like directory layout in the temp dir so the + # release script finds templates/commands/, scripts/, etc. + tmpl_cmds = tmp / "templates" / "commands" + tmpl_cmds.mkdir(parents=True) + for f in commands_dir.iterdir(): + if f.is_file(): + shutil.copy2(f, tmpl_cmds / f.name) + + # Page templates (needed for vscode-settings.json etc.) + if templates_dir.is_dir(): + tmpl_root = tmp / "templates" + for f in templates_dir.iterdir(): + if f.is_file(): + shutil.copy2(f, tmpl_root / f.name) + + # Scripts (bash/ and powershell/) + for subdir in ("bash", "powershell"): + src = scripts_dir / subdir + if src.is_dir(): + dst = tmp / "scripts" / subdir + dst.mkdir(parents=True, exist_ok=True) + for f in src.iterdir(): + if f.is_file(): + shutil.copy2(f, dst / f.name) + + # Run the release script for this single agent + script type + env = os.environ.copy() + if os.name == "nt": + cmd = [ + shell_cmd, "-File", str(release_script), + "-Version", "v0.0.0", + "-Agents", ai_assistant, + "-Scripts", script_type, + ] + else: + cmd = [shell_cmd, str(release_script), "v0.0.0"] + env["AGENTS"] = ai_assistant + env["SCRIPTS"] = script_type + + 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 - # Copilot-specific: copy .vscode/settings.json - if ai_assistant == "copilot" and templates_src.is_dir(): - vscode_settings_src = templates_src / "vscode-settings.json" - if vscode_settings_src.is_file(): - vscode_dir = project_path / ".vscode" - vscode_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(vscode_settings_src, vscode_dir / "settings.json") + # Copy the generated files to the project directory + build_dir = tmp / ".genreleases" / f"sdd-{ai_assistant}-package-{script_type}" + if not build_dir.is_dir(): + if tracker: + tracker.error("scaffold", "release script produced no output") + return False + + for item in build_dir.rglob("*"): + if item.is_file(): + rel = item.relative_to(build_dir) + dest = project_path / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, dest) if tracker: tracker.complete("scaffold", "bundled assets applied") @@ -1710,6 +1670,7 @@ def init( debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), + offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required)"), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), from_github: bool = typer.Option(False, "--from-github", help="Download the latest template release from GitHub instead of using bundled assets (requires network access to api.github.com)"), @@ -1717,14 +1678,15 @@ def init( """ Initialize a new Specify project. - By default, project files are scaffolded from assets bundled inside the - specify-cli package — no internet access is required. Use --from-github - to download the latest template release from GitHub instead. + By default, project files are downloaded from the latest GitHub release. + Use --offline to scaffold from assets bundled inside the specify-cli + package instead (no internet access required, ideal for air-gapped or + enterprise environments). This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant - 3. Scaffold the project from bundled assets (or download from GitHub with --from-github) + 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 @@ -1744,6 +1706,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 specify init my-project --from-github # Force download from GitHub releases """ @@ -1918,12 +1881,16 @@ def init( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) - # Determine whether to use bundled assets (default) or download from GitHub. - # This is decided before the Live() context so the initial step list is stable. + # Determine whether to use bundled assets or download from GitHub (default). + # --offline opts in to bundled assets; without it, always use GitHub. _core = _locate_core_pack() _repo_commands = Path(__file__).parent.parent.parent / "templates" / "commands" _has_bundled = (_core is not None) or _repo_commands.is_dir() - use_github = from_github or not _has_bundled + + if offline and not _has_bundled: + console.print("[yellow]Warning:[/yellow] --offline requested but no bundled assets found; falling back to GitHub download") + + use_github = not (offline and _has_bundled) if use_github: for key, label in [ @@ -2056,6 +2023,7 @@ def init( "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, + "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), }) diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py new file mode 100644 index 0000000000..59924b9f44 --- /dev/null +++ b/tests/test_core_pack_scaffold.py @@ -0,0 +1,542 @@ +""" +Validation tests for offline/air-gapped scaffolding (PR #1803). + +For every supported AI agent (except "generic") the scaffold output is verified +against invariants and compared byte-for-byte with the canonical output produced +by create-release-packages.sh. + +Since scaffold_from_core_pack() now invokes the release script at runtime, the +parity test (section 9) runs the script independently and compares the results +to ensure the integration is correct. + +Per-agent invariants verified +────────────────────────────── + • Command files are written to the directory declared in AGENT_CONFIG + • File count matches the number of source templates + • Extension is correct: .toml (TOML agents), .agent.md (copilot), .md (rest) + • No unresolved placeholders remain ({SCRIPT}, {ARGS}, __AGENT__) + • Argument token is correct: {{args}} for TOML agents, $ARGUMENTS for others + • Path rewrites applied: scripts/ → .specify/scripts/ etc. + • TOML files have "description" and "prompt" fields + • Markdown files have parseable YAML frontmatter + • Copilot: companion speckit.*.prompt.md files are generated in prompts/ + • .specify/scripts/ contains at least one script file + • .specify/templates/ contains at least one template file + +Parity invariant +──────────────── + Every file produced by scaffold_from_core_pack() must be byte-for-byte + identical to the same file in the ZIP produced by the release script. +""" + +import os +import re +import shutil +import subprocess +import zipfile +from pathlib import Path + +import pytest +import yaml + +import specify_cli +from specify_cli import ( + AGENT_CONFIG, + _TOML_AGENTS, + _locate_core_pack, + scaffold_from_core_pack, +) + +_REPO_ROOT = Path(__file__).parent.parent +_RELEASE_SCRIPT = _REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh" + + +def _find_bash() -> str | None: + """Return the path to a usable bash on this machine, or None.""" + candidates = [ + "/opt/homebrew/bin/bash", + "/usr/local/bin/bash", + "/bin/bash", + "/usr/bin/bash", + ] + for candidate in candidates: + try: + result = subprocess.run( + [candidate, "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return candidate + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + return None + + +def _run_release_script(agent: str, script_type: str, genreleases_dir: Path, bash: str) -> Path: + """Run create-release-packages.sh for *agent*/*script_type* and return the + path to the generated ZIP.""" + env = os.environ.copy() + env["AGENTS"] = agent + env["SCRIPTS"] = script_type + + result = subprocess.run( + [bash, str(_RELEASE_SCRIPT), "v0.0.0"], + capture_output=True, text=True, + cwd=str(_REPO_ROOT), + env=env, + ) + + default_dir = _REPO_ROOT / ".genreleases" + zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip" + + zip_path = default_dir / zip_pattern + if not zip_path.exists(): + pytest.fail( + f"Release script did not produce expected ZIP: {zip_path}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return zip_path + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Number of source command templates (one per .md file in templates/commands/) +_SOURCE_TEMPLATES: list[str] = [] + + +def _commands_dir() -> Path: + """Return the command templates directory (source-checkout or core_pack).""" + core = _locate_core_pack() + if core and (core / "commands").is_dir(): + return core / "commands" + # Source-checkout fallback + repo_root = Path(__file__).parent.parent + return repo_root / "templates" / "commands" + + +def _get_source_template_stems() -> list[str]: + """Return the stems of source command template files (e.g. ['specify', 'plan', ...]).""" + return sorted(p.stem for p in _commands_dir().glob("*.md")) + + +def _expected_cmd_dir(project_path: Path, agent: str) -> Path: + """Return the expected command-files directory for a given agent.""" + cfg = AGENT_CONFIG[agent] + folder = (cfg.get("folder") or "").rstrip("/") + subdir = cfg.get("commands_subdir", "commands") + if folder: + return project_path / folder / subdir + return project_path / ".speckit" / subdir + + +def _expected_ext(agent: str) -> str: + if agent in _TOML_AGENTS: + return "toml" + if agent == "copilot": + return "agent.md" + if agent == "kimi": + return "SKILL.md" # Kimi uses skills//SKILL.md + return "md" + + +def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]: + """List generated command files, handling Kimi's directory-per-skill layout.""" + if agent == "kimi": + # Kimi: .kimi/skills/speckit.*/SKILL.md + return sorted(cmd_dir.glob("speckit.*/SKILL.md")) + ext = _expected_ext(agent) + return sorted(cmd_dir.glob(f"speckit.*.{ext}")) + + +def _collect_relative_files(root: Path) -> dict[str, bytes]: + """Walk *root* and return {relative_posix_path: file_bytes}.""" + result: dict[str, bytes] = {} + for p in root.rglob("*"): + if p.is_file(): + result[p.relative_to(root).as_posix()] = p.read_bytes() + return result + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def source_template_stems() -> list[str]: + return _get_source_template_stems() + + +@pytest.fixture +def bundled_project(tmp_path): + """Run scaffold_from_core_pack for each test; caller picks agent.""" + return tmp_path + + +# --------------------------------------------------------------------------- +# Parametrize over all agents except "generic" +# --------------------------------------------------------------------------- + +_TESTABLE_AGENTS = [a for a in AGENT_CONFIG if a != "generic"] + + +# --------------------------------------------------------------------------- +# 1. Bundled scaffold — directory structure +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_creates_specify_scripts(tmp_path, agent): + """scaffold_from_core_pack copies at least one script into .specify/scripts/.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" + + scripts_dir = project / ".specify" / "scripts" / "bash" + assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'" + assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'" + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_creates_specify_templates(tmp_path, agent): + """scaffold_from_core_pack copies at least one page template into .specify/templates/.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + tpl_dir = project / ".specify" / "templates" + assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'" + assert any(tpl_dir.iterdir()), ".specify/templates/ is empty" + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_dir_location(tmp_path, agent, source_template_stems): + """Command files land in the directory declared by AGENT_CONFIG.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + cmd_dir = _expected_cmd_dir(project, agent) + assert cmd_dir.is_dir(), ( + f"Command dir '{cmd_dir.relative_to(project)}' not created for agent '{agent}'" + ) + + +# --------------------------------------------------------------------------- +# 2. Bundled scaffold — file count +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_count(tmp_path, agent, source_template_stems): + """One command file is generated per source template for every agent.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + assert len(generated) == len(source_template_stems), ( + f"Agent '{agent}': expected {len(source_template_stems)} command files " + f"({_expected_ext(agent)}), found {len(generated)}. Dir: {list(cmd_dir.iterdir())}" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_command_file_names(tmp_path, agent, source_template_stems): + """Each source template stem maps to a corresponding speckit.. file.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "sh") + assert ok + + cmd_dir = _expected_cmd_dir(project, agent) + for stem in source_template_stems: + if agent == "kimi": + expected = cmd_dir / f"speckit.{stem}" / "SKILL.md" + else: + ext = _expected_ext(agent) + expected = cmd_dir / f"speckit.{stem}.{ext}" + assert expected.is_file(), ( + f"Agent '{agent}': expected file '{expected.name}' not found in '{cmd_dir}'" + ) + + +# --------------------------------------------------------------------------- +# 3. Bundled scaffold — content invariants +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_script_placeholder(tmp_path, agent): + """{SCRIPT} must not appear in any generated command file.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, ( + f"Unresolved {{SCRIPT}} in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_agent_placeholder(tmp_path, agent): + """__AGENT__ must not appear in any generated command file.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "__AGENT__" not in content, ( + f"Unresolved __AGENT__ in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_no_unresolved_args_placeholder(tmp_path, agent): + """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if f.is_file(): + content = f.read_text(encoding="utf-8") + assert "{ARGS}" not in content, ( + f"Unresolved {{ARGS}} in '{f.relative_to(project)}' for agent '{agent}'" + ) + + +# Build a set of template stems that actually contain {ARGS} in their source. +_TEMPLATES_WITH_ARGS: frozenset[str] = frozenset( + p.stem + for p in _commands_dir().glob("*.md") + if "{ARGS}" in p.read_text(encoding="utf-8") +) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_argument_token_format(tmp_path, agent): + """For templates that carry an {ARGS} token: + - TOML agents must emit {{args}} + - Markdown agents must emit $ARGUMENTS + Templates without {ARGS} (e.g. implement, plan) are skipped. + """ + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + + for f in _list_command_files(cmd_dir, agent): + # Recover the stem from the file path + if agent == "kimi": + stem = f.parent.name.removeprefix("speckit.") + else: + ext = _expected_ext(agent) + stem = f.name.removeprefix("speckit.").removesuffix(f".{ext}") + if stem not in _TEMPLATES_WITH_ARGS: + continue # this template has no argument token + + content = f.read_text(encoding="utf-8") + if agent in _TOML_AGENTS: + assert "{{args}}" in content, ( + f"TOML agent '{agent}': expected '{{{{args}}}}' in '{f.name}'" + ) + else: + assert "$ARGUMENTS" in content, ( + f"Markdown agent '{agent}': expected '$ARGUMENTS' in '{f.name}'" + ) + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_path_rewrites_applied(tmp_path, agent): + """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, agent, "sh") + + cmd_dir = _expected_cmd_dir(project, agent) + for f in cmd_dir.rglob("*"): + if not f.is_file(): + continue + content = f.read_text(encoding="utf-8") + # Should not contain bare (non-.specify/) script paths + assert not re.search(r'(?= 3, f"Incomplete frontmatter in '{f.name}'" + fm = yaml.safe_load(parts[1]) + assert fm is not None, f"Empty frontmatter in '{f.name}'" + assert "description" in fm, ( + f"'description' key missing from frontmatter in '{f.name}' for agent '{agent}'" + ) + + +# --------------------------------------------------------------------------- +# 6. Copilot-specific: companion .prompt.md files +# --------------------------------------------------------------------------- + +def test_copilot_companion_prompt_files(tmp_path, source_template_stems): + """Copilot: a speckit..prompt.md companion is created for every .agent.md file.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, "copilot", "sh") + assert ok + + prompts_dir = project / ".github" / "prompts" + assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot" + + for stem in source_template_stems: + prompt_file = prompts_dir / f"speckit.{stem}.prompt.md" + assert prompt_file.is_file(), ( + f"Companion prompt file '{prompt_file.name}' missing for copilot" + ) + + +def test_copilot_prompt_file_content(tmp_path, source_template_stems): + """Copilot companion .prompt.md files must reference their parent .agent.md.""" + project = tmp_path / "proj" + scaffold_from_core_pack(project, "copilot", "sh") + + prompts_dir = project / ".github" / "prompts" + for stem in source_template_stems: + f = prompts_dir / f"speckit.{stem}.prompt.md" + content = f.read_text(encoding="utf-8") + assert f"agent: speckit.{stem}" in content, ( + f"Companion '{f.name}' does not reference 'speckit.{stem}'" + ) + + +# --------------------------------------------------------------------------- +# 7. PowerShell script variant +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_scaffold_powershell_variant(tmp_path, agent, source_template_stems): + """scaffold_from_core_pack with script_type='ps' creates correct files.""" + project = tmp_path / "proj" + ok = scaffold_from_core_pack(project, agent, "ps") + assert ok + + scripts_dir = project / ".specify" / "scripts" / "powershell" + assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'" + assert any(scripts_dir.iterdir()), ".specify/scripts/powershell/ is empty" + + cmd_dir = _expected_cmd_dir(project, agent) + generated = _list_command_files(cmd_dir, agent) + assert len(generated) == len(source_template_stems) + + +# --------------------------------------------------------------------------- +# 8. Parity: bundled vs. real create-release-packages.sh ZIP +# --------------------------------------------------------------------------- + +# Session-scoped fixture: run the release script once for all agents so tests +# can share the ZIPs without incurring the subprocess cost per test. + +@pytest.fixture(scope="session") +def release_script_zips(tmp_path_factory): + """Invoke create-release-packages.sh (sh variant) for every testable agent + and return a dict mapping agent → extracted Path. + + Skipped when bash is not available on this machine. + """ + bash = _find_bash() + if bash is None: + pytest.skip("bash required to run create-release-packages.sh") + + 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 + + return extracted + + +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_parity_bundled_vs_release_script(tmp_path, agent, release_script_zips): + """scaffold_from_core_pack() file tree is identical to the ZIP produced by + create-release-packages.sh for every agent (sh variant). + + This is the true end-to-end parity check: the Python offline path must + produce exactly the same artifacts as the canonical shell release script. + """ + # --- Bundled path --- + bundled_dir = tmp_path / "bundled" + ok = scaffold_from_core_pack(bundled_dir, agent, "sh") + assert ok + + # --- Release script extracted ZIP --- + script_dir = release_script_zips[agent] + + bundled_tree = _collect_relative_files(bundled_dir) + script_tree = _collect_relative_files(script_dir) + + only_bundled = set(bundled_tree) - set(script_tree) + only_script = set(script_tree) - set(bundled_tree) + + assert not only_bundled, ( + f"Agent '{agent}': files only in bundled output (not in release ZIP):\n " + + "\n ".join(sorted(only_bundled)) + ) + assert not only_script, ( + f"Agent '{agent}': files only in release ZIP (not in bundled output):\n " + + "\n ".join(sorted(only_script)) + ) + + for name in bundled_tree: + assert bundled_tree[name] == script_tree[name], ( + f"Agent '{agent}': file '{name}' content differs between " + f"bundled output and release script ZIP" + ) From 5729247a9e4edfacb4530c8388141540ee737094 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:46:39 -0500 Subject: [PATCH 04/25] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a350410e15..7470f489c6 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,10 @@ If your environment blocks PyPI access, download the pre-built `specify_cli-*.wh ```bash pip install specify_cli-*.whl -specify init my-project --ai claude # works fully offline — no api.github.com needed +specify init my-project --ai claude --offline # runs without contacting api.github.com ``` -The wheel bundles all templates, commands, and scripts, so `specify init` works without any network access after install. See the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) section for fully offline (no-PyPI) instructions. +The wheel bundles all templates, commands, and scripts, so `specify init` can run without any network access after install when you pass `--offline`. See the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) section for fully offline (no-PyPI) instructions. ### 2. Establish project principles From f11df9cd6c120b5cd9057d857b8208347772d370 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:09:15 -0500 Subject: [PATCH 05/25] 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 --- src/specify_cli/__init__.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f67ff807e4..f19c944aa5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1099,12 +1099,14 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ def _locate_core_pack() -> Path | None: - """Return the filesystem path to the bundled core_pack directory. + """Return the filesystem path to the bundled core_pack directory, or None. - 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. + 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" @@ -1888,7 +1890,13 @@ def init( _has_bundled = (_core is not None) or _repo_commands.is_dir() if offline and not _has_bundled: - console.print("[yellow]Warning:[/yellow] --offline requested but no bundled assets found; falling back to GitHub download") + console.print( + "\n[red]Error:[/red] --offline was specified but no bundled assets were found.\n" + " • Wheel install: reinstall the specify-cli wheel (core_pack/ must be present).\n" + " • Source checkout: run from the repo root so templates/ and scripts/ are accessible.\n" + "Remove --offline to attempt a GitHub download instead." + ) + raise typer.Exit(1) use_github = not (offline and _has_bundled) @@ -1933,7 +1941,15 @@ def init( else: scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) if not scaffold_ok: - # Unexpected failure — fall back to GitHub download + if offline: + # --offline explicitly requested: never attempt a network download + console.print( + "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" + "Ensure the specify-cli wheel was installed correctly (it must include core_pack/).\n" + "Remove --offline to attempt a GitHub download instead." + ) + raise typer.Exit(1) + # No explicit offline flag — fall back to GitHub download for key, label in [ ("fetch", "Fetch latest release"), ("download", "Download template"), From 91e43b29b5306c285fcddc7749deac324cbe6cb6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:55:44 -0500 Subject: [PATCH 06/25] 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 --- .github/workflows/release.yml | 12 ++-- .../scripts/create-release-packages.sh | 16 ++--- docs/installation.md | 4 +- src/specify_cli/__init__.py | 33 +++++------ tests/test_core_pack_scaffold.py | 58 +++++++------------ 5 files changed, 52 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 103bdcb67f..a340982c08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,18 +32,18 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - 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 }} + - name: Build Python wheel + if: steps.check_release.outputs.exists == 'false' + run: | + pip install build + python -m build --wheel --outdir .genreleases/ + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' id: release_notes diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index b22d39865a..2ce842d9d1 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -26,7 +26,8 @@ fi echo "Building release packages for $NEW_VERSION" # Create and use .genreleases directory for all build artifacts -GENRELEASES_DIR=".genreleases" +# Override via GENRELEASES_DIR env var (e.g. for tests writing to a temp dir) +GENRELEASES_DIR="${GENRELEASES_DIR:-.genreleases}" mkdir -p "$GENRELEASES_DIR" rm -rf "$GENRELEASES_DIR"/* || true @@ -319,30 +320,29 @@ ALL_SCRIPTS=(sh ps) validate_subset() { local type=$1; shift - local sep="$1"; shift # separator-joined allowed values local invalid=0 + local allowed=" $1 "; shift # space-delimited allowed values for it in "$@"; do - case ",$sep," in - *,"$it",*) ;; - *) echo "Error: unknown $type '$it' (allowed: ${sep//,/ })" >&2; invalid=1 ;; + case "$allowed" in + *" $it "*) ;; + *) echo "Error: unknown $type '$it' (allowed:$allowed)" >&2; invalid=1 ;; esac 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")}'; } -join_csv() { local IFS=,; echo "$*"; } if [[ -n ${AGENTS:-} ]]; then read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)" - validate_subset agent "$(join_csv "${ALL_AGENTS[@]}")" "${AGENT_LIST[@]}" || exit 1 + validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1 else AGENT_LIST=("${ALL_AGENTS[@]}") fi if [[ -n ${SCRIPTS:-} ]]; then read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)" - validate_subset script "$(join_csv "${ALL_SCRIPTS[@]}")" "${SCRIPT_LIST[@]}" || exit 1 + validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1 else SCRIPT_LIST=("${ALL_SCRIPTS[@]}") fi diff --git a/docs/installation.md b/docs/installation.md index b22094bbe6..fb27b74c75 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -92,10 +92,10 @@ pip install specify_cli-*.whl **Step 3: Initialize a project (no network required)** ```bash -specify init my-project --ai claude +specify init my-project --ai claude --offline ``` -The CLI bundles all templates, commands, and scripts inside the wheel, so `specify init` works completely offline — no connection to `api.github.com` needed. +The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub — no connection to `api.github.com` needed. **If you also need runtime dependencies offline** (fully air-gapped machines with no access to any PyPI), use a connected machine with the same OS and Python version to download them first: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f19c944aa5..b4acd18654 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1124,7 +1124,12 @@ def _locate_release_script() -> tuple[Path, str]: """ if os.name == "nt": name = "create-release-packages.ps1" - shell = "pwsh" + 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." + ) else: name = "create-release-packages.sh" shell = "bash" @@ -1941,24 +1946,14 @@ def init( else: scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) if not scaffold_ok: - if offline: - # --offline explicitly requested: never attempt a network download - console.print( - "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" - "Ensure the specify-cli wheel was installed correctly (it must include core_pack/).\n" - "Remove --offline to attempt a GitHub download instead." - ) - raise typer.Exit(1) - # No explicit offline flag — fall back to GitHub download - 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) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + # --offline explicitly requested: never attempt a network download + console.print( + "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" + "Check the output above for the specific error.\n" + "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" + "Remove --offline to attempt a GitHub download instead." + ) + raise typer.Exit(1) # For generic agent, rename placeholder directory to user-specified path if selected_ai == "generic" and ai_commands_dir: diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 59924b9f44..13d65dda2d 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -72,12 +72,14 @@ def _find_bash() -> str | None: return None -def _run_release_script(agent: str, script_type: str, genreleases_dir: Path, bash: str) -> Path: +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.""" + 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"], @@ -86,10 +88,8 @@ def _run_release_script(agent: str, script_type: str, genreleases_dir: Path, bas env=env, ) - default_dir = _REPO_ROOT / ".genreleases" zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip" - - zip_path = default_dir / zip_pattern + zip_path = output_dir / zip_pattern if not zip_path.exists(): pytest.fail( f"Release script did not produce expected ZIP: {zip_path}\n" @@ -476,50 +476,36 @@ def test_scaffold_powershell_variant(tmp_path, agent, source_template_stems): # 8. Parity: bundled vs. real create-release-packages.sh ZIP # --------------------------------------------------------------------------- -# Session-scoped fixture: run the release script once for all agents so tests -# can share the ZIPs without incurring the subprocess cost per test. +@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) +def test_parity_bundled_vs_release_script(tmp_path, agent): + """scaffold_from_core_pack() file tree is identical to the ZIP produced by + create-release-packages.sh for every agent (sh variant). -@pytest.fixture(scope="session") -def release_script_zips(tmp_path_factory): - """Invoke create-release-packages.sh (sh variant) for every testable agent - and return a dict mapping agent → extracted Path. + 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. - Skipped when bash is not available on this machine. + Each agent is tested independently: generate the release ZIP, generate + the bundled scaffold, compare. This avoids cross-agent interference + from the release script's rm -rf at startup. """ bash = _find_bash() if bash is None: pytest.skip("bash required to run create-release-packages.sh") - 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 - - return extracted + # --- Release script path --- + gen_dir = tmp_path / "genreleases" + gen_dir.mkdir() + zip_path = _run_release_script(agent, "sh", bash, gen_dir) + script_dir = tmp_path / "extracted" + script_dir.mkdir() + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(script_dir) - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_parity_bundled_vs_release_script(tmp_path, agent, release_script_zips): - """scaffold_from_core_pack() file tree is identical to the ZIP produced by - create-release-packages.sh for every agent (sh variant). - - This is the true end-to-end parity check: the Python offline path must - produce exactly the same artifacts as the canonical shell release script. - """ # --- Bundled path --- bundled_dir = tmp_path / "bundled" ok = scaffold_from_core_pack(bundled_dir, agent, "sh") assert ok - # --- Release script extracted ZIP --- - script_dir = release_script_zips[agent] - bundled_tree = _collect_relative_files(bundled_dir) script_tree = _collect_relative_files(script_dir) From af70d791978257fe0c45df62bad0735f2f601a66 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:29:48 -0500 Subject: [PATCH 07/25] 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 --- .../scripts/create-release-packages.sh | 14 ++++++++----- pyproject.toml | 10 +++++++++- src/specify_cli/__init__.py | 20 ++++++++++++++++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 2ce842d9d1..d9b07bef49 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -320,13 +320,17 @@ ALL_SCRIPTS=(sh ps) validate_subset() { local type=$1; shift + local allowed_str="$1"; shift local invalid=0 - 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 + local found=0 + 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_str)" >&2 + invalid=1 + fi done return $invalid } diff --git a/pyproject.toml b/pyproject.toml index 70e01cf703..f3ca76dd9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,15 @@ packages = ["src/specify_cli"] [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" +# 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" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b4acd18654..317471a2aa 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1124,11 +1124,12 @@ def _locate_release_script() -> tuple[Path, str]: """ if os.name == "nt": name = "create-release-packages.ps1" - shell = shutil.which("pwsh") or shutil.which("powershell") + shell = shutil.which("pwsh") if not shell: raise FileNotFoundError( - "Neither 'pwsh' (PowerShell 7) nor 'powershell' (Windows PowerShell) " - "found on PATH. Install PowerShell to use offline scaffolding." + "'pwsh' (PowerShell 7) not found on PATH. " + "The bundled release script requires PowerShell 7+. " + "Install from https://aka.ms/powershell to use offline scaffolding." ) else: name = "create-release-packages.sh" @@ -1206,6 +1207,19 @@ def scaffold_from_core_pack( 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") From d6019ee97062ff98d2cd180adf48b96e01e485d9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:04:42 -0500 Subject: [PATCH 08/25] 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) --- src/specify_cli/__init__.py | 17 ++++++++++++---- tests/test_ai_skills.py | 16 ++++++++-------- tests/test_core_pack_scaffold.py | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 317471a2aa..fe7a7c45ee 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1269,10 +1269,19 @@ def scaffold_from_core_pack( env["AGENTS"] = ai_assistant env["SCRIPTS"] = script_type - result = subprocess.run( - cmd, cwd=str(tmp), env=env, - capture_output=True, text=True, - ) + 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" diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 411a509695..0bccd48d49 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -1071,14 +1071,14 @@ def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): ) assert result.exit_code == 0 - # 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" + # 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 index 13d65dda2d..c22db615e8 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -526,3 +526,36 @@ def test_parity_bundled_vs_release_script(tmp_path, agent): f"Agent '{agent}': file '{name}' content differs between " f"bundled output and release script ZIP" ) + + +# --------------------------------------------------------------------------- +# Section 10 – pyproject.toml force-include covers all template files +# --------------------------------------------------------------------------- + +def test_pyproject_force_include_covers_all_templates(): + """Every file in templates/ (excluding commands/) must be listed in + pyproject.toml's [tool.hatch.build.targets.wheel.force-include] section. + + This prevents new template files from being silently omitted from the + wheel, which would break ``specify init --offline``. + """ + templates_dir = _REPO_ROOT / "templates" + # Collect all files directly in templates/ (not in subdirectories like commands/) + repo_template_files = sorted( + f.name for f in templates_dir.iterdir() + if f.is_file() + ) + assert repo_template_files, "Expected at least one template file in templates/" + + pyproject_path = _REPO_ROOT / "pyproject.toml" + pyproject_text = pyproject_path.read_text() + + missing = [ + name for name in repo_template_files + if f"templates/{name}" not in pyproject_text + ] + assert not missing, ( + f"Template files not listed in pyproject.toml force-include " + f"(offline scaffolding will miss them):\n " + + "\n ".join(missing) + ) From b51ea93ce13bff78d362091610362ef6249ff534 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:50:57 -0500 Subject: [PATCH 09/25] 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 --- .github/workflows/release.yml | 3 +++ .../scripts/create-github-release.sh | 20 ++++++++++++++++++- src/specify_cli/__init__.py | 7 ++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a340982c08..d5b2926cee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,9 @@ jobs: chmod +x .github/workflows/scripts/create-release-packages.sh .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }} + # Note: pyproject.toml version is already synced to the git tag by + # release-trigger.yml (which updates pyproject.toml, commits, then pushes + # the tag). No version sync step is needed here. - name: Build Python wheel if: steps.check_release.outputs.exists == 'false' run: | diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 8d2de36827..c6784c4112 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -15,8 +15,26 @@ VERSION="$1" # Remove 'v' prefix from version for release title VERSION_NO_V=${VERSION#v} +# Find the built wheel dynamically to avoid version mismatch between +# pyproject.toml and the git tag. +shopt -s nullglob +wheel_files=(.genreleases/specify_cli-*-py3-none-any.whl) + +if (( ${#wheel_files[@]} == 0 )); then + echo "Error: No specify_cli wheel found in .genreleases/" >&2 + exit 1 +fi + +if (( ${#wheel_files[@]} > 1 )); then + echo "Error: Multiple specify_cli wheels found in .genreleases/; expected exactly one:" >&2 + printf ' %s\n' "${wheel_files[@]}" >&2 + exit 1 +fi + +WHEEL_FILE="${wheel_files[0]}" + gh release create "$VERSION" \ - .genreleases/specify_cli-"$VERSION_NO_V"-py3-none-any.whl \ + "$WHEEL_FILE" \ .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index fe7a7c45ee..f9f7bd23d0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1303,7 +1303,12 @@ def scaffold_from_core_pack( rel = item.relative_to(build_dir) dest = project_path / rel dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(item, dest) + # 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") From 2ab871e2e5b3049080351c1fa98e415392e606f0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:50:53 -0500 Subject: [PATCH 10/25] 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 --- .github/workflows/release.yml | 6 +++ .../scripts/create-github-release.sh | 8 ++++ README.md | 9 ++-- docs/installation.md | 42 ++++++------------- src/specify_cli/__init__.py | 15 +++++-- 5 files changed, 42 insertions(+), 38 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5b2926cee..80e84aa886 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,12 @@ jobs: pip install build python -m build --wheel --outdir .genreleases/ + - name: Bundle offline dependencies + if: steps.check_release.outputs.exists == 'false' + run: | + pip download -d .genreleases/specify-bundle/ .genreleases/specify_cli-*.whl + cd .genreleases && zip -r specify-bundle-${{ steps.version.outputs.tag }}.zip specify-bundle/ + - name: Generate release notes if: steps.check_release.outputs.exists == 'false' id: release_notes diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index c6784c4112..287685f934 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -33,6 +33,13 @@ fi WHEEL_FILE="${wheel_files[0]}" +# Find the offline bundle ZIP +bundle_files=(.genreleases/specify-bundle-"$VERSION".zip) +BUNDLE_FILE="" +if (( ${#bundle_files[@]} == 1 )); then + BUNDLE_FILE="${bundle_files[0]}" +fi + gh release create "$VERSION" \ "$WHEEL_FILE" \ .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ @@ -87,5 +94,6 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ + ${BUNDLE_FILE:+"$BUNDLE_FILE"} \ --title "Spec Kit Templates - $VERSION_NO_V" \ --notes-file release_notes.md diff --git a/README.md b/README.md index 7470f489c6..7e5a7f4b2a 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,15 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c #### Option 3: Enterprise / Air-Gapped Installation -If your environment blocks PyPI access, download the pre-built `specify_cli-*.whl` wheel from the [releases page](https://github.com/github/spec-kit/releases/latest) and install it directly: +Download `specify-bundle-v*.zip` from the [releases page](https://github.com/github/spec-kit/releases/latest) — it contains the CLI wheel and all dependencies in one file (~2.5 MB): ```bash -pip install specify_cli-*.whl -specify init my-project --ai claude --offline # runs without contacting api.github.com +unzip specify-bundle-v*.zip +pip install --no-index --find-links=./specify-bundle/ specify-cli +specify init my-project --ai claude --offline ``` -The wheel bundles all templates, commands, and scripts, so `specify init` can run without any network access after install when you pass `--offline`. See the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) section for fully offline (no-PyPI) instructions. +See the [full air-gapped guide](./docs/installation.md#enterprise--air-gapped-installation) for details. ### 2. Establish project principles diff --git a/docs/installation.md b/docs/installation.md index fb27b74c75..49589e6033 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -77,48 +77,30 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. ### Enterprise / Air-Gapped Installation -If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can install Specify using the pre-built wheel from the GitHub releases page. +For environments with no access to PyPI or GitHub, download the pre-built offline bundle from the [releases page](https://github.com/github/spec-kit/releases/latest). -**Step 1: Download the wheel** +**On a connected machine:** -Go to the [Spec Kit releases page](https://github.com/github/spec-kit/releases/latest) and download the `specify_cli-*.whl` file. +Download `specify-bundle-v*.zip` from the [Spec Kit releases page](https://github.com/github/spec-kit/releases/latest). This single ZIP contains the specify-cli wheel and all its runtime dependencies (~2.5 MB). -**Step 2: Install the wheel** +**On the air-gapped machine:** ```bash -pip install specify_cli-*.whl -``` +# Unzip the bundle +unzip specify-bundle-v*.zip -**Step 3: Initialize a project (no network required)** +# Install — no network access needed +pip install --no-index --find-links=./specify-bundle/ specify-cli -```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 — no connection to `api.github.com` needed. +The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub. -**If you also need runtime dependencies offline** (fully air-gapped machines with no access to any PyPI), use a connected machine with the same OS and Python version to download them first: - -```bash -# On a connected machine (same OS and Python version as the target): -pip download -d vendor specify_cli-*.whl - -# Transfer the wheel and vendor/ directory to the target machine - -# On the target machine: -pip install --no-index --find-links=./vendor specify_cli-*.whl -``` +> **Note:** Python 3.11+ is required. All dependencies are pure-Python wheels, so the bundle works on any platform without recompilation. -> **Note:** Python 3.11+ is required. The wheel is a pure-Python artifact, so it works on any platform without recompilation. - -**Using bundled assets (offline / air-gapped):** - -If you want to scaffold from the templates bundled inside the specify-cli -package instead of downloading from GitHub, use: - -```bash -specify init my-project --ai claude --offline -``` +> **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 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f9f7bd23d0..e9fc63bf93 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1127,8 +1127,9 @@ def _locate_release_script() -> tuple[Path, str]: shell = shutil.which("pwsh") if not shell: raise FileNotFoundError( - "'pwsh' (PowerShell 7) not found on PATH. " - "The bundled release script requires PowerShell 7+. " + "'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: @@ -1919,8 +1920,14 @@ def init( # Determine whether to use bundled assets or download from GitHub (default). # --offline opts in to bundled assets; without it, always use GitHub. _core = _locate_core_pack() - _repo_commands = Path(__file__).parent.parent.parent / "templates" / "commands" - _has_bundled = (_core is not None) or _repo_commands.is_dir() + _repo_root = Path(__file__).parent.parent.parent + _repo_commands = _repo_root / "templates" / "commands" + _repo_scripts = _repo_root / "scripts" + # Treat bundled assets as available if we have a core_pack (wheel install), + # or, for a source checkout, when both commands and scripts are present. + _has_bundled = (_core is not None) or ( + _repo_commands.is_dir() and _repo_scripts.is_dir() + ) if offline and not _has_bundled: console.print( From 976d5806b5eb19ee65bab82c6786756f875340da Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:06:57 -0500 Subject: [PATCH 11/25] 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 --- tests/test_core_pack_scaffold.py | 121 ++++++++++++++++--------------- 1 file changed, 63 insertions(+), 58 deletions(-) diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index c22db615e8..42c20c8a1a 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -39,7 +39,6 @@ import pytest import yaml -import specify_cli from specify_cli import ( AGENT_CONFIG, _TOML_AGENTS, @@ -86,8 +85,15 @@ def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Pat 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(): @@ -102,7 +108,6 @@ def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Pat # --------------------------------------------------------------------------- # Number of source command templates (one per .md file in templates/commands/) -_SOURCE_TEMPLATES: list[str] = [] def _commands_dir() -> Path: @@ -167,10 +172,32 @@ def source_template_stems() -> list[str]: return _get_source_template_stems() -@pytest.fixture -def bundled_project(tmp_path): - """Run scaffold_from_core_pack for each test; caller picks agent.""" - return tmp_path +@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 # --------------------------------------------------------------------------- @@ -185,11 +212,9 @@ def bundled_project(tmp_path): # --------------------------------------------------------------------------- @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_creates_specify_scripts(tmp_path, agent): +def test_scaffold_creates_specify_scripts(agent, scaffolded_sh): """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}'" + project = scaffolded_sh(agent) scripts_dir = project / ".specify" / "scripts" / "bash" assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'" @@ -197,11 +222,9 @@ def test_scaffold_creates_specify_scripts(tmp_path, agent): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_creates_specify_templates(tmp_path, agent): +def test_scaffold_creates_specify_templates(agent, scaffolded_sh): """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 + project = scaffolded_sh(agent) tpl_dir = project / ".specify" / "templates" assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'" @@ -209,11 +232,9 @@ def test_scaffold_creates_specify_templates(tmp_path, agent): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_dir_location(tmp_path, agent, source_template_stems): +def test_scaffold_command_dir_location(agent, scaffolded_sh, 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 + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) assert cmd_dir.is_dir(), ( @@ -226,11 +247,9 @@ def test_scaffold_command_dir_location(tmp_path, agent, source_template_stems): # --------------------------------------------------------------------------- @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_file_count(tmp_path, agent, source_template_stems): +def test_scaffold_command_file_count(agent, scaffolded_sh, source_template_stems): """One command file is generated per source template for every agent.""" - project = tmp_path / "proj" - ok = scaffold_from_core_pack(project, agent, "sh") - assert ok + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) generated = _list_command_files(cmd_dir, agent) @@ -241,11 +260,9 @@ def test_scaffold_command_file_count(tmp_path, agent, source_template_stems): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_file_names(tmp_path, agent, source_template_stems): +def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems): """Each source template stem maps to a corresponding speckit.. file.""" - project = tmp_path / "proj" - ok = scaffold_from_core_pack(project, agent, "sh") - assert ok + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for stem in source_template_stems: @@ -264,10 +281,9 @@ def test_scaffold_command_file_names(tmp_path, agent, source_template_stems): # --------------------------------------------------------------------------- @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_script_placeholder(tmp_path, agent): +def test_no_unresolved_script_placeholder(agent, scaffolded_sh): """{SCRIPT} must not appear in any generated command file.""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for f in cmd_dir.rglob("*"): @@ -279,10 +295,9 @@ def test_no_unresolved_script_placeholder(tmp_path, agent): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_agent_placeholder(tmp_path, agent): +def test_no_unresolved_agent_placeholder(agent, scaffolded_sh): """__AGENT__ must not appear in any generated command file.""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for f in cmd_dir.rglob("*"): @@ -294,10 +309,9 @@ def test_no_unresolved_agent_placeholder(tmp_path, agent): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_args_placeholder(tmp_path, agent): +def test_no_unresolved_args_placeholder(agent, scaffolded_sh): """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for f in cmd_dir.rglob("*"): @@ -317,14 +331,13 @@ def test_no_unresolved_args_placeholder(tmp_path, agent): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_argument_token_format(tmp_path, agent): +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 = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) @@ -350,10 +363,9 @@ def test_argument_token_format(tmp_path, agent): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_path_rewrites_applied(tmp_path, agent): +def test_path_rewrites_applied(agent, scaffolded_sh): """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants.""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for f in cmd_dir.rglob("*"): @@ -374,10 +386,9 @@ def test_path_rewrites_applied(tmp_path, agent): # --------------------------------------------------------------------------- @pytest.mark.parametrize("agent", sorted(_TOML_AGENTS)) -def test_toml_format_valid(tmp_path, agent): +def test_toml_format_valid(agent, scaffolded_sh): """TOML agents: every command file must have description and prompt fields.""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for f in cmd_dir.glob("speckit.*.toml"): @@ -398,10 +409,9 @@ def test_toml_format_valid(tmp_path, agent): @pytest.mark.parametrize("agent", _MARKDOWN_AGENTS) -def test_markdown_has_frontmatter(tmp_path, agent): +def test_markdown_has_frontmatter(agent, scaffolded_sh): """Markdown agents: every command file must start with valid YAML frontmatter.""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, agent, "sh") + project = scaffolded_sh(agent) cmd_dir = _expected_cmd_dir(project, agent) for f in _list_command_files(cmd_dir, agent): @@ -422,11 +432,9 @@ def test_markdown_has_frontmatter(tmp_path, agent): # 6. Copilot-specific: companion .prompt.md files # --------------------------------------------------------------------------- -def test_copilot_companion_prompt_files(tmp_path, source_template_stems): +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 = tmp_path / "proj" - ok = scaffold_from_core_pack(project, "copilot", "sh") - assert ok + project = scaffolded_sh("copilot") prompts_dir = project / ".github" / "prompts" assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot" @@ -438,10 +446,9 @@ def test_copilot_companion_prompt_files(tmp_path, source_template_stems): ) -def test_copilot_prompt_file_content(tmp_path, source_template_stems): +def test_copilot_prompt_file_content(scaffolded_sh, source_template_stems): """Copilot companion .prompt.md files must reference their parent .agent.md.""" - project = tmp_path / "proj" - scaffold_from_core_pack(project, "copilot", "sh") + project = scaffolded_sh("copilot") prompts_dir = project / ".github" / "prompts" for stem in source_template_stems: @@ -457,11 +464,9 @@ def test_copilot_prompt_file_content(tmp_path, source_template_stems): # --------------------------------------------------------------------------- @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_powershell_variant(tmp_path, agent, source_template_stems): +def test_scaffold_powershell_variant(agent, scaffolded_ps, source_template_stems): """scaffold_from_core_pack with script_type='ps' creates correct files.""" - project = tmp_path / "proj" - ok = scaffold_from_core_pack(project, agent, "ps") - assert ok + project = scaffolded_ps(agent) scripts_dir = project / ".specify" / "scripts" / "powershell" assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'" From 889ecf36535ad638831dc9991145eb7c5efe0508 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:49:36 -0500 Subject: [PATCH 12/25] fix(offline): remove wheel from release, update air-gapped docs to use pip download --- .github/workflows/release.yml | 15 -------- .../scripts/create-github-release.sh | 27 -------------- README.md | 10 +----- docs/installation.md | 35 ++++++++++++++----- 4 files changed, 27 insertions(+), 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80e84aa886..2e29592cc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,21 +38,6 @@ jobs: chmod +x .github/workflows/scripts/create-release-packages.sh .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }} - # Note: pyproject.toml version is already synced to the git tag by - # release-trigger.yml (which updates pyproject.toml, commits, then pushes - # the tag). No version sync step is needed here. - - name: Build Python wheel - if: steps.check_release.outputs.exists == 'false' - run: | - pip install build - python -m build --wheel --outdir .genreleases/ - - - name: Bundle offline dependencies - if: steps.check_release.outputs.exists == 'false' - run: | - pip download -d .genreleases/specify-bundle/ .genreleases/specify_cli-*.whl - cd .genreleases && zip -r specify-bundle-${{ steps.version.outputs.tag }}.zip specify-bundle/ - - name: Generate release notes if: steps.check_release.outputs.exists == 'false' id: release_notes diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh index 287685f934..4a67d8dfef 100755 --- a/.github/workflows/scripts/create-github-release.sh +++ b/.github/workflows/scripts/create-github-release.sh @@ -15,33 +15,7 @@ VERSION="$1" # Remove 'v' prefix from version for release title VERSION_NO_V=${VERSION#v} -# Find the built wheel dynamically to avoid version mismatch between -# pyproject.toml and the git tag. -shopt -s nullglob -wheel_files=(.genreleases/specify_cli-*-py3-none-any.whl) - -if (( ${#wheel_files[@]} == 0 )); then - echo "Error: No specify_cli wheel found in .genreleases/" >&2 - exit 1 -fi - -if (( ${#wheel_files[@]} > 1 )); then - echo "Error: Multiple specify_cli wheels found in .genreleases/; expected exactly one:" >&2 - printf ' %s\n' "${wheel_files[@]}" >&2 - exit 1 -fi - -WHEEL_FILE="${wheel_files[0]}" - -# Find the offline bundle ZIP -bundle_files=(.genreleases/specify-bundle-"$VERSION".zip) -BUNDLE_FILE="" -if (( ${#bundle_files[@]} == 1 )); then - BUNDLE_FILE="${bundle_files[0]}" -fi - gh release create "$VERSION" \ - "$WHEEL_FILE" \ .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ @@ -94,6 +68,5 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ - ${BUNDLE_FILE:+"$BUNDLE_FILE"} \ --title "Spec Kit Templates - $VERSION_NO_V" \ --notes-file release_notes.md diff --git a/README.md b/README.md index 7e5a7f4b2a..08028c2f32 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c #### Option 3: Enterprise / Air-Gapped Installation -Download `specify-bundle-v*.zip` from the [releases page](https://github.com/github/spec-kit/releases/latest) — it contains the CLI wheel and all dependencies in one file (~2.5 MB): - -```bash -unzip specify-bundle-v*.zip -pip install --no-index --find-links=./specify-bundle/ specify-cli -specify init my-project --ai claude --offline -``` - -See the [full air-gapped guide](./docs/installation.md#enterprise--air-gapped-installation) for details. +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 diff --git a/docs/installation.md b/docs/installation.md index 49589e6033..90d7c9c17b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -77,28 +77,45 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts. ### Enterprise / Air-Gapped Installation -For environments with no access to PyPI or GitHub, download the pre-built offline bundle from the [releases page](https://github.com/github/spec-kit/releases/latest). +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. -**On a connected machine:** +**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)** -Download `specify-bundle-v*.zip` from the [Spec Kit releases page](https://github.com/github/spec-kit/releases/latest). This single ZIP contains the specify-cli wheel and all its runtime dependencies (~2.5 MB). +```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. -**On the air-gapped machine:** +**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 -# Unzip the bundle -unzip specify-bundle-v*.zip +pip install --no-index --find-links=./dist specify-cli +``` -# Install — no network access needed -pip install --no-index --find-links=./specify-bundle/ 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. -> **Note:** Python 3.11+ is required. All dependencies are pure-Python wheels, so the bundle works on any platform without recompilation. +> **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. From 52e7e21fce057c7b6b4b57d08d9abca1689ca38f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:12:00 -0500 Subject: [PATCH 13/25] 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. --- tests/test_core_pack_scaffold.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 42c20c8a1a..929dcd27ba 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -135,21 +135,26 @@ def _expected_cmd_dir(project_path: Path, agent: str) -> Path: 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 == "kimi": - return "SKILL.md" # Kimi uses skills//SKILL.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 Kimi's directory-per-skill layout.""" - if agent == "kimi": - # Kimi: .kimi/skills/speckit.*/SKILL.md - return sorted(cmd_dir.glob("speckit.*/SKILL.md")) + """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}")) @@ -266,8 +271,9 @@ def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems cmd_dir = _expected_cmd_dir(project, agent) for stem in source_template_stems: - if agent == "kimi": - expected = cmd_dir / f"speckit.{stem}" / "SKILL.md" + 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}" @@ -343,8 +349,9 @@ def test_argument_token_format(agent, scaffolded_sh): for f in _list_command_files(cmd_dir, agent): # Recover the stem from the file path - if agent == "kimi": - stem = f.parent.name.removeprefix("speckit.") + 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}") From 6da76bf48d6c8499e89a84760bd0cd80021d53ac Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:27:47 -0500 Subject: [PATCH 14/25] fix(offline): require wheel core_pack for --offline, remove source-checkout fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --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. --- src/specify_cli/__init__.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e9fc63bf93..36ee1cf49d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1920,25 +1920,17 @@ def init( # Determine whether to use bundled assets or download from GitHub (default). # --offline opts in to bundled assets; without it, always use GitHub. _core = _locate_core_pack() - _repo_root = Path(__file__).parent.parent.parent - _repo_commands = _repo_root / "templates" / "commands" - _repo_scripts = _repo_root / "scripts" - # Treat bundled assets as available if we have a core_pack (wheel install), - # or, for a source checkout, when both commands and scripts are present. - _has_bundled = (_core is not None) or ( - _repo_commands.is_dir() and _repo_scripts.is_dir() - ) - if offline and not _has_bundled: + if offline and _core is None: console.print( - "\n[red]Error:[/red] --offline was specified but no bundled assets were found.\n" - " • Wheel install: reinstall the specify-cli wheel (core_pack/ must be present).\n" - " • Source checkout: run from the repo root so templates/ and scripts/ are accessible.\n" - "Remove --offline to attempt a GitHub download instead." + "\n[red]Error:[/red] --offline requires a wheel install with bundled assets.\n" + " Install the specify-cli wheel (it must contain core_pack/).\n" + " See: docs/installation.md → Enterprise / Air-Gapped Installation\n" + "Remove --offline to download from GitHub instead." ) raise typer.Exit(1) - use_github = not (offline and _has_bundled) + use_github = not (offline and _core is not None) if use_github: for key, label in [ From addb3f8d90748f908cd3ca25890b6eae05e6160c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:47:08 -0500 Subject: [PATCH 15/25] fix(offline): remove stale [Unreleased] CHANGELOG section, scope httpx.Client to download path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 7 ------- src/specify_cli/__init__.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbec208c07..43f93b6ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,10 +70,6 @@ ### Added - feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks -- feat(cli): embed core templates/commands/scripts in wheel for air-gapped deployment; `specify init --offline` uses bundled assets without network access (#1711) -- feat(cli): add `--offline` flag to `specify init` to scaffold from bundled assets instead of downloading from GitHub (for air-gapped/enterprise environments) -- feat(cli): embed release scripts (bash + PowerShell) in wheel and invoke at runtime for guaranteed parity with GitHub release ZIPs -- feat(release): build and publish `specify_cli-*.whl` Python wheel as a release asset for enterprise/offline installation (#1752) - feat(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` @@ -89,9 +85,6 @@ - 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(cli): embed core templates/commands/scripts in wheel for air-gapped deployment; `specify init` now works offline by default (#1711) -- feat(cli): add `--from-github` flag to `specify init` to force download from GitHub releases instead of using bundled assets -- feat(release): build and publish `specify_cli-*.whl` Python wheel as a release asset for enterprise/offline installation (#1752) - feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781) ## [0.2.1] - 2026-03-11 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 36ee1cf49d..d6f9a2cb62 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1966,10 +1966,10 @@ def init( try: verify = not skip_tls local_ssl_context = ssl_context if verify else False - local_client = httpx.Client(verify=local_ssl_context) if use_github: - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + 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: From 6aa53d6e382e02788824ad00384921652a6b684a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:24:56 -0500 Subject: [PATCH 16/25] 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 --- CHANGELOG.md | 63 +++++++------------------------- README.md | 16 +++++--- docs/installation.md | 26 +++++++------ docs/upgrade.md | 10 +++-- src/specify_cli/__init__.py | 8 +++- tests/test_core_pack_scaffold.py | 5 +-- 6 files changed, 53 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f93b6ccf..dd6c2e5dd5 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,13 +21,13 @@ - 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) +- fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867) - feat: add Trae IDE support as a new agent (#1817) - feat(cli): polite deep merge for settings.json and support JSONC (#1874) - feat(extensions,presets): add priority-based resolution ordering (#1855) @@ -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 @@ -316,8 +286,3 @@ ## [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 08028c2f32..1d93ed4a1f 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:** diff --git a/docs/installation.md b/docs/installation.md index 90d7c9c17b..1c6afcf607 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 diff --git a/docs/upgrade.md b/docs/upgrade.md index 74bf6192c0..cd5cc124fe 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/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d6f9a2cb62..06bd83ac1f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1199,6 +1199,10 @@ def scaffold_from_core_pack( 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: @@ -1709,7 +1713,6 @@ def init( 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)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), - from_github: bool = typer.Option(False, "--from-github", help="Download the latest template release from GitHub instead of using bundled assets (requires network access to api.github.com)"), ): """ Initialize a new Specify project. @@ -1744,7 +1747,6 @@ def init( 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 - specify init my-project --from-github # Force download from GitHub releases """ show_banner() @@ -2102,6 +2104,8 @@ def init( 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_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 929dcd27ba..1709ef081d 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -31,7 +31,6 @@ import os import re -import shutil import subprocess import zipfile from pathlib import Path @@ -567,7 +566,7 @@ def test_pyproject_force_include_covers_all_templates(): if f"templates/{name}" not in pyproject_text ] assert not missing, ( - f"Template files not listed in pyproject.toml force-include " - f"(offline scaffolding will miss them):\n " + "Template files not listed in pyproject.toml force-include " + "(offline scaffolding will miss them):\n " + "\n ".join(missing) ) From 2d3e4a1c7c9af8dab345205bb28ade209855c99e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:36:10 -0500 Subject: [PATCH 17/25] 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 --- docs/installation.md | 2 ++ src/specify_cli/__init__.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/installation.md b/docs/installation.md index 1c6afcf607..5d560b6e33 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -119,6 +119,8 @@ 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. diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 06bd83ac1f..b346a32948 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1710,7 +1710,7 @@ 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)"), + 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)"), ): @@ -1722,6 +1722,12 @@ def init( 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 @@ -1934,6 +1940,14 @@ def init( use_github = not (offline and _core is not None) + 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"), From 55c9bef10c067d97ca611d2db3667870a6ac7bf4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:50:46 -0500 Subject: [PATCH 18/25] fix(offline): allow --offline in source checkouts, fix CHANGELOG truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 6 +----- src/specify_cli/__init__.py | 14 ++++---------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6c2e5dd5..efc967feec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ - 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 skill… (#1867) +- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867) - feat: add Trae IDE support as a new agent (#1817) - feat(cli): polite deep merge for settings.json and support JSONC (#1874) - feat(extensions,presets): add priority-based resolution ordering (#1855) @@ -282,7 +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) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b346a32948..bed9101730 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1927,18 +1927,12 @@ def init( # 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() - if offline and _core is None: - console.print( - "\n[red]Error:[/red] --offline requires a wheel install with bundled assets.\n" - " Install the specify-cli wheel (it must contain core_pack/).\n" - " See: docs/installation.md → Enterprise / Air-Gapped Installation\n" - "Remove --offline to download from GitHub instead." - ) - raise typer.Exit(1) - - use_github = not (offline and _core is not None) + use_github = not offline if use_github and _core is not None: console.print( From 00756abf58bdbcb4bb9fe22881a54096e325d18a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:04:29 -0500 Subject: [PATCH 19/25] 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) --- src/specify_cli/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index bed9101730..41b357759d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1262,6 +1262,9 @@ def scaffold_from_core_pack( # 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), @@ -1990,6 +1993,9 @@ def init( "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" "Remove --offline to attempt a GitHub download instead." ) + # 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 From b83873e58797dedd7d02244279196f108d50460a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:29:08 -0500 Subject: [PATCH 20/25] 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) --- tests/test_core_pack_scaffold.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 1709ef081d..3ebd2213c2 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -31,6 +31,7 @@ import os import re +import shutil import subprocess import zipfile from pathlib import Path @@ -51,6 +52,10 @@ 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", @@ -487,10 +492,11 @@ def test_scaffold_powershell_variant(agent, scaffolded_ps, source_template_stems # 8. Parity: bundled vs. real create-release-packages.sh ZIP # --------------------------------------------------------------------------- +@pytest.mark.parametrize("script_type", ["sh", "ps"]) @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_parity_bundled_vs_release_script(tmp_path, agent): +def test_parity_bundled_vs_release_script(tmp_path, agent, script_type): """scaffold_from_core_pack() file tree is identical to the ZIP produced by - create-release-packages.sh for every agent (sh variant). + 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. @@ -506,7 +512,7 @@ def test_parity_bundled_vs_release_script(tmp_path, agent): # --- Release script path --- gen_dir = tmp_path / "genreleases" gen_dir.mkdir() - zip_path = _run_release_script(agent, "sh", bash, gen_dir) + zip_path = _run_release_script(agent, script_type, bash, gen_dir) script_dir = tmp_path / "extracted" script_dir.mkdir() with zipfile.ZipFile(zip_path) as zf: @@ -514,7 +520,7 @@ def test_parity_bundled_vs_release_script(tmp_path, agent): # --- Bundled path --- bundled_dir = tmp_path / "bundled" - ok = scaffold_from_core_pack(bundled_dir, agent, "sh") + ok = scaffold_from_core_pack(bundled_dir, agent, script_type) assert ok bundled_tree = _collect_relative_files(bundled_dir) @@ -524,17 +530,17 @@ def test_parity_bundled_vs_release_script(tmp_path, agent): only_script = set(script_tree) - set(bundled_tree) assert not only_bundled, ( - f"Agent '{agent}': files only in bundled output (not in release ZIP):\n " + 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}': files only in release ZIP (not in bundled output):\n " + 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}': file '{name}' content differs between " + f"Agent '{agent}' ({script_type}): file '{name}' content differs between " f"bundled output and release script ZIP" ) From 8ac37fc09d7d89a9f4d196fca492943289361cd6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:13:34 -0500 Subject: [PATCH 21/25] 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 --- tests/test_core_pack_scaffold.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 3ebd2213c2..d7b159e34e 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -33,6 +33,7 @@ import re import shutil import subprocess +import tomllib import zipfile from pathlib import Path @@ -241,7 +242,7 @@ def test_scaffold_creates_specify_templates(agent, scaffolded_sh): @pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_dir_location(agent, scaffolded_sh, source_template_stems): +def test_scaffold_command_dir_location(agent, scaffolded_sh): """Command files land in the directory declared by AGENT_CONFIG.""" project = scaffolded_sh(agent) @@ -565,11 +566,13 @@ def test_pyproject_force_include_covers_all_templates(): assert repo_template_files, "Expected at least one template file in templates/" pyproject_path = _REPO_ROOT / "pyproject.toml" - pyproject_text = pyproject_path.read_text() + 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 pyproject_text + if f"templates/{name}" not in force_include ] assert not missing, ( "Template files not listed in pyproject.toml force-include " From 243ef5d955172d6768ec1fe9c3738211196565df Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:04:33 -0500 Subject: [PATCH 22/25] 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') --- .../workflows/scripts/create-release-packages.sh | 15 ++++++++++++++- src/specify_cli/__init__.py | 7 +++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index d9b07bef49..a56b3e6fb2 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -28,8 +28,21 @@ echo "Building release packages for $NEW_VERSION" # Create and use .genreleases directory for all build artifacts # 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 + mkdir -p "$GENRELEASES_DIR" -rm -rf "$GENRELEASES_DIR"/* || true +rm -rf "${GENRELEASES_DIR%/}/"* || true rewrite_paths() { sed -E \ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 41b357759d..8b88307f7d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1165,8 +1165,11 @@ def scaffold_from_core_pack( guarantees byte-for-byte parity between ``specify init`` and the GitHub release ZIPs because both use the exact same script. - Returns True on success, False if the required assets cannot be located - (caller should fall back to download_and_extract_template). + 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() From 9d322345720486c6faed2d17e3b9c9897499e48d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:32:59 -0500 Subject: [PATCH 23/25] fix: harden GENRELEASES_DIR guard, cache parity tests, safe iterdir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../scripts/create-release-packages.sh | 4 ++ tests/test_core_pack_scaffold.py | 62 ++++++++++++------- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index a56b3e6fb2..475c98be0a 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -40,6 +40,10 @@ case "$GENRELEASES_DIR" in 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 diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index d7b159e34e..0ba05c8f25 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -263,9 +263,15 @@ def test_scaffold_command_file_count(agent, scaffolded_sh, source_template_stems 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: {list(cmd_dir.iterdir())}" + f"({_expected_ext(agent)}), found {len(generated)}. Dir: {dir_listing}" ) @@ -493,39 +499,53 @@ def test_scaffold_powershell_variant(agent, scaffolded_ps, 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(tmp_path, agent, script_type): +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. - Each agent is tested independently: generate the release ZIP, generate - the bundled scaffold, compare. This avoids cross-agent interference - from the release script's rm -rf at startup. + Both sides are session-cached: each agent/script_type combination is + scaffolded and release-scripted only once across all tests. """ - bash = _find_bash() - if bash is None: + script_tree = release_script_trees(agent, script_type) + if script_tree is None: pytest.skip("bash required to run create-release-packages.sh") - # --- Release script path --- - gen_dir = tmp_path / "genreleases" - gen_dir.mkdir() - zip_path = _run_release_script(agent, script_type, bash, gen_dir) - script_dir = tmp_path / "extracted" - script_dir.mkdir() - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(script_dir) - - # --- Bundled path --- - bundled_dir = tmp_path / "bundled" - ok = scaffold_from_core_pack(bundled_dir, agent, script_type) - assert ok + # 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) - script_tree = _collect_relative_files(script_dir) only_bundled = set(bundled_tree) - set(script_tree) only_script = set(script_tree) - set(bundled_tree) From ed138130af2b9d6557b85116fd35cb39de3b6a35 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:43:27 -0500 Subject: [PATCH 24/25] fix(tests): exclude YAML frontmatter source metadata from path rewrite check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_core_pack_scaffold.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py index 0ba05c8f25..92848bb163 100644 --- a/tests/test_core_pack_scaffold.py +++ b/tests/test_core_pack_scaffold.py @@ -382,7 +382,11 @@ def test_argument_token_format(agent, scaffolded_sh): @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.""" + """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) @@ -390,11 +394,19 @@ def test_path_rewrites_applied(agent, scaffolded_sh): 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'(? Date: Fri, 20 Mar 2026 14:24:38 -0500 Subject: [PATCH 25/25] 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). --- src/specify_cli/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8b88307f7d..8fb67677e0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1992,10 +1992,14 @@ def init( # --offline explicitly requested: never attempt a network download console.print( "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" - "Check the output above for the specific error.\n" "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" "Remove --offline to attempt a GitHub download instead." ) + # 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)