Skip to content
Open
7 changes: 1 addition & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,7 @@ Here are a few things you can do that will increase the likelihood of your pull

## Development workflow

When working on spec-kit:

1. Test changes with the `specify` CLI commands (`/specify`, `/plan`, `/tasks`) in your coding agent of choice
2. Verify templates are working correctly in `templates/` directory
3. Test script functionality in the `scripts/` directory
4. Ensure memory files (`memory/constitution.md`) are updated if major process changes are made
See `docs/local-development.md` for up-to-date, end-to-end instructions.

## AI contributions in Spec Kit

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- [🎯 Experimental goals](#-experimental-goals)
- [🔧 Prerequisites](#-prerequisites)
- [📖 Learn more](#-learn-more)
- [🛠️ Local Development](docs/local-development.md)
- [📋 Detailed process](#-detailed-process)
- [🔍 Troubleshooting](#-troubleshooting)
- [👥 Maintainers](#-maintainers)
Expand Down Expand Up @@ -242,6 +243,7 @@ If you encounter issues with an agent, please open an issue so we can refine the

- **[Complete Spec-Driven Development Methodology](./spec-driven.md)** - Deep dive into the full process
- **[Detailed Walkthrough](#-detailed-process)** - Step-by-step implementation guide
- **[Local Development Guide](docs/local-development.md)** - Run the CLI from source, editable installs, uvx flows, and using locally built template ZIPs

---

Expand Down
89 changes: 89 additions & 0 deletions docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ This guide shows how to iterate on the `specify` CLI locally without publishing

> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.

## Development Workflow (Checklist)
- Test the `specify` CLI flows (`/specify`, `/plan`, `/tasks`) for your changes
- Verify templates in `templates/` render and behave as expected
- Test scripts in `scripts/` for both `sh` and `ps` variants where applicable
- Update `memory/constitution.md` if you changed core process expectations

## 1. Clone and Switch Branches

```bash
Expand Down Expand Up @@ -85,6 +91,44 @@ specify-dev() { uvx --from /mnt/c/GitHub/spec-kit specify "$@"; }
specify-dev --help
```

### 4b. Use Locally Built Templates (No Network)

Build the per-agent, per-script template archives locally and point the CLI at one ZIP via `SPECIFY_TEMPLATE_ZIP`.

- Build on Linux

```bash
chmod +x .github/workflows/scripts/create-release-packages.sh && \
.github/workflows/scripts/create-release-packages.sh v0.0.1; \
chmod -x .github/workflows/scripts/create-release-packages.sh
```

- Build on macOS (use Docker for GNU tools like `cp --parents`)

```bash
docker run --rm -it -v "$PWD":/w -w /w ubuntu:24.04 bash -lc "apt-get update && apt-get install -y zip && chmod +x .github/workflows/scripts/create-release-packages.sh && .github/workflows/scripts/create-release-packages.sh v0.0.1 && chmod -x .github/workflows/scripts/create-release-packages.sh"
```

- (Optional) Build only specific variants

```bash
AGENTS=claude,cursor SCRIPTS=sh .github/workflows/scripts/create-release-packages.sh v0.0.1
```

- Run the CLI against a local ZIP (no network)

```bash
SPECIFY_TEMPLATE_ZIP=/abs/path/spec-kit-template-claude-sh-v0.0.1.zip \
uvx --refresh --no-cache --from /abs/path/to/spec-kit \
specify init --here --ai claude --script sh --ignore-agent-tools
```

Notes:
- Keep `--ai` and `--script` consistent with the ZIP variant you built.
- On Linux, ensure `zip` is installed. On macOS, prefer the Docker method above.
- Artifacts are git-ignored by default (`spec-kit-template-*-v*.zip`, `sdd-*-package-*`).
- This flow mirrors the release packaging; it’s ideal for verifying template layout and agent-specific directories (Claude, Cursor, Copilot, Qwen, Gemini, opencode, Windsurf).

## 5. Testing Script Permission Logic

After running an `init`, check that shell scripts are executable on POSIX systems:
Expand Down Expand Up @@ -142,6 +186,7 @@ specify init demo --skip-tls --ai gemini --ignore-agent-tools --script ps
| Local uvx run (abs path) | `uvx --from /mnt/c/GitHub/spec-kit specify ...` |
| Git branch uvx | `uvx --from git+URL@branch specify ...` |
| Build wheel | `uv build` |
| Use locally built template | `SPECIFY_TEMPLATE_ZIP=… uvx --from … specify init …` |

## 11. Cleaning Up

Expand All @@ -166,3 +211,47 @@ rm -rf .venv dist build *.egg-info
- Open a PR when satisfied
- (Optional) Tag a release once changes land in `main`

## 14. Distributing and testing your fork

You can point the CLI at a different GitHub repository (e.g., your fork) for template downloads without changing code. Set these environment variables before running `specify`:

- `SPECIFY_REPO_OWNER` — GitHub user/org that owns the repo
- `SPECIFY_REPO_NAME` — Repository name

The CLI queries `https://api.github.com/repos/<owner>/<repo>/releases/latest` and selects the first asset whose filename contains `spec-kit-template-{ai}-{script}` and ends with `.zip`.

Recommended asset naming (matches release packaging scripts):
`spec-kit-template-<agent>-<script>-vX.Y.Z.zip` (e.g., `spec-kit-template-claude-sh-v0.0.1.zip`).

Steps to distribute/test your fork:
1. Fork this repo and push your changes.
2. Build template archives using the release packaging script (see section 4b for examples).
3. Draft a GitHub Release on your fork and upload the ZIP assets.
4. Export env var overrides and run the CLI against your fork’s release.

Bash/Zsh:
```bash
export SPECIFY_REPO_OWNER=your-gh-username-or-org
export SPECIFY_REPO_NAME=your-spec-kit-repo
# Optional if private/rate-limited
export GH_TOKEN=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXX

specify init my-forked-test --ai claude --script sh --ignore-agent-tools
```

PowerShell:
```powershell
$env:SPECIFY_REPO_OWNER = 'your-gh-username-or-org'
$env:SPECIFY_REPO_NAME = 'your-spec-kit-repo'
# Optional if private/rate-limited
$env:GH_TOKEN = 'ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXX'

specify init my-forked-test --ai claude --script ps --ignore-agent-tools
```

Notes:
- If no asset matches, the CLI prints the available asset names—verify your filenames include `spec-kit-template-{ai}-{script}` and end with `.zip`.
- Use `GITHUB_TOKEN` or `GH_TOKEN` for private forks or to avoid GitHub API rate limits.
- Clear the overrides to return to upstream defaults:
- Bash: `unset SPECIFY_REPO_OWNER SPECIFY_REPO_NAME`
- PowerShell: `Remove-Item Env:SPECIFY_REPO_OWNER,Env:SPECIFY_REPO_NAME -ErrorAction SilentlyContinue`
97 changes: 64 additions & 33 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,11 +431,11 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> bool:


def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]:
repo_owner = "github"
repo_name = "spec-kit"
repo_owner = os.environ.get("SPECIFY_REPO_OWNER") or "github"
repo_name = os.environ.get("SPECIFY_REPO_NAME") or "spec-kit"
if client is None:
client = httpx.Client(verify=ssl_context)

if verbose:
console.print("[cyan]Fetching latest release information...[/cyan]")
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
Expand Down Expand Up @@ -549,30 +549,58 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
current_dir = Path.cwd()

# Step: fetch + download combined
if tracker:
tracker.start("fetch", "contacting GitHub API")
try:
zip_path, meta = download_template_from_github(
ai_assistant,
current_dir,
script_type=script_type,
verbose=verbose and tracker is None,
show_progress=(tracker is None),
client=client,
debug=debug,
github_token=github_token
)
zip_path: Path
meta: dict
cleanup_required = True

# Env override for local testing: SPECIFY_TEMPLATE_ZIP=/path/to/spec-kit-template-*.zip
env_zip = os.environ.get("SPECIFY_TEMPLATE_ZIP")
if env_zip:
candidate = Path(env_zip).expanduser().resolve()
if not candidate.exists() or not candidate.is_file():
raise typer.Exit(f"SPECIFY_TEMPLATE_ZIP does not exist or is not a file: {candidate}")
if candidate.suffix.lower() != ".zip":
raise typer.Exit(f"SPECIFY_TEMPLATE_ZIP must point to a .zip file: {candidate}")
# Do not delete a user-provided local archive in cleanup
cleanup_required = False
zip_path = candidate
meta = {
"filename": candidate.name,
"size": candidate.stat().st_size,
"release": "ENV-OVERRIDE",
}
if tracker:
tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)")
tracker.start("fetch", "using SPECIFY_TEMPLATE_ZIP")
tracker.complete("fetch", "env override")
tracker.add("download", "Download template")
tracker.complete("download", meta['filename'])
except Exception as e:
tracker.complete("download", candidate.name)
elif verbose:
console.print(f"[cyan]Using local template ZIP:[/cyan] {candidate}")
else:
if tracker:
tracker.error("fetch", str(e))
else:
if verbose:
console.print(f"[red]Error downloading template:[/red] {e}")
raise
tracker.start("fetch", "contacting GitHub API")
try:
zip_path, meta = download_template_from_github(
ai_assistant,
current_dir,
script_type=script_type,
verbose=verbose and tracker is None,
show_progress=(tracker is None),
client=client,
debug=debug,
github_token=github_token,
)
if tracker:
tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)")
tracker.add("download", "Download template")
tracker.complete("download", meta['filename'])
except Exception as e:
if tracker:
tracker.error("fetch", str(e))
else:
if verbose:
console.print(f"[red]Error downloading template:[/red] {e}")
raise

if tracker:
tracker.add("extract", "Extract template")
Expand Down Expand Up @@ -687,16 +715,19 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
if tracker:
tracker.complete("extract")
finally:
if tracker:
tracker.add("cleanup", "Remove temporary archive")
# Clean up downloaded ZIP file
if zip_path.exists():
zip_path.unlink()
if cleanup_required:
if tracker:
tracker.complete("cleanup")
elif verbose:
console.print(f"Cleaned up: {zip_path.name}")

tracker.add("cleanup", "Remove temporary archive")
# Clean up downloaded ZIP file
if zip_path.exists():
zip_path.unlink()
if tracker:
tracker.complete("cleanup")
elif verbose:
console.print(f"Cleaned up: {zip_path.name}")
else:
tracker.complete("cleanup")

return project_path


Expand Down