mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
1 Commits
copilot/ad
...
v0.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63f64b69d |
@@ -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)).
|
||||
|
||||
45
README.md
45
README.md
@@ -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
|
||||
|
||||

|
||||
|
||||
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`.
|
||||
|
||||

|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user