Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
a63f64b69d chore: bump version to 0.8.1 2026-04-24 17:39:27 +00:00
20 changed files with 225 additions and 1849 deletions

View File

@@ -94,7 +94,7 @@ uv pip install -e .
# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing.
# Initialize a test project using your local changes
uv run specify init <temp-dir>/speckit-test --integration <agent>
uv run specify init <temp-dir>/speckit-test --ai <agent> --offline
cd <temp-dir>/speckit-test
# Open in your agent
@@ -102,7 +102,7 @@ cd <temp-dir>/speckit-test
#### Manual testing process
Any change that affects a slash command's behavior requires manually testing that command through a coding agent and submitting results with the PR.
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)).

View File

@@ -81,9 +81,9 @@ And use the tool directly:
specify init <PROJECT_NAME>
# Or initialize in existing project
specify init . --integration copilot
specify init . --ai copilot
# or
specify init --here --integration copilot
specify init --here --ai copilot
# Check installed tools
specify check
@@ -105,9 +105,9 @@ Run directly without installing:
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Or initialize in existing project
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot
# or
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
```
**Benefits of persistent installation:**
@@ -123,7 +123,7 @@ If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-G
### 2. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
@@ -228,11 +228,9 @@ The following community-contributed extensions are available in [`catalog.commun
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
@@ -257,7 +255,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
@@ -304,7 +301,7 @@ Run `specify integration list` to see all available integrations in your install
## Available Slash Commands
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai <agent> --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`.
#### Core Commands
@@ -477,37 +474,37 @@ specify init --here --force
![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif)
You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal:
You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal:
```bash
specify init <project_name> --integration copilot
specify init <project_name> --integration gemini
specify init <project_name> --integration codex
specify init <project_name> --ai copilot
specify init <project_name> --ai gemini
specify init <project_name> --ai copilot
# Or in current directory:
specify init . --integration copilot
specify init . --integration codex --integration-options="--skills"
specify init . --ai copilot
specify init . --ai codex --ai-skills
# or use --here flag
specify init --here --integration copilot
specify init --here --integration codex --integration-options="--skills"
specify init --here --ai copilot
specify init --here --ai codex --ai-skills
# Force merge into a non-empty current directory
specify init . --force --integration copilot
specify init . --force --ai copilot
# or
specify init --here --force --integration copilot
specify init --here --force --ai copilot
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --integration copilot --ignore-agent-tools
specify init <project_name> --ai copilot --ignore-agent-tools
```
### **STEP 1:** Establish project principles
Go to the project folder and run your coding agent. In our example, we're using `claude`.
Go to the project folder and run your AI agent. In our example, we're using `claude`.
![Bootstrapping Claude Code environment](./media/bootstrap-claude-code.gif)
@@ -519,7 +516,7 @@ The first step should be establishing your project's governing principles using
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements. Include governance for how these principles should guide technical decisions and implementation choices.
```
This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the coding agent will reference during specification, planning, and implementation phases.
This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the AI agent will reference during specification, planning, and implementation phases.
### **STEP 2:** Create project specifications
@@ -727,9 +724,9 @@ The `/speckit.implement` command will:
- Provide progress updates and handle errors appropriately
> [!IMPORTANT]
> The coding agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.
> The AI agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine.
Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your coding agent for resolution.
Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your AI agent for resolution.
</details>

View File

@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 1 script | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |

View File

@@ -39,16 +39,16 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init .
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
```
### Specify Integration
### Specify AI Agent
You can proactively specify your coding agent integration during initialization:
You can proactively specify your AI agent during initialization:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration gemini
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration codebuddy
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration pi
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai gemini
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai codebuddy
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai pi
```
### Specify Script Type (Shell vs PowerShell)
@@ -73,7 +73,7 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje
If you prefer to get the templates without checking for the right tools:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude --ignore-agent-tools
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --ai claude --ignore-agent-tools
```
## Verification
@@ -86,7 +86,7 @@ specify version
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
After initialization, you should see the following commands available in your coding agent:
After initialization, you should see the following commands available in your AI agent:
- `/speckit.specify` - Create specifications
- `/speckit.plan` - Generate implementation plans
@@ -131,10 +131,12 @@ pip install --no-index --find-links=./dist specify-cli
```bash
# Initialize a project — no GitHub access needed
specify init my-project --integration claude
specify init my-project --ai claude --offline
```
Bundled assets are used by default — no network access is required.
The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub.
> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box.
> **Note:** Python 3.11+ is required.

View File

@@ -20,7 +20,7 @@ You can execute the CLI via the module entrypoint without installing anything:
```bash
# From repo root
python -m src.specify_cli --help
python -m src.specify_cli init demo-project --integration claude --ignore-agent-tools --script sh
python -m src.specify_cli init demo-project --ai claude --ignore-agent-tools --script sh
```
If you prefer invoking the script file style (uses shebang):
@@ -52,7 +52,7 @@ Re-running after code edits requires no reinstall because of editable mode.
`uvx` can run from a local path (or a Git ref) to simulate user flows:
```bash
uvx --from . specify init demo-uvx --integration copilot --ignore-agent-tools --script sh
uvx --from . specify init demo-uvx --ai copilot --ignore-agent-tools --script sh
```
You can also point uvx at a specific branch without merging:
@@ -69,14 +69,14 @@ If you're in another directory, use an absolute path instead of `.`:
```bash
uvx --from /mnt/c/GitHub/spec-kit specify --help
uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --integration copilot --ignore-agent-tools --script sh
uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --ai copilot --ignore-agent-tools --script sh
```
Set an environment variable for convenience:
```bash
export SPEC_KIT_SRC=/mnt/c/GitHub/spec-kit
uvx --from "$SPEC_KIT_SRC" specify init demo-env --integration copilot --ignore-agent-tools --script ps
uvx --from "$SPEC_KIT_SRC" specify init demo-env --ai copilot --ignore-agent-tools --script ps
```
(Optional) Define a shell function:
@@ -123,7 +123,7 @@ When testing `init --here` in a dirty directory, create a temp workspace:
```bash
mkdir /tmp/spec-test && cd /tmp/spec-test
python -m src.specify_cli init --here --integration claude --ignore-agent-tools --script sh # if repo copied here
python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script sh # if repo copied here
```
Or copy only the modified CLI portion if you want a lighter sandbox.

View File

@@ -42,7 +42,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
### Step 2: Define Your Constitution
**In your coding agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.
**In your AI Agent's chat interface**, use the `/speckit.constitution` slash command to establish the core rules and principles for your project. You should provide your project's specific principles as arguments.
```markdown
/speckit.constitution This project follows a "Library-First" approach. All features must be implemented as standalone libraries first. We use TDD strictly. We prefer functional programming patterns.
@@ -159,7 +159,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
### Step 7: Validate and Implement
Have your coding agent audit the implementation plan using `/speckit.analyze`:
Have your AI agent audit the implementation plan using `/speckit.analyze`:
```bash
/speckit.analyze
@@ -180,7 +180,7 @@ Finally, implement the solution:
- **Don't focus on tech stack** during specification phase
- **Iterate and refine** your specifications before implementation
- **Validate** the plan before coding begins
- **Let the coding agent handle** the implementation details
- **Let the AI agent handle** the implementation details
## Next Steps

View File

@@ -10,7 +10,7 @@
|----------------|---------|-------------|
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Project Files** | `specify init --here --force --ai <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
---
@@ -32,7 +32,7 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki
Specify the desired release tag:
```bash
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
```
### If you installed with `pipx`
@@ -82,7 +82,7 @@ The `specs/` directory is completely excluded from template packages and will ne
Run this inside your project directory:
```bash
specify init --here --force --integration <your-agent>
specify init --here --force --ai <your-agent>
```
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
@@ -90,7 +90,7 @@ Replace `<your-agent>` with your AI coding agent. Refer to this list of [Support
**Example:**
```bash
specify init --here --force --integration copilot
specify init --here --force --ai copilot
```
### Understanding the `--force` flag
@@ -124,7 +124,7 @@ Without `--force`, shared infrastructure files that already exist are skipped
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
# 2. Run the upgrade
specify init --here --force --integration copilot
specify init --here --force --ai copilot
# 3. Restore your customized constitution
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
@@ -182,7 +182,7 @@ Restart your IDE to refresh the command list.
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
# Update project files to get new commands
specify init --here --force --integration copilot
specify init --here --force --ai copilot
# Restore your constitution if customized
git restore .specify/memory/constitution.md
@@ -199,7 +199,7 @@ cp -r .specify/templates /tmp/templates-backup
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
# 3. Update project
specify init --here --force --integration copilot
specify init --here --force --ai copilot
# 4. Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md
@@ -232,7 +232,7 @@ If you initialized your project with `--no-git`, you can still upgrade:
cp .specify/memory/constitution.md /tmp/constitution-backup.md
# Run upgrade
specify init --here --force --integration copilot --no-git
specify init --here --force --ai copilot --no-git
# Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md
@@ -253,13 +253,13 @@ The `--no-git` flag tells Spec Kit to **skip git repository initialization**. Th
**During initial setup:**
```bash
specify init my-project --integration copilot --no-git
specify init my-project --ai copilot --no-git
```
**During upgrade:**
```bash
specify init --here --force --integration copilot --no-git
specify init --here --force --ai copilot --no-git
```
### What `--no-git` does NOT do
@@ -367,7 +367,7 @@ Only Spec Kit infrastructure files:
- **Use `--force` flag** - Skip this confirmation entirely:
```bash
specify init --here --force --integration copilot
specify init --here --force --ai copilot
```
**When you see this warning:**

View File

@@ -153,7 +153,7 @@ This will:
2. Validate the manifest
3. Check compatibility with your spec-kit version
4. Install to `.specify/extensions/jira/`
5. Register commands with your coding agent
5. Register commands with your AI agent
6. Create config template
### Install from URL
@@ -189,7 +189,7 @@ Provided commands:
### Automatic Agent Skill Registration
If your project uses a skills-based integration (e.g., `--integration claude`, `--integration codex`) or was initialized with `--integration-options="--skills"`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
```text
✓ Extension installed successfully!
@@ -208,7 +208,7 @@ When an extension is removed, its corresponding skills are also cleaned up autom
### Using Extension Commands
Extensions add commands that appear in your coding agent (Claude Code):
Extensions add commands that appear in your AI agent (Claude Code):
```text
# In Claude Code
@@ -423,7 +423,7 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`),
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or extension ZIPs are hosted in a private GitHub repository. | None |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None |
#### Example: Using a custom catalog for testing
@@ -435,21 +435,6 @@ export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json"
export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json"
```
#### Example: Using a private GitHub-hosted catalog
```bash
# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI)
export GITHUB_TOKEN=$(gh auth token)
# Search a private catalog added via `specify extension catalog add`
specify extension search jira
# Install from a private catalog
specify extension add jira-sync
```
The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials.
---
## Extension Catalogs
@@ -795,12 +780,12 @@ specify extension add --dev /path/to/extension
### Command Not Available
**Issue**: Extension command not appearing in coding agent
**Issue**: Extension command not appearing in AI agent
**Solutions**:
1. Check extension is enabled: `specify extension list`
2. Restart coding agent (Claude Code)
2. Restart AI agent (Claude Code)
3. Check command file exists:
```bash
@@ -834,8 +819,8 @@ specify extension add --dev /path/to/extension
**Solutions**:
1. Check MCP server is installed
2. Check coding agent MCP configuration
3. Restart coding agent
2. Check AI agent MCP configuration
3. Restart AI agent
4. Check extension requirements: `specify extension info jira`
### Permission Denied

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-24T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -657,18 +657,18 @@
"id": "extensify",
"description": "Create and validate extensions and extension catalogs.",
"author": "mnriem",
"version": "1.1.0",
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.1.0/extensify.zip",
"version": "1.0.0",
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip",
"repository": "https://github.com/mnriem/spec-kit-extensions",
"homepage": "https://github.com/mnriem/spec-kit-extensions",
"documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md",
"changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 5,
"commands": 4,
"hooks": 0
},
"tags": [
@@ -681,7 +681,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z"
"updated_at": "2026-03-18T00:00:00Z"
},
"fix-findings": {
"name": "Fix Findings",
@@ -941,44 +941,6 @@
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z"
},
"m365": {
"name": "Microsoft 365 Integration",
"id": "m365",
"description": "Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation.",
"author": "BenBtg",
"version": "1.0.0",
"download_url": "https://github.com/BenBtg/spec-kit-m365/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/BenBtg/spec-kit-m365",
"homepage": "https://github.com/BenBtg/spec-kit-m365",
"documentation": "https://github.com/BenBtg/spec-kit-m365/blob/main/README.md",
"changelog": "https://github.com/BenBtg/spec-kit-m365/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "m365",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"microsoft-365",
"teams",
"transcripts",
"collaboration",
"summarization"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z"
},
"maqa": {
"name": "MAQA — Multi-Agent & Quality Assurance",
"id": "maqa",
@@ -1205,45 +1167,6 @@
"created_at": "2026-03-26T00:00:00Z",
"updated_at": "2026-03-26T00:00:00Z"
},
"markitdown": {
"name": "MarkItDown Document Converter",
"id": "markitdown",
"description": "Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material in Spec Kit workflows.",
"author": "BenBtg",
"version": "1.0.0",
"download_url": "https://github.com/BenBtg/spec-kit-markitdown/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/BenBtg/spec-kit-markitdown",
"homepage": "https://github.com/BenBtg/spec-kit-markitdown",
"documentation": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/README.md",
"changelog": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "markitdown",
"version": ">=0.1.0",
"required": true
}
]
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"markdown",
"pdf",
"document-conversion",
"reference-material",
"extraction"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z"
},
"memory-loader": {
"name": "Memory Loader",
"id": "memory-loader",
@@ -1404,38 +1327,6 @@
"created_at": "2026-04-03T00:00:00Z",
"updated_at": "2026-04-03T00:00:00Z"
},
"orchestrator": {
"name": "Spec Orchestrator",
"id": "orchestrator",
"description": "Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-orchestrator",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-orchestrator",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/releases",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"orchestration",
"multi-feature",
"coordination",
"workflow",
"parallel"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-24T14:00:00Z",
"updated_at": "2026-04-24T14:00:00Z"
},
"plan-review-gate": {
"name": "Plan Review Gate",
"id": "plan-review-gate",

View File

@@ -123,25 +123,9 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `SPECKIT_PRESET_CATALOG_URL` | Override the full catalog stack with a single URL (replaces all defaults) | Built-in default stack |
| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or preset ZIPs are hosted in a private GitHub repository. | None |
#### Example: Using a private GitHub-hosted catalog
```bash
# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI)
export GITHUB_TOKEN=$(gh auth token)
# Search a private catalog added via `specify preset catalog add`
specify preset search my-template
# Install from a private catalog
specify preset add my-template
```
The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials.
| Variable | Description |
|----------|-------------|
| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
## Configuration Files

View File

@@ -108,11 +108,11 @@
"fiction-book-writing": {
"name": "Fiction Book Writing",
"id": "fiction-book-writing",
"version": "1.7.0",
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"version": "1.6.0",
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.zip",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT",
@@ -122,7 +122,7 @@
"provides": {
"templates": 22,
"commands": 27,
"scripts": 2
"scripts": 1
},
"tags": [
"writing",
@@ -140,7 +140,7 @@
"language-support"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-27T08:00:00Z"
"updated_at": "2026-04-19T08:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.8.2.dev0"
version = "0.8.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 = [

View File

@@ -127,7 +127,7 @@ def _build_ai_deprecation_warning(
ai_commands_dir=ai_commands_dir,
)
return (
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
"[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n"
f"Use [bold]{replacement}[/bold] instead."
)
@@ -967,7 +967,7 @@ def init(
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
@@ -997,28 +997,29 @@ def init(
This command will:
1. Check that required tools are installed (git is optional)
2. Let you choose your coding agent integration
2. Let you choose your AI assistant
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 coding agent integration commands
5. Optionally set up AI assistant commands
Examples:
specify init my-project
specify init my-project --integration claude
specify init my-project --integration copilot --no-git
specify init my-project --ai claude
specify init my-project --ai copilot --no-git
specify init --ignore-agent-tools my-project
specify init . --integration claude # Initialize in current directory
specify init . # Initialize in current directory (interactive integration selection)
specify init --here --integration claude # Alternative syntax for current directory
specify init --here --integration codex --integration-options="--skills"
specify init --here --integration codebuddy
specify init --here --integration vibe # Initialize with Mistral Vibe support
specify init . --ai claude # Initialize in current directory
specify init . # Initialize in current directory (interactive AI selection)
specify init --here --ai claude # Alternative syntax for current directory
specify init --here --ai codex --ai-skills
specify init --here --ai codebuddy
specify init --here --ai vibe # Initialize with Mistral Vibe support
specify init --here
specify init --here --force # Skip confirmation when current directory not empty
specify init my-project --integration claude # Claude installs skills by default
specify init --here --integration gemini
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
specify init my-project --integration claude --preset healthcare-compliance # With preset
specify init my-project --ai claude # Claude installs skills by default
specify init --here --ai gemini --ai-skills
specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent
specify init my-project --offline # Use bundled assets (no network access)
specify init my-project --ai claude --preset healthcare-compliance # With preset
"""
show_banner()
@@ -1028,14 +1029,14 @@ def init(
if ai_assistant and ai_assistant.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
console.print("[yellow]Example:[/yellow] specify init --ai claude --here")
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
raise typer.Exit(1)
if ai_commands_dir and ai_commands_dir.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/")
raise typer.Exit(1)
if ai_assistant:
@@ -1087,13 +1088,6 @@ def init(
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
)
if no_git:
console.print(
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
"[yellow]The git extension will no longer be enabled by default "
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
)
if project_name == ".":
here = True
project_name = None # Clear project_name to use existing validation logic
@@ -1169,7 +1163,7 @@ def init(
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
selected_ai = select_with_arrows(
ai_choices,
"Choose your coding agent integration:",
"Choose your AI assistant:",
"copilot"
)
@@ -1240,7 +1234,7 @@ def init(
else:
selected_script = default_script
console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}")
console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}")
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
tracker = StepTracker("Initialize Specify Project")
@@ -1249,7 +1243,7 @@ def init(
tracker.add("precheck", "Check required tools")
tracker.complete("precheck", "ok")
tracker.add("ai-select", "Select coding agent integration")
tracker.add("ai-select", "Select AI assistant")
tracker.complete("ai-select", f"{selected_ai}")
tracker.add("script-select", "Select script type")
tracker.complete("script-select", selected_script)
@@ -1564,7 +1558,7 @@ def init(
return f"/speckit-{name}"
return f"/speckit.{name}"
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
@@ -1635,7 +1629,7 @@ def check():
console.print("[dim]Tip: Install git for repository management[/dim]")
if not any(agent_results.values()):
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]")
@app.command()
def version():
@@ -1881,7 +1875,7 @@ def get_speckit_version() -> str:
integration_app = typer.Typer(
name="integration",
help="Manage coding agent integrations",
help="Manage AI agent integrations",
add_completion=False,
)
app.add_typer(integration_app, name="integration")
@@ -2019,7 +2013,7 @@ def integration_list(
console.print(table)
return
table = Table(title="Coding Agent Integrations")
table = Table(title="AI Agent Integrations")
table.add_column("Key", style="cyan")
table.add_column("Name")
table.add_column("Status")
@@ -2576,7 +2570,7 @@ def preset_list():
@preset_app.command("add")
def preset_add(
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP or .tar.gz/.tgz archive)"),
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
@@ -2629,46 +2623,17 @@ def preset_add(
import urllib.request
import urllib.error
import tempfile
from .extensions import detect_archive_format as _det_fmt
with tempfile.TemporaryDirectory() as tmpdir:
final_url = from_url
archive_fmt = ""
zip_path = Path(tmpdir) / "preset.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
final_url = response.geturl()
# Re-validate scheme after any redirect (scheme-downgrade
# guard). Check BEFORE reading the body so an insecure
# redirect cannot cause us to fetch the payload.
_fp = _urlparse(final_url)
_fl = _fp.hostname in ("localhost", "127.0.0.1", "::1")
if _fp.scheme != "https" and not (_fp.scheme == "http" and _fl):
console.print(f"[red]Error:[/red] URL was redirected to a non-HTTPS URL: {final_url}")
raise typer.Exit(1)
content_type = response.headers.get("Content-Type", "")
# Prefer the post-redirect URL for format detection;
# fall back to the original URL only as a last hint.
archive_fmt = _det_fmt(final_url, content_type)
if not archive_fmt:
archive_fmt = _det_fmt(from_url)
archive_data = response.read()
zip_path.write_bytes(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}")
raise typer.Exit(1)
if not archive_fmt:
console.print("[red]Error:[/red] Could not determine archive format from URL or Content-Type.")
console.print("Ensure the URL points to a .zip or .tar.gz/.tgz file.")
raise typer.Exit(1)
suffix = ".tar.gz" if archive_fmt == "tar.gz" else ".zip"
archive_path = Path(tmpdir) / f"preset{suffix}"
try:
archive_path.write_bytes(archive_data)
manifest = manager.install_from_zip(archive_path, speckit_version, priority)
except OSError as e:
console.print(f"[red]Error:[/red] Failed to save or install archive: {e}")
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
@@ -3602,7 +3567,7 @@ def catalog_remove(
def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL (ZIP or .tar.gz/.tgz archive)"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
@@ -3641,11 +3606,10 @@ def extension_add(
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
elif from_url:
# Install from URL (ZIP or tar.gz archive)
# Install from URL (ZIP file)
import urllib.request
import urllib.error
from urllib.parse import urlparse
from .extensions import detect_archive_format
# Validate URL
parsed = urlparse(from_url)
@@ -3661,53 +3625,25 @@ def extension_add(
console.print("Only install extensions from sources you trust.\n")
console.print(f"Downloading from {from_url}...")
# Download archive to temp location; detect format from the
# post-redirect URL (with Content-Type fallback), only using
# the original URL as a last hint.
# Download ZIP to temp location
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
archive_fmt = ""
archive_path = None
zip_path = download_dir / f"{extension}-url-download.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
final_url = response.geturl()
# Re-validate scheme after any redirect (scheme-downgrade
# guard). Check BEFORE reading the body so an insecure
# redirect cannot cause us to fetch the payload.
_fp = urlparse(final_url)
_fl = _fp.hostname in ("localhost", "127.0.0.1", "::1")
if _fp.scheme != "https" and not (_fp.scheme == "http" and _fl):
console.print(f"[red]Error:[/red] URL was redirected to a non-HTTPS URL: {final_url}")
raise typer.Exit(1)
content_type = response.headers.get("Content-Type", "")
archive_fmt = detect_archive_format(final_url, content_type)
if not archive_fmt:
archive_fmt = detect_archive_format(from_url)
archive_data = response.read()
zip_data = response.read()
zip_path.write_bytes(zip_data)
if not archive_fmt:
console.print("[red]Error:[/red] Could not determine archive format from URL or Content-Type.")
console.print("Ensure the URL points to a .zip or .tar.gz/.tgz file.")
raise typer.Exit(1)
suffix = ".tar.gz" if archive_fmt == "tar.gz" else ".zip"
safe_name = Path(extension).name or "extension"
archive_path = download_dir / f"{safe_name}-url-download{suffix}"
archive_path.write_bytes(archive_data)
# Install from downloaded archive
manifest = manager.install_from_zip(archive_path, speckit_version, priority=priority)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
raise typer.Exit(1)
except OSError as e:
console.print(f"[red]Error:[/red] Failed to save or install archive: {e}")
raise typer.Exit(1)
finally:
# Clean up the downloaded archive
if archive_path is not None and archive_path.exists():
archive_path.unlink()
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
else:
# Try bundled extensions first (shipped with spec-kit)
@@ -4359,55 +4295,29 @@ def extension_update(
backup_hooks[hook_name] = ext_hooks
# 5. Download new version
archive_path = catalog.download_extension(extension_id)
zip_path = catalog.download_extension(extension_id)
try:
# 6. Validate extension ID from archive BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated archives)
from .extensions import detect_archive_format
import tarfile
archive_fmt = detect_archive_format(str(archive_path))
import yaml
manifest_data = None
# 6. Validate extension ID from ZIP BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
with zipfile.ZipFile(zip_path, "r") as zf:
import yaml
manifest_data = None
namelist = zf.namelist()
if archive_fmt == "tar.gz":
with tarfile.open(archive_path, "r:gz") as tf:
# First try root-level extension.yml
try:
m = tf.getmember("extension.yml")
f = tf.extractfile(m)
if f is not None:
with f:
manifest_data = yaml.safe_load(f.read()) or {}
except KeyError:
# extension.yml not present at archive root; use nested fallback below.
manifest_data = None
# Fall back to nested-directory search if root-level
# was missing (KeyError) or not a regular file (None).
if manifest_data is None:
members = [m for m in tf.getmembers() if m.name.endswith("/extension.yml") and m.name.count("/") == 1]
if len(members) == 1:
f = tf.extractfile(members[0])
if f is not None:
with f:
manifest_data = yaml.safe_load(f.read()) or {}
else:
with zipfile.ZipFile(archive_path, "r") as zf:
namelist = zf.namelist()
# First try root-level extension.yml
if "extension.yml" in namelist:
with zf.open("extension.yml") as f:
# First try root-level extension.yml
if "extension.yml" in namelist:
with zf.open("extension.yml") as f:
manifest_data = yaml.safe_load(f) or {}
else:
# Look for extension.yml in a single top-level subdirectory
# (e.g., "repo-name-branch/extension.yml")
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
if len(manifest_paths) == 1:
with zf.open(manifest_paths[0]) as f:
manifest_data = yaml.safe_load(f) or {}
else:
# Look for extension.yml in a single top-level subdirectory
# (e.g., "repo-name-branch/extension.yml")
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
if len(manifest_paths) == 1:
with zf.open(manifest_paths[0]) as f:
manifest_data = yaml.safe_load(f) or {}
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
zip_extension_id = manifest_data.get("extension", {}).get("id")
if zip_extension_id != extension_id:
@@ -4419,7 +4329,7 @@ def extension_update(
manager.remove(extension_id, keep_config=True)
# 8. Install new version
_ = manager.install_from_zip(archive_path, speckit_version)
_ = manager.install_from_zip(zip_path, speckit_version)
# Restore user config files from backup after successful install.
new_extension_dir = manager.extensions_dir / extension_id
@@ -4465,9 +4375,9 @@ def extension_update(
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
# Clean up downloaded archive
if archive_path.exists():
archive_path.unlink()
# Clean up downloaded ZIP
if zip_path.exists():
zip_path.unlink()
# 10. Clean up backup on success
if backup_base.exists():
@@ -4959,59 +4869,6 @@ def workflow_list():
console.print()
def _extract_workflow_yml(archive_path: Path, archive_fmt: str) -> bytes:
"""Extract ``workflow.yml`` from a ZIP or ``.tar.gz`` archive.
Searches the archive root and a single nested top-level subdirectory
(e.g., ``repo-name-1.0/workflow.yml``).
Args:
archive_path: Path to the downloaded archive.
archive_fmt: ``"zip"`` or ``"tar.gz"``.
Returns:
Raw bytes of the ``workflow.yml`` file.
Raises:
ValueError: If no ``workflow.yml`` is found in the archive.
"""
import tarfile
if archive_fmt == "tar.gz":
with tarfile.open(archive_path, "r:gz") as tf:
# Try root-level first.
try:
f = tf.extractfile(tf.getmember("workflow.yml"))
if f is not None:
with f:
return f.read()
except KeyError:
pass # Root-level workflow.yml not found; fall through to subdirectory search below.
# Look in a single top-level subdirectory.
candidates = [
m for m in tf.getmembers()
if m.name.endswith("/workflow.yml") and m.name.count("/") == 1
]
if len(candidates) == 1:
f = tf.extractfile(candidates[0])
if f is not None:
with f:
return f.read()
else:
with zipfile.ZipFile(archive_path, "r") as zf:
namelist = zf.namelist()
if "workflow.yml" in namelist:
return zf.read("workflow.yml")
candidates = [
n for n in namelist
if n.endswith("/workflow.yml") and n.count("/") == 1
]
if len(candidates) == 1:
return zf.read(candidates[0])
raise ValueError("No workflow.yml found in the downloaded archive")
@workflow_app.command("add")
def workflow_add(
source: str = typer.Argument(..., help="Workflow ID, URL, or local path"),
@@ -5065,7 +4922,6 @@ def workflow_add(
from ipaddress import ip_address
from urllib.parse import urlparse
from urllib.request import urlopen # noqa: S310
from .extensions import detect_archive_format
parsed_src = urlparse(source)
src_host = parsed_src.hostname or ""
@@ -5096,53 +4952,18 @@ def workflow_add(
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}")
raise typer.Exit(1)
# Detect archive format from the final URL or Content-Type header.
archive_fmt = detect_archive_format(final_url)
if not archive_fmt:
content_type = resp.headers.get("Content-Type", "")
archive_fmt = detect_archive_format(final_url, content_type)
raw_data = resp.read()
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
tmp.write(resp.read())
tmp_path = Path(tmp.name)
except typer.Exit:
raise
except Exception as exc:
console.print(f"[red]Error:[/red] Failed to download workflow: {exc}")
raise typer.Exit(1)
tmp_path = None
try:
if archive_fmt in ("tar.gz", "zip"):
# Extract workflow.yml from the archive.
suffix = ".tar.gz" if archive_fmt == "tar.gz" else ".zip"
arc_tmp_path = None
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as arc_tmp:
arc_tmp_path = Path(arc_tmp.name)
arc_tmp.write(raw_data)
try:
wf_yaml = _extract_workflow_yml(arc_tmp_path, archive_fmt)
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
tmp_path = Path(tmp.name)
tmp.write(wf_yaml)
finally:
if arc_tmp_path is not None:
arc_tmp_path.unlink(missing_ok=True)
else:
# Treat as a plain YAML file (existing behaviour).
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
tmp.write(raw_data)
tmp_path = Path(tmp.name)
except typer.Exit:
raise
except Exception as exc:
console.print(f"[red]Error:[/red] Failed to process downloaded workflow: {exc}")
raise typer.Exit(1)
try:
_validate_and_install_local(tmp_path, source)
finally:
if tmp_path is not None:
tmp_path.unlink(missing_ok=True)
tmp_path.unlink(missing_ok=True)
return
# Try as a local file/directory
@@ -5151,27 +4972,6 @@ def workflow_add(
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
_validate_and_install_local(source_path, str(source_path))
return
elif source_path.is_file() and (
source.lower().endswith(".tar.gz") or source.lower().endswith(".tgz") or source.lower().endswith(".zip")
):
# Local archive file containing workflow.yml
from .extensions import detect_archive_format
local_fmt = detect_archive_format(source)
try:
wf_yaml = _extract_workflow_yml(source_path, local_fmt)
except Exception as exc:
console.print(f"[red]Error:[/red] Failed to extract workflow from archive: {exc}")
raise typer.Exit(1)
import tempfile
tmp_local = None
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
tmp_local = Path(tmp.name)
tmp.write(wf_yaml)
try:
_validate_and_install_local(tmp_local, str(source_path))
finally:
tmp_local.unlink(missing_ok=True)
return
elif source_path.is_dir():
wf_file = source_path / "workflow.yml"
if not wf_file.exists():
@@ -5235,7 +5035,6 @@ def workflow_add(
try:
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
from .extensions import detect_archive_format
workflow_dir.mkdir(parents=True, exist_ok=True)
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
@@ -5258,32 +5057,7 @@ def workflow_add(
f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}"
)
raise typer.Exit(1)
# Detect archive format from the final URL or Content-Type header.
cat_archive_fmt = detect_archive_format(final_url)
if not cat_archive_fmt:
cat_ct = response.headers.get("Content-Type", "")
cat_archive_fmt = detect_archive_format(final_url, cat_ct)
raw_response = response.read()
if cat_archive_fmt in ("tar.gz", "zip"):
# Download URL points to an archive — extract workflow.yml from it.
suffix = ".tar.gz" if cat_archive_fmt == "tar.gz" else ".zip"
arc_tmp = None
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as arc_f:
arc_tmp = Path(arc_f.name)
arc_f.write(raw_response)
try:
wf_yaml_bytes = _extract_workflow_yml(arc_tmp, cat_archive_fmt)
finally:
if arc_tmp is not None:
arc_tmp.unlink(missing_ok=True)
workflow_file.write_bytes(wf_yaml_bytes)
else:
workflow_file.write_bytes(raw_response)
except typer.Exit:
raise
workflow_file.write_bytes(response.read())
except Exception as exc:
if workflow_dir.exists():
import shutil

View File

@@ -1,80 +0,0 @@
"""Shared GitHub-authenticated HTTP helpers.
Used by both ExtensionCatalog and PresetCatalog to attach
GITHUB_TOKEN / GH_TOKEN credentials to requests targeting
GitHub-hosted domains, while preventing token leakage to
third-party hosts on redirects.
"""
import os
import urllib.request
from urllib.parse import urlparse
from typing import Dict
# GitHub-owned hostnames that should receive the Authorization header.
# Includes codeload.github.com because GitHub archive URL downloads
# (e.g. /archive/refs/tags/<tag>.zip) redirect there and require auth
# for private repositories.
GITHUB_HOSTS = frozenset({
"raw.githubusercontent.com",
"github.com",
"api.github.com",
"codeload.github.com",
})
def build_github_request(url: str) -> urllib.request.Request:
"""Build a urllib Request, adding a GitHub auth header when available.
Reads GITHUB_TOKEN or GH_TOKEN from the environment and attaches an
``Authorization: Bearer <value>`` header when the target hostname is one
of the known GitHub-owned domains. Non-GitHub URLs are returned as plain
requests so credentials are never leaked to third-party hosts.
"""
headers: Dict[str, str] = {}
github_token = (os.environ.get("GITHUB_TOKEN") or "").strip()
gh_token = (os.environ.get("GH_TOKEN") or "").strip()
token = github_token or gh_token or None
hostname = (urlparse(url).hostname or "").lower()
if token and hostname in GITHUB_HOSTS:
headers["Authorization"] = f"Bearer {token}"
return urllib.request.Request(url, headers=headers)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Redirect handler that drops the Authorization header when leaving GitHub.
Prevents token leakage to CDNs or other third-party hosts that GitHub
may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com).
Auth is preserved as long as the redirect target remains within GITHUB_HOSTS.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
original_auth = req.get_header("Authorization")
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
hostname = (urlparse(newurl).hostname or "").lower()
if hostname in GITHUB_HOSTS:
if original_auth:
new_req.add_unredirected_header("Authorization", original_auth)
else:
new_req.headers.pop("Authorization", None)
new_req.unredirected_hdrs.pop("Authorization", None)
return new_req
def open_github_url(url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
When the request carries an Authorization header, a custom redirect
handler drops that header if the redirect target is not a GitHub-owned
domain, preventing token leakage to CDNs or other third-party hosts
that GitHub may redirect to (e.g. S3 for release asset downloads).
"""
req = build_github_request(url)
if not req.get_header("Authorization"):
return urllib.request.urlopen(req, timeout=timeout)
opener = urllib.request.build_opener(_StripAuthOnRedirect)
return opener.open(req, timeout=timeout)

View File

@@ -9,8 +9,6 @@ without bloating the core framework.
import json
import hashlib
import os
import sys
import tarfile
import tempfile
import zipfile
import shutil
@@ -108,137 +106,6 @@ def normalize_priority(value: Any, default: int = 10) -> int:
return priority if priority >= 1 else default
def detect_archive_format(url: str, content_type: str = "") -> str:
"""Detect archive format from URL path extension or Content-Type header.
Args:
url: URL or file path to inspect.
content_type: Optional ``Content-Type`` header value from the HTTP response.
Returns:
``"zip"`` for ZIP archives, ``"tar.gz"`` for gzipped tarballs, or ``""``
when the format cannot be determined.
"""
# Strip query-string / fragment before examining the path extension.
url_path = url.split("?")[0].split("#")[0].lower()
if url_path.endswith(".zip"):
return "zip"
if url_path.endswith(".tar.gz") or url_path.endswith(".tgz"):
return "tar.gz"
# Fall back to Content-Type header inspection.
ct = content_type.lower()
if "application/zip" in ct or "application/x-zip" in ct:
return "zip"
if any(
t in ct
for t in (
"application/gzip",
"application/x-gzip",
"application/x-tar+gzip",
)
):
return "tar.gz"
return ""
def safe_extract_tarball(
archive_path: Path,
dest_dir: Path,
error_class: "type[Exception]" = Exception,
) -> None:
"""Safely extract a ``.tar.gz`` or ``.tgz`` archive into *dest_dir*.
All members are validated before extraction to prevent *tar slip*
(path traversal) attacks. Symlinks, hard links, and special files
(devices, FIFOs, etc.) are rejected.
On Python 3.12 and later the ``"data"`` extraction filter is applied
for an additional layer of OS-level protection. On earlier versions
the explicit member list (containing only pre-validated regular files
and directories) is passed to ``extractall()`` — since all symlinks are
already rejected in the validation phase, no archive-introduced symlink
can be followed during extraction.
Args:
archive_path: Path to the ``.tar.gz``/``.tgz`` archive.
dest_dir: Destination directory (must already exist).
error_class: Exception class to raise on unsafe entries.
Raises:
error_class: If any member is unsafe or the archive cannot be read.
"""
dest_resolved = dest_dir.resolve()
# Tar metadata member types to skip during validation — they carry no
# extractable payload and are generated automatically by many common
# archiving tools (e.g. PAX headers, GNU longname/longlink entries).
# GNUTYPE_SPARSE is intentionally excluded: it carries a real file payload
# and tarfile.TarInfo.isreg() returns True for it, so it passes the
# regular-file check below and is extracted correctly.
_TAR_METADATA_TYPES = (
tarfile.XHDTYPE, # PAX extended header
tarfile.XGLTYPE, # PAX global extended header
tarfile.SOLARIS_XHDTYPE, # Solaris PAX extended header
tarfile.GNUTYPE_LONGNAME, # GNU long path name (metadata only)
tarfile.GNUTYPE_LONGLINK, # GNU long link name (metadata only)
)
try:
with tarfile.open(archive_path, "r:gz") as tf:
members = tf.getmembers()
safe_members = []
# Validate every member before extracting anything.
for member in members:
# Reject absolute paths and any path component that is "..".
if os.path.isabs(member.name) or any(
part == ".." for part in member.name.replace("\\", "/").split("/")
):
raise error_class(
f"Unsafe path in tar archive: {member.name} (potential path traversal)"
)
# Confirm the resolved path stays inside dest_dir.
member_path = (dest_dir / member.name).resolve()
try:
member_path.relative_to(dest_resolved)
except ValueError:
raise error_class(
f"Unsafe path in tar archive: {member.name} (potential path traversal)"
)
# Skip tar metadata members — they carry no extractable payload.
if member.type in _TAR_METADATA_TYPES:
continue
# Reject symlinks and hard links.
if member.issym() or member.islnk():
raise error_class(
f"Symlinks are not allowed in archive: {member.name}"
)
# Reject devices, FIFOs and other special file types.
if not (member.isreg() or member.isdir()):
raise error_class(
f"Non-regular file in archive: {member.name}"
)
safe_members.append(member)
# Extract — use the "data" filter on Python 3.12+ for extra hardening.
# On all versions pass only the pre-validated members so that no
# unvetted entry (added concurrently or via a race) slips through.
if sys.version_info >= (3, 12):
tf.extractall(dest_dir, members=safe_members, filter="data") # type: ignore[call-arg]
else:
tf.extractall(dest_dir, members=safe_members) # noqa: S202 — validated above
except error_class:
raise
except (tarfile.TarError, OSError) as e:
raise error_class(f"Failed to read archive {archive_path}: {e}") from e
@dataclass
class CatalogEntry:
"""Represents a single catalog entry in the catalog stack."""
@@ -272,18 +139,12 @@ class ExtensionManifest:
def _load_yaml(self, path: Path) -> dict:
"""Load YAML file safely."""
try:
with open(path, 'r', encoding='utf-8') as f:
with open(path, 'r') as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ValidationError(f"Invalid YAML in {path}: {e}")
except FileNotFoundError:
raise ValidationError(f"Manifest not found: {path}")
except UnicodeDecodeError as e:
raise ValidationError(
f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})"
)
except OSError as e:
raise ValidationError(f"Could not read manifest {path}: {e}")
if not isinstance(data, dict):
raise ValidationError(
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
@@ -1335,10 +1196,10 @@ class ExtensionManager:
speckit_version: str,
priority: int = 10,
) -> ExtensionManifest:
"""Install extension from a ZIP or ``.tar.gz``/``.tgz`` archive.
"""Install extension from ZIP file.
Args:
zip_path: Path to the extension archive (ZIP or gzipped tarball).
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
@@ -1346,8 +1207,7 @@ class ExtensionManager:
Installed extension manifest
Raises:
ValidationError: If manifest is invalid, the archive is unsafe, or
priority is invalid
ValidationError: If manifest is invalid or priority is invalid
CompatibilityError: If extension is incompatible
"""
# Validate priority early
@@ -1357,27 +1217,21 @@ class ExtensionManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
archive_fmt = detect_archive_format(str(zip_path))
if archive_fmt == "tar.gz":
# Extract tarball safely (prevent tar slip attack)
safe_extract_tarball(zip_path, temp_path, ValidationError)
else:
# Extract ZIP safely (prevent Zip Slip attack)
with zipfile.ZipFile(zip_path, 'r') as zf:
# Validate all paths first before extracting anything
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
# Use is_relative_to for safe path containment check
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise ValidationError(
f"Unsafe path in ZIP archive: {member} (potential path traversal)"
)
# Only extract after all paths are validated
zf.extractall(temp_path)
# Extract ZIP safely (prevent Zip Slip attack)
with zipfile.ZipFile(zip_path, 'r') as zf:
# Validate all paths first before extracting anything
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
# Use is_relative_to for safe path containment check
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise ValidationError(
f"Unsafe path in ZIP archive: {member} (potential path traversal)"
)
# Only extract after all paths are validated
zf.extractall(temp_path)
# Find extension directory (may be nested)
extension_dir = temp_path
@@ -1391,7 +1245,7 @@ class ExtensionManager:
manifest_path = extension_dir / "extension.yml"
if not manifest_path.exists():
raise ValidationError("No extension.yml found in archive")
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
@@ -1685,22 +1539,6 @@ class ExtensionCatalog:
if not parsed.netloc:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
Delegates to :func:`specify_cli._github_http.build_github_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
Delegates to :func:`specify_cli._github_http.open_github_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
@@ -1857,6 +1695,7 @@ class ExtensionCatalog:
Raises:
ExtensionError: If catalog cannot be fetched or has invalid format
"""
import urllib.request
import urllib.error
# Determine cache file paths (backward compat for default catalog)
@@ -1890,7 +1729,7 @@ class ExtensionCatalog:
# Fetch from network
try:
with self._open_url(entry.url, timeout=10) as response:
with urllib.request.urlopen(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
if "schema_version" not in catalog_data or "extensions" not in catalog_data:
@@ -2004,9 +1843,10 @@ class ExtensionCatalog:
catalog_url = self.get_catalog_url()
try:
import urllib.request
import urllib.error
with self._open_url(catalog_url, timeout=10) as response:
with urllib.request.urlopen(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
# Validate catalog structure
@@ -2105,22 +1945,19 @@ class ExtensionCatalog:
return None
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
"""Download extension archive from catalog.
Supports both ZIP (``.zip``) and gzipped tarball (``.tar.gz``/``.tgz``)
archives. The format is detected from the download URL's path extension;
when ambiguous the ``Content-Type`` header is used as a fallback.
"""Download extension ZIP from catalog.
Args:
extension_id: ID of the extension to download
target_dir: Directory to save the archive (defaults to cache directory)
target_dir: Directory to save ZIP file (defaults to temp directory)
Returns:
Path to downloaded archive file
Path to downloaded ZIP file
Raises:
ExtensionError: If extension not found or download fails
"""
import urllib.request
import urllib.error
# Get extension info from catalog
@@ -2155,60 +1992,21 @@ class ExtensionCatalog:
target_dir.mkdir(parents=True, exist_ok=True)
version = ext_info.get("version", "unknown")
zip_filename = f"{extension_id}-{version}.zip"
zip_path = target_dir / zip_filename
# Download the archive. Determine the archive format from the
# post-redirect URL first (with Content-Type fallback); only use the
# original `download_url` as a last hint if the final URL gives no
# signal.
final_url = download_url
archive_fmt = ""
# Download the ZIP file
try:
with self._open_url(download_url, timeout=60) as response:
final_url = response.geturl()
# Re-validate scheme after any redirect to guard against
# scheme-downgrade. Validate BEFORE reading the body so a
# malicious redirect cannot cause us to fetch the payload
# over an insecure scheme.
_final_parsed = urlparse(final_url)
_final_is_localhost = _final_parsed.hostname in (
"localhost",
"127.0.0.1",
"::1",
)
if _final_parsed.scheme != "https" and not (
_final_parsed.scheme == "http" and _final_is_localhost
):
raise ExtensionError(
f"Extension download URL was redirected to a non-HTTPS URL: {final_url}"
)
content_type = response.headers.get("Content-Type", "")
archive_fmt = detect_archive_format(final_url, content_type)
if not archive_fmt:
archive_fmt = detect_archive_format(download_url)
archive_data = response.read()
with urllib.request.urlopen(download_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise ExtensionError(f"Failed to download extension from {download_url}: {e}")
except IOError as e:
raise ExtensionError(f"Failed to read extension archive from {download_url}: {e}")
# Choose file extension based on detected format.
if not archive_fmt:
raise ExtensionError(
f"Could not determine archive format for {download_url}. "
"Ensure the URL points to a .zip or .tar.gz/.tgz file."
)
if archive_fmt == "tar.gz":
archive_filename = f"{extension_id}-{version}.tar.gz"
else:
archive_filename = f"{extension_id}-{version}.zip"
archive_path = target_dir / archive_filename
try:
archive_path.write_bytes(archive_data)
except IOError as e:
raise ExtensionError(f"Failed to save extension archive: {e}")
return archive_path
raise ExtensionError(f"Failed to save extension ZIP: {e}")
def clear_cache(self):
"""Clear the catalog cache (both legacy and URL-hash-based files)."""

View File

@@ -27,7 +27,7 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import ExtensionRegistry, normalize_priority, detect_archive_format, safe_extract_tarball
from .extensions import ExtensionRegistry, normalize_priority
def _substitute_core_template(
@@ -136,25 +136,12 @@ class PresetManifest:
def _load_yaml(self, path: Path) -> dict:
"""Load YAML file safely."""
try:
with open(path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
with open(path, 'r') as f:
return yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise PresetValidationError(f"Invalid YAML in {path}: {e}")
except FileNotFoundError:
raise PresetValidationError(f"Manifest not found: {path}")
except UnicodeDecodeError as e:
raise PresetValidationError(
f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})"
)
except OSError as e:
raise PresetValidationError(f"Could not read manifest {path}: {e}")
if data is None:
return {}
if not isinstance(data, dict):
raise PresetValidationError(
f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}"
)
return data
def _validate(self):
"""Validate manifest structure and required fields."""
@@ -1604,10 +1591,10 @@ class PresetManager:
speckit_version: str,
priority: int = 10,
) -> PresetManifest:
"""Install preset from a ZIP or ``.tar.gz``/``.tgz`` archive.
"""Install preset from ZIP file.
Args:
zip_path: Path to the preset archive (ZIP or gzipped tarball).
zip_path: Path to preset ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
@@ -1615,8 +1602,7 @@ class PresetManager:
Installed preset manifest
Raises:
PresetValidationError: If manifest is invalid, the archive is unsafe,
or priority is invalid
PresetValidationError: If manifest is invalid or priority is invalid
PresetCompatibilityError: If pack is incompatible
"""
# Validate priority early
@@ -1626,24 +1612,18 @@ class PresetManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
archive_fmt = detect_archive_format(str(zip_path))
if archive_fmt == "tar.gz":
# Extract tarball safely (prevent tar slip attack)
safe_extract_tarball(zip_path, temp_path, PresetValidationError)
else:
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise PresetValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise PresetValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -1656,7 +1636,7 @@ class PresetManager:
if not manifest_path.exists():
raise PresetValidationError(
"No preset.yml found in archive"
"No preset.yml found in ZIP file"
)
return self.install_from_directory(pack_dir, speckit_version, priority)
@@ -1851,22 +1831,6 @@ class PresetCatalog:
"Catalog URL must be a valid URL with a host."
)
def _make_request(self, url: str):
"""Build a urllib Request, adding a GitHub auth header when available.
Delegates to :func:`specify_cli._github_http.build_github_request`.
"""
from specify_cli._github_http import build_github_request
return build_github_request(url)
def _open_url(self, url: str, timeout: int = 10):
"""Open a URL with GitHub auth, stripping the header on cross-host redirects.
Delegates to :func:`specify_cli._github_http.open_github_url`.
"""
from specify_cli._github_http import open_github_url
return open_github_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
@@ -2049,7 +2013,10 @@ class PresetCatalog:
pass
try:
with self._open_url(entry.url, timeout=10) as response:
import urllib.request
import urllib.error
with urllib.request.urlopen(entry.url, timeout=10) as response:
catalog_data = json.loads(response.read())
if (
@@ -2142,7 +2109,10 @@ class PresetCatalog:
pass
try:
with self._open_url(catalog_url, timeout=10) as response:
import urllib.request
import urllib.error
with urllib.request.urlopen(catalog_url, timeout=10) as response:
catalog_data = json.loads(response.read())
if (
@@ -2249,22 +2219,19 @@ class PresetCatalog:
def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download preset archive from catalog.
Supports both ZIP (``.zip``) and gzipped tarball (``.tar.gz``/``.tgz``)
archives. The format is detected from the download URL's path extension;
when ambiguous the ``Content-Type`` header is used as a fallback.
"""Download preset ZIP from catalog.
Args:
pack_id: ID of the preset to download
target_dir: Directory to save the archive (defaults to cache directory)
target_dir: Directory to save ZIP file (defaults to cache directory)
Returns:
Path to downloaded archive file
Path to downloaded ZIP file
Raises:
PresetError: If pack not found or download fails
"""
import urllib.request
import urllib.error
pack_info = self.get_pack_info(pack_id)
@@ -2312,61 +2279,22 @@ class PresetCatalog:
target_dir.mkdir(parents=True, exist_ok=True)
version = pack_info.get("version", "unknown")
zip_filename = f"{pack_id}-{version}.zip"
zip_path = target_dir / zip_filename
# Determine the archive format from the post-redirect URL first
# (with Content-Type fallback); only use the original `download_url`
# as a last hint if the final URL gives no signal.
final_url = download_url
archive_fmt = ""
try:
with self._open_url(download_url, timeout=60) as response:
final_url = response.geturl()
# Re-validate scheme after any redirect to guard against
# scheme-downgrade. Validate BEFORE reading the body so a
# malicious redirect cannot cause us to fetch the payload
# over an insecure scheme.
_final_parsed = urlparse(final_url)
_final_is_localhost = _final_parsed.hostname in (
"localhost",
"127.0.0.1",
"::1",
)
if _final_parsed.scheme != "https" and not (
_final_parsed.scheme == "http" and _final_is_localhost
):
raise PresetError(
f"Preset download URL was redirected to a non-HTTPS URL: {final_url}"
)
content_type = response.headers.get("Content-Type", "")
archive_fmt = detect_archive_format(final_url, content_type)
if not archive_fmt:
archive_fmt = detect_archive_format(download_url)
archive_data = response.read()
with urllib.request.urlopen(download_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise PresetError(
f"Failed to download preset from {download_url}: {e}"
)
except IOError as e:
raise PresetError(f"Failed to read preset archive from {download_url}: {e}")
# Choose file extension based on detected format.
if not archive_fmt:
raise PresetError(
f"Could not determine archive format for {download_url}. "
"Ensure the URL points to a .zip or .tar.gz/.tgz file."
)
if archive_fmt == "tar.gz":
archive_filename = f"{pack_id}-{version}.tar.gz"
else:
archive_filename = f"{pack_id}-{version}.zip"
archive_path = target_dir / archive_filename
try:
archive_path.write_bytes(archive_data)
except IOError as e:
raise PresetError(f"Failed to save preset archive: {e}")
return archive_path
raise PresetError(f"Failed to save preset ZIP: {e}")
def clear_cache(self):
"""Clear all catalog cache files, including per-URL hashed caches."""

View File

@@ -112,7 +112,7 @@ class TestInitIntegrationFlag:
assert "--ai" in normalized_output
assert "deprecated" in normalized_output
assert "no longer be available" in normalized_output
assert "0.10.0" in normalized_output
assert "1.0.0" in normalized_output
assert "--integration copilot" in normalized_output
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
@@ -446,33 +446,6 @@ class TestGitExtensionAutoInstall:
ext_dir = project / ".specify" / "extensions" / "git"
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_no_git_emits_deprecation_warning(self, tmp_path):
"""Using --no-git emits a visible deprecation warning."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "no-git-warn"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "--no-git" in normalized_output
assert "deprecated" in normalized_output
assert "0.10.0" in normalized_output
assert "specify extension" in normalized_output
assert "will be removed" in normalized_output
assert "git extension will no longer be enabled by default" in normalized_output
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner

View File

@@ -178,47 +178,6 @@ class TestNormalizePriority:
assert normalize_priority("invalid", default=1) == 1
# ===== detect_archive_format Tests =====
class TestDetectArchiveFormat:
"""Test the detect_archive_format helper."""
def _fmt(self, url, ct=""):
from specify_cli.extensions import detect_archive_format
return detect_archive_format(url, ct)
def test_zip_url_extension(self):
assert self._fmt("https://example.com/ext-1.0.0.zip") == "zip"
def test_tar_gz_url_extension(self):
assert self._fmt("https://example.com/ext-1.0.0.tar.gz") == "tar.gz"
def test_tgz_url_extension(self):
assert self._fmt("https://example.com/ext-1.0.0.tgz") == "tar.gz"
def test_zip_uppercase_url_extension(self):
assert self._fmt("https://example.com/ext.ZIP") == "zip"
def test_tar_gz_with_query_string(self):
assert self._fmt("https://example.com/ext.tar.gz?token=abc") == "tar.gz"
def test_zip_content_type_fallback(self):
assert self._fmt("https://example.com/download", "application/zip") == "zip"
def test_gzip_content_type_fallback(self):
assert self._fmt("https://example.com/download", "application/gzip") == "tar.gz"
def test_x_gzip_content_type_fallback(self):
assert self._fmt("https://example.com/download", "application/x-gzip") == "tar.gz"
def test_unknown_returns_empty_string(self):
assert self._fmt("https://example.com/workflow.yml") == ""
def test_url_extension_takes_precedence_over_content_type(self):
# URL says .zip — content-type claiming gzip should not override.
assert self._fmt("https://example.com/ext.zip", "application/gzip") == "zip"
# ===== ExtensionManifest Tests =====
class TestExtensionManifest:
@@ -266,35 +225,6 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="YAML mapping"):
ExtensionManifest(manifest_path)
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_data):
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
On Windows, Python's default text-mode encoding is the locale codepage
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
outside the ASCII range. The loader must open with encoding='utf-8'.
"""
import yaml
valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀"
manifest_path = temp_dir / "extension.yml"
# Write UTF-8 bytes explicitly so the test exercises the read path,
# not the (locale-dependent) write path.
manifest_path.write_bytes(
yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8")
)
manifest = ExtensionManifest(manifest_path)
assert manifest.description == "中文测试 — émojis 🚀"
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
"""Negative case: file containing invalid UTF-8 bytes raises ValidationError, not raw UnicodeDecodeError."""
manifest_path = temp_dir / "extension.yml"
# 0xFF/0xFE are not valid UTF-8 lead bytes.
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
with pytest.raises(ValidationError, match="not valid UTF-8"):
ExtensionManifest(manifest_path)
def test_invalid_extension_id(self, temp_dir, valid_manifest_data):
"""Test manifest with invalid extension ID format."""
import yaml
@@ -1054,97 +984,6 @@ class TestExtensionManager:
assert backup_file.read_text() == "test: config"
# ===== install_from_zip Tarball Tests =====
class TestInstallFromTarball:
"""Tests for install_from_zip accepting .tar.gz/.tgz archives."""
def _make_tarball(self, dest: Path, extension_dir: Path, nested: bool = False) -> None:
"""Create a minimal .tar.gz archive from *extension_dir*."""
import tarfile
with tarfile.open(dest, "w:gz") as tf:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(extension_dir)
if nested:
arcname = Path("test-ext-v1.0.0") / arcname
tf.add(file_path, arcname=str(arcname))
def test_install_from_tar_gz(self, extension_dir, project_dir, temp_dir):
"""install_from_zip should accept a .tar.gz archive."""
archive = temp_dir / "test-ext-1.0.0.tar.gz"
self._make_tarball(archive, extension_dir)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tgz(self, extension_dir, project_dir, temp_dir):
"""install_from_zip should accept a .tgz archive."""
archive = temp_dir / "test-ext-1.0.0.tgz"
self._make_tarball(archive, extension_dir)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tar_gz_nested(self, extension_dir, project_dir, temp_dir):
"""install_from_zip should handle a single nested directory inside the tarball."""
archive = temp_dir / "test-ext-nested.tar.gz"
self._make_tarball(archive, extension_dir, nested=True)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tar_gz_no_manifest(self, project_dir, temp_dir):
"""install_from_zip raises ValidationError when tarball has no extension.yml."""
import tarfile
import io
archive = temp_dir / "bad.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = b"no manifest here"
info = tarfile.TarInfo(name="readme.txt")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="No extension.yml found"):
manager.install_from_zip(archive, "0.1.0")
def test_install_from_tar_gz_rejects_path_traversal(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs with path traversal entries."""
import tarfile
import io
archive = temp_dir / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="../../evil.txt")
data = b"evil"
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Unsafe path"):
manager.install_from_zip(archive, "0.1.0")
def test_install_from_tar_gz_rejects_symlinks(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs containing symlinks."""
import tarfile
archive = temp_dir / "symlink.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tf.addfile(info)
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Symlinks"):
manager.install_from_zip(archive, "0.1.0")
# ===== CommandRegistrar Tests =====
class TestCommandRegistrar:
@@ -2577,216 +2416,6 @@ class TestExtensionCatalog:
assert not catalog.cache_file.exists()
assert not catalog.cache_metadata_file.exists()
# --- _make_request / GitHub auth ---
def _make_catalog(self, temp_dir):
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
return ExtensionCatalog(project_dir)
def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_only_github_token_ignored(self, temp_dir, monkeypatch):
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_dir, monkeypatch):
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch):
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip")
assert "Authorization" not in req.headers
def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for api.github.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_redirect_preserves_auth_for_github_to_codeload(self):
"""Auth header is preserved when GitHub redirects to codeload.github.com."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip"
redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
assert auth == "Bearer ghp_test"
def test_redirect_strips_auth_for_github_to_external(self):
"""Auth header is stripped when GitHub redirects to a non-GitHub host."""
from specify_cli._github_http import _StripAuthOnRedirect
from urllib.request import Request
import io
handler = _StripAuthOnRedirect()
original_url = "https://github.com/org/repo/releases/download/v1/asset.zip"
redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345"
req = Request(original_url, headers={"Authorization": "Bearer ghp_test"})
fp = io.BytesIO(b"")
new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url)
assert new_req is not None
auth_header = new_req.headers.get("Authorization")
auth_unredirected = new_req.unredirected_hdrs.get("Authorization")
assert auth_header is None
assert auth_unredirected is None
def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
catalog_data = {"schema_version": "1.0", "extensions": {}}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
entry = CatalogEntry(
url="https://raw.githubusercontent.com/org/repo/main/catalog.json",
name="private",
priority=1,
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
"""download_extension passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
import zipfile, io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = self._make_catalog(temp_dir)
# Build a minimal valid ZIP in memory
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.geturl.return_value = "https://github.com/org/repo/releases/download/v1/test-ext.zip"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://github.com/org/repo/releases/download/v1/test-ext.zip",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
# ===== CatalogEntry Tests =====
@@ -3662,7 +3291,6 @@ class TestDownloadExtensionBundled:
mock_response = MagicMock()
mock_response.read.return_value = b"fake zip data"
mock_response.geturl.return_value = "https://example.com/git-2.0.0.zip"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)

View File

@@ -160,38 +160,6 @@ class TestPresetManifest:
with pytest.raises(PresetValidationError, match="Invalid YAML"):
PresetManifest(bad_file)
def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data):
"""Regression for #2325: non-ASCII (UTF-8) description loads on any platform.
On Windows, Python's default text-mode encoding is the locale codepage
(e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes
outside the ASCII range. The loader must open with encoding='utf-8'.
"""
valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀"
manifest_path = temp_dir / "preset.yml"
manifest_path.write_bytes(
yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8")
)
manifest = PresetManifest(manifest_path)
assert manifest.description == "中文测试 — émojis 🚀"
def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir):
"""Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError."""
manifest_path = temp_dir / "preset.yml"
manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n")
with pytest.raises(PresetValidationError, match="not valid UTF-8"):
PresetManifest(manifest_path)
def test_non_mapping_yaml_raises_validation_error(self, temp_dir):
"""Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError."""
manifest_path = temp_dir / "preset.yml"
for bad_content in ("42\n", "[1, 2]\n"):
manifest_path.write_text(bad_content, encoding="utf-8")
with pytest.raises(PresetValidationError, match="YAML mapping"):
PresetManifest(manifest_path)
def test_missing_schema_version(self, temp_dir, valid_pack_data):
"""Test missing schema_version field."""
del valid_pack_data["schema_version"]
@@ -649,90 +617,6 @@ class TestPresetManager:
with pytest.raises(PresetValidationError, match="No preset.yml found"):
manager.install_from_zip(zip_path, "0.1.5")
def _make_tarball(self, dest, pack_dir, nested=False):
import tarfile
with tarfile.open(dest, "w:gz") as tf:
for file_path in pack_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(pack_dir)
if nested:
arcname = Path("test-pack-v1.0.0") / arcname
tf.add(file_path, arcname=str(arcname))
def test_install_from_tar_gz(self, project_dir, pack_dir, temp_dir):
"""Test installing a preset from a .tar.gz archive."""
archive = temp_dir / "test-pack-1.0.tar.gz"
self._make_tarball(archive, pack_dir)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tgz(self, project_dir, pack_dir, temp_dir):
"""Test installing a preset from a .tgz archive."""
archive = temp_dir / "test-pack-1.0.tgz"
self._make_tarball(archive, pack_dir)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tar_gz_nested(self, project_dir, pack_dir, temp_dir):
"""Test installing a preset from a .tar.gz archive with a single nested directory."""
archive = temp_dir / "test-pack-nested.tar.gz"
self._make_tarball(archive, pack_dir, nested=True)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tar_gz_no_manifest(self, project_dir, temp_dir):
"""Test installing a preset from a .tar.gz without preset.yml raises error."""
import tarfile
import io
archive = temp_dir / "bad.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = b"no manifest here"
info = tarfile.TarInfo(name="readme.txt")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="No preset.yml found"):
manager.install_from_zip(archive, "0.1.5")
def test_install_from_tar_gz_rejects_path_traversal(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs with path traversal entries."""
import tarfile
import io
archive = temp_dir / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="../../evil.txt")
data = b"evil"
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="Unsafe path"):
manager.install_from_zip(archive, "0.1.5")
def test_install_from_tar_gz_rejects_symlinks(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs containing symlinks."""
import tarfile
archive = temp_dir / "symlink.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tf.addfile(info)
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="Symlinks"):
manager.install_from_zip(archive, "0.1.5")
def test_remove(self, project_dir, pack_dir):
"""Test removing a preset."""
manager = PresetManager(project_dir)
@@ -1479,167 +1363,6 @@ class TestPresetCatalog:
catalog = PresetCatalog(project_dir)
assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json"
# --- _make_request / GitHub auth ---
def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch):
"""Without a token, requests carry no Authorization header."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch):
"""A whitespace-only GITHUB_TOKEN is treated as unset."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch):
"""When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback."""
monkeypatch.setenv("GITHUB_TOKEN", " ")
monkeypatch.setenv("GH_TOKEN", "ghp_fallback")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_fallback"
def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch):
"""GITHUB_TOKEN is attached for raw.githubusercontent.com URLs."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
monkeypatch.delenv("GH_TOKEN", raising=False)
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_gh_token_fallback(self, project_dir, monkeypatch):
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip")
assert req.get_header("Authorization") == "Bearer ghp_ghtoken"
def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch):
"""GITHUB_TOKEN takes precedence over GH_TOKEN when both are set."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary")
monkeypatch.setenv("GH_TOKEN", "ghp_secondary")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://api.github.com/repos/org/repo")
assert req.get_header("Authorization") == "Bearer ghp_primary"
def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch):
"""GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects)."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0")
assert req.get_header("Authorization") == "Bearer ghp_testtoken"
def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch):
"""Auth header is never attached to non-GitHub URLs to prevent credential leakage."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://internal.example.com/catalog.json")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch):
"""Auth header is not attached to hosts that include github.com as a suffix."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the URL path."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip")
assert "Authorization" not in req.headers
def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch):
"""Auth header is not attached when github.com appears only in the query string."""
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip")
assert "Authorization" not in req.headers
def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch):
"""_fetch_single_catalog passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
catalog_data = {"schema_version": "1.0", "presets": {}}
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(catalog_data).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
entry = PresetCatalogEntry(
url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json",
name="private",
priority=1,
install_allowed=True,
)
with patch("urllib.request.build_opener", return_value=mock_opener):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_download_pack_sends_auth_header(self, project_dir, monkeypatch):
"""download_pack passes Authorization header via opener for GitHub URLs."""
from unittest.mock import patch, MagicMock
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
catalog = PresetCatalog(project_dir)
import io
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.geturl.return_value = "https://github.com/org/repo/releases/download/v1/test-pack.zip"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
captured = {}
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
mock_opener.open.side_effect = fake_open
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip",
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch("urllib.request.build_opener", return_value=mock_opener):
catalog.download_pack("test-pack", target_dir=project_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
# ===== Integration Tests =====

View File

@@ -1843,230 +1843,3 @@ steps:
assert state.status == RunStatus.COMPLETED
assert "do-plan" in state.step_results
assert "do-specify" not in state.step_results
# ===== workflow add archive CLI tests =====
MINIMAL_WORKFLOW_YAML = """\
schema_version: "1.0"
workflow:
id: "arc-workflow"
name: "Archive Workflow"
version: "1.0.0"
description: "Installed from archive"
steps:
- id: step-one
type: shell
run: "echo hello"
"""
class TestWorkflowAddArchive:
"""CLI-level tests for `workflow add` with local archive files."""
@pytest.fixture
def project_dir(self, tmp_path):
"""Create a minimal spec-kit project."""
specify = tmp_path / ".specify"
specify.mkdir()
(specify / "workflows").mkdir()
return tmp_path
def _runner_and_app(self):
from typer.testing import CliRunner
from specify_cli import app
return CliRunner(), app
# -- Local ZIP archive --------------------------------------------------
def test_workflow_add_local_zip_flat(self, project_dir):
"""workflow add installs from a local ZIP with workflow.yml at root."""
import zipfile
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("workflow.yml", MINIMAL_WORKFLOW_YAML)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
installed = project_dir / ".specify" / "workflows" / "arc-workflow" / "workflow.yml"
assert installed.exists()
def test_workflow_add_local_zip_nested(self, project_dir):
"""workflow add installs from a local ZIP with workflow.yml in a subdirectory."""
import zipfile
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("repo-1.0/workflow.yml", MINIMAL_WORKFLOW_YAML)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_local_zip_missing_workflow_yml(self, project_dir):
"""workflow add exits with an error when the ZIP has no workflow.yml."""
import zipfile
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "empty.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("README.md", "nothing here")
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=True)
assert result.exit_code != 0
assert "extract" in result.output.lower() or "workflow" in result.output.lower()
# -- Local tar.gz archive -----------------------------------------------
def test_workflow_add_local_tar_gz_flat(self, project_dir):
"""workflow add installs from a local .tar.gz with workflow.yml at root."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
installed = project_dir / ".specify" / "workflows" / "arc-workflow" / "workflow.yml"
assert installed.exists()
def test_workflow_add_local_tar_gz_nested(self, project_dir):
"""workflow add installs from a local .tar.gz with workflow.yml in a subdirectory."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="repo-1.0/workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_local_tgz_flat(self, project_dir):
"""workflow add recognises the .tgz extension as a gzipped tarball."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.tgz"
with tarfile.open(archive, "w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_local_tar_gz_missing_workflow_yml(self, project_dir):
"""workflow add exits with an error when the .tar.gz has no workflow.yml."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "empty.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = b"nothing"
info = tarfile.TarInfo(name="README.md")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=True)
assert result.exit_code != 0
assert "extract" in result.output.lower() or "workflow" in result.output.lower()
# -- URL archive download -----------------------------------------------
def test_workflow_add_url_tar_gz(self, project_dir):
"""workflow add downloads a .tar.gz from a URL and installs the workflow."""
import tarfile, io
from unittest.mock import patch, MagicMock
runner, app = self._runner_and_app()
# Build an in-memory tar.gz archive containing workflow.yml.
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
raw_bytes = buf.getvalue()
mock_resp = MagicMock()
mock_resp.geturl.return_value = "https://example.com/workflow.tar.gz"
mock_resp.headers.get.return_value = "application/gzip"
mock_resp.read.return_value = raw_bytes
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["workflow", "add", "https://example.com/workflow.tar.gz"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_url_zip(self, project_dir):
"""workflow add downloads a .zip from a URL and installs the workflow."""
import zipfile, io
from unittest.mock import patch, MagicMock
runner, app = self._runner_and_app()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("workflow.yml", MINIMAL_WORKFLOW_YAML)
raw_bytes = buf.getvalue()
mock_resp = MagicMock()
mock_resp.geturl.return_value = "https://example.com/workflow.zip"
mock_resp.headers.get.return_value = "application/zip"
mock_resp.read.return_value = raw_bytes
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["workflow", "add", "https://example.com/workflow.zip"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output