From 38db28aa1d5d943196445db696d1469537a0db2d 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/11] 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 | 9 +- src/specify_cli/__init__.py | 296 +++++++++++++++++- 7 files changed, 356 insertions(+), 14 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 29851a1409..990d4a4364 100644 --- 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 45b662160b..5d5dacec83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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.0] - 2026-03-09 diff --git a/README.md b/README.md index c3afd18460..9cec27b1a9 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,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. The `/speckit.*` commands are available in the assistant. diff --git a/docs/installation.md b/docs/installation.md index 6daff24315..f7dbb0b461 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -74,6 +74,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 0bb55ceaf2..07b8e7eeff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.2.0" +version = "0.2.1" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ @@ -26,6 +26,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 209632a709..ff38cd5fed 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -278,6 +278,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.""" @@ -977,6 +980,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": @@ -1272,18 +1504,22 @@ 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)"), + 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 @@ -1300,6 +1536,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 --from-github # Force download from GitHub releases """ show_banner() @@ -1455,12 +1692,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"), ]: @@ -1484,7 +1736,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: @@ -1542,6 +1808,10 @@ def init( else: tracker.skip("git", "--no-git flag") + # 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 2ab14e03591375dd16ec7fc2b5673eff6ed63885 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/11] 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 a040b4bd01..39602c43f6 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -677,10 +677,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, [ @@ -696,9 +698,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 adc0fc6ad64102a252ec608d1058a4c2bcb00daf 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/11] 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 | 5 +- docs/installation.md | 7 +- pyproject.toml | 2 + src/specify_cli/__init__.py | 289 ++++------ tests/test_core_pack_scaffold.py | 542 ++++++++++++++++++ 6 files changed, 693 insertions(+), 184 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 620da02337..29de66f63e 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -218,7 +218,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) @@ -303,34 +303,32 @@ build_variant() { ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi 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 2f46c370c7..b7aac616d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,8 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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(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 diff --git a/docs/installation.md b/docs/installation.md index f7dbb0b461..e230e4e40a 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -110,12 +110,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 2ebad4fa2d..c57d1cb6fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,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 a2d673f541..664598927c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -286,7 +286,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.""" @@ -1002,113 +1002,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( @@ -1121,10 +1040,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() @@ -1133,7 +1055,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(): @@ -1141,25 +1062,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") @@ -1168,41 +1095,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") @@ -1562,20 +1522,21 @@ 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)"), - 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)"), + 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)"), ): """ 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 @@ -1595,7 +1556,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 --from-github # Force download from GitHub releases + specify init my-project --offline # Use bundled assets (no network access) specify init my-project --ai claude --preset healthcare-compliance # With preset """ @@ -1772,12 +1733,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 [ @@ -1900,7 +1865,7 @@ def init( "ai_commands_dir": ai_commands_dir, "here": here, "preset": preset, - "from_github": from_github, + "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 ecd7fba3604e79c8e9627287d8c0f9fc4f8b3bf3 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/11] 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 f898fa00a0..663eff9020 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,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 adcd6a343f0975fd6ffbe1be4cab503f805fc7b0 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/11] 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 664598927c..ab38052f1e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -988,12 +988,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" @@ -1740,7 +1742,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) @@ -1785,7 +1793,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 32255c7e356b940316ac3336e513b9aeb584449f 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/11] 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 29de66f63e..c1bcc6253a 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 @@ -305,30 +306,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 e230e4e40a..14d6adda24 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -91,10 +91,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 ab38052f1e..4daf97b355 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1013,7 +1013,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" @@ -1793,24 +1798,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 d5ea59f0832e183bbcb5e7a908100cf6d75067aa 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/11] 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 c1bcc6253a..1d8cf495a4 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -306,13 +306,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 c57d1cb6fe..5b34463e5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,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 4daf97b355..477c63d80a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1013,11 +1013,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" @@ -1095,6 +1096,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 e292a1cac3cd91f3db490adb33ab246ed2e4681b 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/11] 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 477c63d80a..0c1b2e1fa1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1158,10 +1158,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 88010c7b50..fb4fa8669f 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -795,14 +795,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 9c0a6ddb6513ea199d22e4f52fde0820c9442b7a 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/11] 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 d7ec36afc9..943d7cdaa2 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 32e1842d3f..98dc67cc90 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1283,7 +1283,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 629257e35e2291be6bd4531260ebc2386b7e36aa 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/11] 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 32bc83b0ed..630cbc8cb4 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 \ @@ -83,5 +90,6 @@ gh release create "$VERSION" \ .genreleases/spec-kit-template-pi-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 3ec735bad4..ae6f998e61 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,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 efb787cbb5..b841781a1c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1114,8 +1114,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: @@ -1880,8 +1881,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 4df67cdb6e7d9251b9e757703e0786936ea3262a 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/11] 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}'"