mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16468f21f | ||
|
|
9735145289 | ||
|
|
68a031c768 | ||
|
|
a59381ae30 | ||
|
|
975498e11d | ||
|
|
51e6a140e2 | ||
|
|
81e9ecd4d9 | ||
|
|
409ec59704 | ||
|
|
b36c34f171 | ||
|
|
8bd20a2f5f | ||
|
|
4c610a20dc | ||
|
|
27700387b6 | ||
|
|
d947fda96f | ||
|
|
13c167e107 | ||
|
|
f684305e51 | ||
|
|
b774282058 | ||
|
|
6322a4d429 | ||
|
|
be382804c7 | ||
|
|
c87081a50a | ||
|
|
e6afba9429 | ||
|
|
c1a1653aca | ||
|
|
0e5b59fcaa | ||
|
|
707e929c2a | ||
|
|
59fa8b5947 | ||
|
|
def1a05420 | ||
|
|
4f05eff4e4 | ||
|
|
59fdca5997 | ||
|
|
2fb9d3bb4b | ||
|
|
9732a4d092 | ||
|
|
4f51e066c3 | ||
|
|
0aae1ec2b9 | ||
|
|
31a06101ef |
169
.github/skills/add-community-extension/SKILL.md
vendored
Normal file
169
.github/skills/add-community-extension/SKILL.md
vendored
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: add-community-extension
|
||||
description: 'Add a community extension to the Spec Kit catalog from a GitHub issue submission. USE FOR: processing extension submission issues, validating catalog entries, updating catalog.community.json and docs/community/extensions.md, creating PRs. DO NOT USE FOR: creating new extensions from scratch, or first-party extension work.'
|
||||
argument-hint: 'GitHub issue URL or number for the extension submission'
|
||||
---
|
||||
|
||||
# Add Community Extension
|
||||
|
||||
Process an extension submission issue and add or update it in the community catalog.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A new `[Extension]` submission issue is filed
|
||||
- An existing extension submits an update issue (new version, changed metadata)
|
||||
- You need to add or update a community extension in `extensions/catalog.community.json` and `docs/community/extensions.md`
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Fetch the submission issue
|
||||
|
||||
Read the GitHub issue to extract all metadata:
|
||||
- Extension ID, name, version, description, author
|
||||
- Repository URL, download URL, homepage, documentation, changelog
|
||||
- License, required spec-kit version, optional tool dependencies
|
||||
- Number of commands and hooks
|
||||
- Tags
|
||||
|
||||
### 2. Validate against publishing rules
|
||||
|
||||
Check **all** of the following (per `extensions/EXTENSION-PUBLISHING-GUIDE.md`):
|
||||
|
||||
| Check | How |
|
||||
|-------|-----|
|
||||
| Repository exists and is public | Fetch the repository URL |
|
||||
| `extension.yml` manifest present | Confirm in repo file listing |
|
||||
| README.md present | Confirm in repo file listing |
|
||||
| LICENSE file present | Confirm in repo file listing |
|
||||
| GitHub release exists matching version | Check releases on the repo page |
|
||||
| Download URL is accessible | Verify it follows `archive/refs/tags/vX.Y.Z.zip` pattern and release exists |
|
||||
| Extension ID is lowercase-with-hyphens only | Regex: `^[a-z][a-z0-9-]*$` |
|
||||
| Version follows semver | Format: `X.Y.Z` |
|
||||
| Submission checklists are all checked | Confirm in issue body |
|
||||
|
||||
### 3. Determine if this is an add or update
|
||||
|
||||
Search `extensions/catalog.community.json` for the extension ID.
|
||||
|
||||
- **Not found** → this is a **new addition**. Proceed to step 4.
|
||||
- **Found** → this is an **update**. Proceed to step 4 but replace the existing entry in-place instead of inserting.
|
||||
|
||||
### 4. Add or update `extensions/catalog.community.json`
|
||||
|
||||
**New extension:** Insert the entry in **alphabetical order** by extension ID.
|
||||
|
||||
**Update:** Replace the existing entry in-place. Update only the fields that changed (typically `version`, `download_url`, `description`, `provides`, `requires`, `tags`, `updated_at`). Preserve `created_at` and `downloads`/`stars` from the existing entry.
|
||||
|
||||
Use the existing entries as the format template. Required fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"<id>": {
|
||||
"name": "<name>",
|
||||
"id": "<id>",
|
||||
"description": "<description>",
|
||||
"author": "<author>",
|
||||
"version": "<version>",
|
||||
"download_url": "<download_url>",
|
||||
"repository": "<repository>",
|
||||
"homepage": "<homepage>",
|
||||
"documentation": "<documentation>",
|
||||
"changelog": "<changelog>",
|
||||
"license": "<license>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>"
|
||||
},
|
||||
"provides": {
|
||||
"commands": <N>,
|
||||
"hooks": <N>
|
||||
},
|
||||
"tags": ["<tag1>", "<tag2>"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "<today>T00:00:00Z",
|
||||
"updated_at": "<today>T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the extension has optional tool dependencies, add a `"tools"` array inside `"requires"`:
|
||||
|
||||
```json
|
||||
"tools": [{ "name": "<tool>", "required": false }]
|
||||
```
|
||||
|
||||
Also update the top-level `"updated_at"` timestamp in the catalog.
|
||||
|
||||
After editing, **validate the JSON** by running:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('extensions/catalog.community.json')); print('Valid JSON')"
|
||||
```
|
||||
|
||||
### 5. Add or update `docs/community/extensions.md` community extensions table
|
||||
|
||||
**New extension:** Insert a new row into the `# Community Extensions` table in **alphabetical order** by extension name.
|
||||
|
||||
**Update:** Find the existing row and update the description or other changed fields in-place.
|
||||
|
||||
Determine the category and effect from the extension's behavior:
|
||||
|
||||
```
|
||||
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
**Category** — one of: `docs`, `code`, `process`, `integration`, `visibility`
|
||||
**Effect** — `Read-only` (produces reports only) or `Read+Write` (modifies project files)
|
||||
|
||||
### 6. Commit, push, and open PR
|
||||
|
||||
Use `add-` for new extensions, `update-` for updates:
|
||||
|
||||
```bash
|
||||
# New extension
|
||||
git checkout -b add-<extension-id>-extension
|
||||
|
||||
# Update
|
||||
git checkout -b update-<extension-id>-extension
|
||||
```
|
||||
|
||||
```bash
|
||||
git add extensions/catalog.community.json docs/community/extensions.md
|
||||
|
||||
# New extension
|
||||
git commit -m "Add <Name> extension to community catalog
|
||||
|
||||
Add <id> extension submitted by @<issue-author> to:
|
||||
- extensions/catalog.community.json (alphabetical order)
|
||||
- docs/community/extensions.md community extensions table
|
||||
|
||||
Closes #<issue-number>"
|
||||
|
||||
# Update
|
||||
git commit -m "Update <Name> extension to v<version>
|
||||
|
||||
Update <id> extension submitted by @<issue-author>:
|
||||
- extensions/catalog.community.json (version, download_url, etc.)
|
||||
- docs/community/extensions.md community extensions table
|
||||
|
||||
Closes #<issue-number>"
|
||||
|
||||
git push origin <branch-name>
|
||||
```
|
||||
|
||||
Then create a PR to `upstream` (`github/spec-kit`) with:
|
||||
- **Title:** `Add <Name> extension to community catalog` (or `Update <Name> extension to v<version>`)
|
||||
- **Body:** Include validation summary, `Closes #<issue-number>`, and `cc @<issue-author>`
|
||||
- **Head:** `<fork-owner>:<branch-name>`
|
||||
- **Base:** `main`
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and by name in the docs table.
|
||||
- **Don't forget the catalog `updated_at`** — the top-level timestamp in `catalog.community.json` must be refreshed.
|
||||
- **Validate JSON after editing** — a trailing comma or missing brace will break the catalog.
|
||||
- **Use `Closes` not `Fixes`** — `Closes #N` is the correct keyword for submission issues.
|
||||
- **Match the proposed entry but verify** — the issue may include a proposed JSON block, but always validate field values against the actual repository state.
|
||||
- **Preserve `created_at` on updates** — keep the original `created_at` value; only change `updated_at`.
|
||||
- **Preserve `downloads` and `stars` on updates** — these reflect usage metrics and must not be reset.
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -2,6 +2,52 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.12] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(codex): inject dot-to-hyphen hook command note in Codex skills (#2503)
|
||||
- Update Squad Bridge extension to v1.3.0 (#2645)
|
||||
- Update Superpowers Implementation Bridge extension to v0.5.0 (#2644)
|
||||
- Add Team Assign extension to community catalog (#2642)
|
||||
- refactor: migrate extension catalog stack parsing to shared base (#2576)
|
||||
- Update Architecture Workflow extension to v1.1.0 (#2588)
|
||||
- fix(workflow): support integration: auto to follow project's initialized AI (#2421)
|
||||
- Add Superpowers Implementation Bridge extension to community catalog (#2586)
|
||||
- Add Interactive HTML Preview extension to community catalog (#2585)
|
||||
- chore: release 0.8.11, begin 0.8.12.dev0 development (#2584)
|
||||
- Update Agent Governance extension to v1.1.0 (#2583)
|
||||
|
||||
## [0.8.11] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
|
||||
- Add Time Machine extension to community catalog (#2580)
|
||||
- fix(powershell): ensure UTF-8 templates are written without BOM (#2280)
|
||||
- docs: document high-assurance spec workflow (#2518)
|
||||
- docs: fix script name in directory tree examples (#2555)
|
||||
- Fix preset skill description precedence (#2538)
|
||||
- fix(integration): clarify multi-install guidance (#2549)
|
||||
- feat: add version feature reporting (#2548)
|
||||
- Add Architecture Workflow extension to community catalog (#2565)
|
||||
- chore: release 0.8.10, begin 0.8.11.dev0 development (#2562)
|
||||
|
||||
## [0.8.10] - 2026-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: streamline install section and add community overview (#2561)
|
||||
- Move community extensions table from README to docs site (#2560)
|
||||
- Add Agent Governance extension to community catalog (#2559)
|
||||
- Add Reqnroll BDD extension to community catalog (#2545)
|
||||
- fix(cli): harden extension registration and discovery workflows (#2499)
|
||||
- refactor: extract _assets.py and _utils.py from __init__.py (PR-2/8) (#2543)
|
||||
- fix(opencode): use commands/ directory (plural) to match OpenCode docs (#2453)
|
||||
- refactor: extract _console.py from __init__.py (PR-1/8) (#2474)
|
||||
- Fix constitution reference in README (#2491)
|
||||
- chore: release 0.8.9, begin 0.8.10.dev0 development (#2532)
|
||||
|
||||
## [0.8.9] - 2026-05-12
|
||||
|
||||
### Changed
|
||||
|
||||
251
README.md
251
README.md
@@ -35,8 +35,7 @@
|
||||
- [🔧 Prerequisites](#-prerequisites)
|
||||
- [📖 Learn More](#-learn-more)
|
||||
- [📋 Detailed Process](#-detailed-process)
|
||||
- [🔍 Troubleshooting](#-troubleshooting)
|
||||
- [💬 Support](#-support)
|
||||
- [ Support](#-support)
|
||||
- [🙏 Acknowledgements](#-acknowledgements)
|
||||
- [📄 License](#-license)
|
||||
|
||||
@@ -48,83 +47,22 @@ Spec-Driven Development **flips the script** on traditional software development
|
||||
|
||||
### 1. Install Specify CLI
|
||||
|
||||
Choose your preferred installation method:
|
||||
|
||||
> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below.
|
||||
|
||||
#### Option 1: Persistent Installation (Recommended)
|
||||
|
||||
Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
> [!NOTE]
|
||||
> The `uv tool install` commands below require **[uv](https://docs.astral.sh/uv/)** — a fast Python package manager. If you see `command not found: uv`, [install uv first](./docs/install/uv.md). The `pipx` alternative does not require uv.
|
||||
Requires **[uv](https://docs.astral.sh/uv/)** ([install uv](./docs/install/uv.md)). Replace `vX.Y.Z` with the latest tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
|
||||
```bash
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
# Alternative: using pipx (also works)
|
||||
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
pipx install git+https://github.com/github/spec-kit.git
|
||||
```
|
||||
|
||||
Then verify the correct version is installed:
|
||||
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
|
||||
|
||||
### 2. Initialize a project
|
||||
|
||||
```bash
|
||||
specify version
|
||||
specify init my-project --integration copilot
|
||||
cd my-project
|
||||
```
|
||||
|
||||
And use the tool directly:
|
||||
|
||||
```bash
|
||||
# Create new project
|
||||
specify init <PROJECT_NAME>
|
||||
|
||||
# Or initialize in existing project
|
||||
specify init . --integration copilot
|
||||
# or
|
||||
specify init --here --integration copilot
|
||||
|
||||
# Check installed tools
|
||||
specify check
|
||||
```
|
||||
|
||||
To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed instructions. Quick upgrade:
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
#### Option 2: One-time Usage
|
||||
|
||||
Run directly without installing:
|
||||
|
||||
```bash
|
||||
# Create new project (pinned to a stable release — replace vX.Y.Z with the latest tag)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Or initialize in existing project
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot
|
||||
# or
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot
|
||||
```
|
||||
|
||||
**Benefits of persistent installation:**
|
||||
|
||||
- Tool stays installed and available in PATH
|
||||
- No need to create shell aliases
|
||||
- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`
|
||||
- Cleaner shell configuration
|
||||
|
||||
#### Option 3: Enterprise / Air-Gapped Installation
|
||||
|
||||
If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](./docs/installation.md#enterprise--air-gapped-installation) guide for step-by-step instructions on using `pip download` to create portable, OS-specific wheel bundles on a connected machine.
|
||||
|
||||
### 2. Establish project principles
|
||||
### 3. 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.
|
||||
|
||||
@@ -134,7 +72,7 @@ Use the **`/speckit.constitution`** command to create your project's governing p
|
||||
/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements
|
||||
```
|
||||
|
||||
### 3. Create the spec
|
||||
### 4. Create the spec
|
||||
|
||||
Use the **`/speckit.specify`** command to describe what you want to build. Focus on the **what** and **why**, not the tech stack.
|
||||
|
||||
@@ -142,7 +80,7 @@ Use the **`/speckit.specify`** command to describe what you want to build. Focus
|
||||
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
|
||||
```
|
||||
|
||||
### 4. Create a technical implementation plan
|
||||
### 5. Create a technical implementation plan
|
||||
|
||||
Use the **`/speckit.plan`** command to provide your tech stack and architecture choices.
|
||||
|
||||
@@ -150,7 +88,7 @@ Use the **`/speckit.plan`** command to provide your tech stack and architecture
|
||||
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
|
||||
```
|
||||
|
||||
### 5. Break down into tasks
|
||||
### 6. Break down into tasks
|
||||
|
||||
Use **`/speckit.tasks`** to create an actionable task list from your implementation plan.
|
||||
|
||||
@@ -158,7 +96,7 @@ Use **`/speckit.tasks`** to create an actionable task list from your implementat
|
||||
/speckit.tasks
|
||||
```
|
||||
|
||||
### 6. Execute implementation
|
||||
### 7. Execute implementation
|
||||
|
||||
Use **`/speckit.implement`** to execute all tasks and build your feature according to the plan.
|
||||
|
||||
@@ -176,124 +114,10 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
|
||||
## 🧩 Community Extensions
|
||||
|
||||
Community-contributed extensions add new commands, hooks, and capabilities to Spec Kit. See the full list on the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page.
|
||||
|
||||
> [!NOTE]
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
|
||||
|
||||
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
||||
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json):
|
||||
|
||||
**Categories:**
|
||||
|
||||
- `docs` — reads, validates, or generates spec artifacts
|
||||
- `code` — reviews, validates, or modifies source code
|
||||
- `process` — orchestrates workflow across phases
|
||||
- `integration` — syncs with external platforms
|
||||
- `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:**
|
||||
|
||||
- `Read-only` — produces reports without modifying files
|
||||
- `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
||||
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
||||
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
|
||||
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
||||
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
||||
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
||||
| MAQA GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) |
|
||||
| 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) |
|
||||
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
|
||||
| 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 | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `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) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| 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) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| 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) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
||||
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
|
||||
| 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 Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| 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 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 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) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
|
||||
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
|
||||
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
|
||||
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
|
||||
@@ -580,11 +404,12 @@ At this stage, your project folder contents should resemble the following:
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
│ └── bash
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── setup-tasks.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ └── spec.md
|
||||
@@ -641,11 +466,12 @@ The output of this step will include a number of implementation detail documents
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
│ └── bash
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── setup-tasks.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ ├── contracts
|
||||
@@ -707,7 +533,7 @@ This helps refine the implementation plan and helps you avoid potential blind sp
|
||||
You can also ask Claude Code (if you have the [GitHub CLI](https://docs.github.com/en/github-cli/github-cli) installed) to go ahead and create a pull request from your current branch to `main` with a detailed description, to make sure that the effort is properly tracked.
|
||||
|
||||
> [!NOTE]
|
||||
> Before you have the agent implement it, it's also worth prompting Claude Code to cross-check the details to see if there are any over-engineered pieces (remember - it can be over-eager). If over-engineered components or decisions exist, you can ask Claude Code to resolve them. Ensure that Claude Code follows the [constitution](base/memory/constitution.md) as the foundational piece that it must adhere to when establishing the plan.
|
||||
> Before you have the agent implement it, it's also worth prompting Claude Code to cross-check the details to see if there are any over-engineered pieces (remember - it can be over-eager). If over-engineered components or decisions exist, you can ask Claude Code to resolve them. Ensure that Claude Code follows the constitution in `.specify/memory/constitution.md` as the foundational piece that it must adhere to when establishing the plan.
|
||||
|
||||
### **STEP 6:** Generate task breakdown with /speckit.tasks
|
||||
|
||||
@@ -753,26 +579,7 @@ Once the implementation is complete, test the application and resolve any runtim
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Git Credential Manager on Linux
|
||||
|
||||
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
echo "Downloading Git Credential Manager v2.6.1..."
|
||||
wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb
|
||||
echo "Installing Git Credential Manager..."
|
||||
sudo dpkg -i gcm-linux_amd64.2.6.1.deb
|
||||
echo "Configuring Git to use GCM..."
|
||||
git config --global credential.helper manager
|
||||
echo "Cleaning up..."
|
||||
rm gcm-linux_amd64.2.6.1.deb
|
||||
```
|
||||
|
||||
## 💬 Support
|
||||
## Support
|
||||
|
||||
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.
|
||||
|
||||
|
||||
129
docs/community/extensions.md
Normal file
129
docs/community/extensions.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Community Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
|
||||
|
||||
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
||||
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):
|
||||
|
||||
**Categories:**
|
||||
|
||||
- `docs` — reads, validates, or generates spec artifacts
|
||||
- `code` — reviews, validates, or modifies source code
|
||||
- `process` — orchestrates workflow across phases
|
||||
- `integration` — syncs with external platforms
|
||||
- `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:**
|
||||
|
||||
- `Read-only` — produces reports without modifying files
|
||||
- `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| Agent Governance | Project-local agent governance memory and context projection | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
||||
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
||||
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
|
||||
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
||||
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
||||
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
||||
| MAQA GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) |
|
||||
| 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) |
|
||||
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
|
||||
| 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 | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `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) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| 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) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| 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) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
||||
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
|
||||
| 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 Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| 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 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 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) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
|
||||
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
|
||||
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
|
||||
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
27
docs/community/overview.md
Normal file
27
docs/community/overview.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Community
|
||||
|
||||
The Spec Kit community builds extensions, presets, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors.
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. Over 90 community extensions are available from 50+ authors, covering everything from accessibility governance to multi-agent orchestration.
|
||||
|
||||
[Browse community extensions →](extensions.md)
|
||||
|
||||
## Presets
|
||||
|
||||
Presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Community presets range from language localizations to entirely different development methodologies.
|
||||
|
||||
[Browse community presets →](presets.md)
|
||||
|
||||
## Walkthroughs
|
||||
|
||||
Step-by-step guides that show Spec-Driven Development in action across different scenarios, languages, and frameworks.
|
||||
|
||||
[Browse community walkthroughs →](walkthroughs.md)
|
||||
|
||||
## Friends
|
||||
|
||||
Community projects that extend, visualize, or build on Spec Kit — including VS Code extensions, Claude Code plugins, and more.
|
||||
|
||||
[Browse friend projects →](friends.md)
|
||||
@@ -43,7 +43,9 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm
|
||||
|
||||
### Make it your own
|
||||
|
||||
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing — including entirely different SDD processes:
|
||||
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
|
||||
|
||||
Including entirely different SDD processes:
|
||||
|
||||
- **AIDE** — 7-step AI-driven engineering lifecycle
|
||||
- **Canon** — baseline-driven workflows (spec-first, code-first, spec-drift)
|
||||
@@ -51,8 +53,6 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm
|
||||
- **FX→.NET** — end-to-end .NET Framework migration across 7 phases
|
||||
- **MAQA** — multi-agent orchestration with quality assurance gates
|
||||
|
||||
Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
|
||||
|
||||
<a href="community/presets.md" class="pillar-link">Browse community presets →</a>
|
||||
|
||||
</div>
|
||||
@@ -124,9 +124,9 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
|
||||
<strong>Reference</strong>
|
||||
<span>Core commands, integrations, extensions, presets, and workflows</span>
|
||||
</a>
|
||||
<a href="community/presets.md" class="nav-card">
|
||||
<a href="community/overview.md" class="nav-card">
|
||||
<strong>Community</strong>
|
||||
<span>Presets, walkthroughs, and friend projects</span>
|
||||
<span>Extensions, presets, walkthroughs, and friend projects</span>
|
||||
</a>
|
||||
<a href="local-development.md" class="nav-card">
|
||||
<strong>Development</strong>
|
||||
|
||||
59
docs/install/air-gapped.md
Normal file
59
docs/install/air-gapped.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Enterprise / Air-Gapped Installation
|
||||
|
||||
If your environment blocks access to PyPI or GitHub, you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target.
|
||||
|
||||
## Step 1: Build the wheel on a connected machine
|
||||
|
||||
> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/github/spec-kit.git
|
||||
cd spec-kit
|
||||
|
||||
# Build the wheel
|
||||
pip install build
|
||||
python -m build --wheel --outdir dist/
|
||||
|
||||
# Download the wheel and all its runtime dependencies
|
||||
pip download -d dist/ dist/specify_cli-*.whl
|
||||
```
|
||||
|
||||
## Step 2: Transfer the `dist/` directory
|
||||
|
||||
Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method.
|
||||
|
||||
## Step 3: Install on the air-gapped machine
|
||||
|
||||
```bash
|
||||
pip install --no-index --find-links=./dist specify-cli
|
||||
```
|
||||
|
||||
## Step 4: Initialize a project
|
||||
|
||||
No network access is required — bundled assets are used by default:
|
||||
|
||||
```bash
|
||||
specify init my-project --integration copilot
|
||||
```
|
||||
|
||||
> **Note:** Python 3.11+ is required.
|
||||
|
||||
> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell.
|
||||
|
||||
## Git Credential Manager on Linux
|
||||
|
||||
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
echo "Downloading Git Credential Manager v2.6.1..."
|
||||
wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb
|
||||
echo "Installing Git Credential Manager..."
|
||||
sudo dpkg -i gcm-linux_amd64.2.6.1.deb
|
||||
echo "Configuring Git to use GCM..."
|
||||
git config --global credential.helper manager
|
||||
echo "Cleaning up..."
|
||||
rm gcm-linux_amd64.2.6.1.deb
|
||||
```
|
||||
32
docs/install/one-time.md
Normal file
32
docs/install/one-time.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# One-time Usage (uvx)
|
||||
|
||||
If you want to try Spec Kit without installing it permanently, use `uvx` to run it directly. This downloads the tool into a temporary environment that is discarded after the command finishes.
|
||||
|
||||
> [!NOTE]
|
||||
> The commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](uv.md).
|
||||
|
||||
## Run Specify CLI
|
||||
|
||||
```bash
|
||||
# Create a new project (latest from main)
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
|
||||
# Or target a specific release (replace vX.Y.Z with a tag from Releases)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Initialize in the current directory
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init . --integration copilot
|
||||
|
||||
# Or use the --here flag
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init --here --integration copilot
|
||||
```
|
||||
|
||||
## When to use persistent installation instead
|
||||
|
||||
If you plan to use Spec Kit regularly, a persistent installation is recommended:
|
||||
|
||||
- Tool stays installed and available in PATH
|
||||
- No re-download on every invocation
|
||||
- Better tool management with `uv tool list`, `uv tool upgrade`, `uv tool uninstall`
|
||||
|
||||
See the main [Installation Guide](../installation.md) for persistent installation instructions.
|
||||
37
docs/install/pipx.md
Normal file
37
docs/install/pipx.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Installing with pipx
|
||||
|
||||
[pipx](https://pypa.github.io/pipx/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
## Install Specify CLI
|
||||
|
||||
Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
|
||||
```bash
|
||||
# Install a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
pipx install git+https://github.com/github/spec-kit.git
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
specify version
|
||||
```
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
pipx uninstall specify-cli
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
Head to the [Quick Start](../quickstart.md) to initialize your first project.
|
||||
@@ -10,38 +10,35 @@
|
||||
|
||||
## Installation
|
||||
|
||||
> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
|
||||
> [!IMPORTANT]
|
||||
> The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
|
||||
|
||||
### Initialize a New Project
|
||||
### Persistent Installation (Recommended)
|
||||
|
||||
The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
|
||||
> [!NOTE]
|
||||
> The `uvx` commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](./install/uv.md). The `pipx` alternative does not require uv.
|
||||
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
|
||||
|
||||
```bash
|
||||
# Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag)
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
|
||||
|
||||
# Or install latest from main (may include unreleased changes)
|
||||
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME>
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> For a persistent installation, `pipx` works equally well:
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
> ```
|
||||
> The project uses a standard `hatchling` build backend and has no uv-specific dependencies.
|
||||
|
||||
Or initialize in the current directory:
|
||||
Then initialize a project:
|
||||
|
||||
```bash
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init .
|
||||
# or use the --here flag
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here
|
||||
specify init <PROJECT_NAME> --integration copilot
|
||||
```
|
||||
|
||||
### One-time Usage
|
||||
|
||||
Run directly without installing — see the [One-time usage (uvx)](install/one-time.md) guide.
|
||||
|
||||
### Alternative Package Managers
|
||||
|
||||
- **pipx** — see the [pipx installation guide](install/pipx.md)
|
||||
- **Enterprise / Air-Gapped** — see the [air-gapped installation guide](install/air-gapped.md)
|
||||
|
||||
### Specify Integration
|
||||
|
||||
Interactive terminals prompt you to choose a coding agent integration during initialization. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot unless you pass `--integration`.
|
||||
@@ -49,11 +46,11 @@ Interactive terminals prompt you to choose a coding agent integration during ini
|
||||
You can proactively specify your coding agent integration 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
|
||||
specify init <project_name> --integration claude
|
||||
specify init <project_name> --integration gemini
|
||||
specify init <project_name> --integration copilot
|
||||
specify init <project_name> --integration codebuddy
|
||||
specify init <project_name> --integration pi
|
||||
```
|
||||
|
||||
### Specify Script Type (Shell vs PowerShell)
|
||||
@@ -69,8 +66,8 @@ Auto behavior:
|
||||
Force a specific script type:
|
||||
|
||||
```bash
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --script sh
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --script ps
|
||||
specify init <project_name> --script sh
|
||||
specify init <project_name> --script ps
|
||||
```
|
||||
|
||||
### Ignore Agent Tools Check
|
||||
@@ -78,7 +75,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
|
||||
specify init <project_name> --integration claude --ignore-agent-tools
|
||||
```
|
||||
|
||||
## Verification
|
||||
@@ -97,67 +94,17 @@ After initialization, you should see the following commands available in your co
|
||||
- `/speckit.plan` - Generate implementation plans
|
||||
- `/speckit.tasks` - Break down into actionable tasks
|
||||
|
||||
The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
|
||||
Scripts are installed into a variant subdirectory matching the chosen script type:
|
||||
|
||||
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
|
||||
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Enterprise / Air-Gapped Installation
|
||||
|
||||
If your environment blocks access to PyPI (you see 403 errors when running `uv tool install` or `pip install`), you can create a portable wheel bundle on a connected machine and transfer it to the air-gapped target.
|
||||
|
||||
**Step 1: Build the wheel on a connected machine (same OS and Python version as the target)**
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/github/spec-kit.git
|
||||
cd spec-kit
|
||||
|
||||
# Build the wheel
|
||||
pip install build
|
||||
python -m build --wheel --outdir dist/
|
||||
|
||||
# Download the wheel and all its runtime dependencies
|
||||
pip download -d dist/ dist/specify_cli-*.whl
|
||||
```
|
||||
|
||||
> **Important:** `pip download` resolves platform-specific wheels (e.g., PyYAML includes native extensions). You must run this step on a machine with the **same OS and Python version** as the air-gapped target. If you need to support multiple platforms, repeat this step on each target OS (Linux, macOS, Windows) and Python version.
|
||||
|
||||
**Step 2: Transfer the `dist/` directory to the air-gapped machine**
|
||||
|
||||
Copy the entire `dist/` directory (which contains the `specify-cli` wheel and all dependency wheels) to the target machine via USB, network share, or other approved transfer method.
|
||||
|
||||
**Step 3: Install on the air-gapped machine**
|
||||
|
||||
```bash
|
||||
pip install --no-index --find-links=./dist specify-cli
|
||||
```
|
||||
|
||||
**Step 4: Initialize a project (no network required)**
|
||||
|
||||
```bash
|
||||
# Initialize a project — no GitHub access needed
|
||||
specify init my-project --integration claude
|
||||
```
|
||||
|
||||
Bundled assets are used by default — no network access is required.
|
||||
|
||||
> **Note:** Python 3.11+ is required.
|
||||
|
||||
> **Windows note:** Offline scaffolding requires PowerShell 7+ (`pwsh`), not Windows PowerShell 5.x (`powershell.exe`). Install from https://aka.ms/powershell.
|
||||
If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-Gapped Installation](install/air-gapped.md) guide for step-by-step instructions on creating portable wheel bundles.
|
||||
|
||||
### Git Credential Manager on Linux
|
||||
|
||||
If you're having issues with Git authentication on Linux, you can install Git Credential Manager:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
echo "Downloading Git Credential Manager v2.6.1..."
|
||||
wget https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.6.1/gcm-linux_amd64.2.6.1.deb
|
||||
echo "Installing Git Credential Manager..."
|
||||
sudo dpkg -i gcm-linux_amd64.2.6.1.deb
|
||||
echo "Configuring Git to use GCM..."
|
||||
git config --global credential.helper manager
|
||||
echo "Cleaning up..."
|
||||
rm gcm-linux_amd64.2.6.1.deb
|
||||
```
|
||||
If you're having issues with Git authentication on Linux, see the [Air-Gapped Installation guide](install/air-gapped.md#git-credential-manager-on-linux) for Git Credential Manager setup instructions.
|
||||
|
||||
@@ -5,11 +5,19 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
|
||||
> [!NOTE]
|
||||
> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`.
|
||||
|
||||
## The 6-Step Process
|
||||
## Recommended Workflow
|
||||
|
||||
> [!TIP]
|
||||
> **Context Awareness**: Spec Kit commands automatically detect the active feature based on your current Git branch (e.g., `001-feature-name`). To switch between different specifications, simply switch Git branches.
|
||||
|
||||
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
|
||||
|
||||
```text
|
||||
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
|
||||
```
|
||||
|
||||
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
|
||||
|
||||
### Step 1: Install Specify
|
||||
|
||||
**In your terminal**, run the `specify` CLI command to initialize your project:
|
||||
@@ -24,10 +32,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init .
|
||||
|
||||
> [!NOTE]
|
||||
> You can also install the CLI persistently with `pipx`:
|
||||
>
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git
|
||||
> ```
|
||||
>
|
||||
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
|
||||
>
|
||||
> ```bash
|
||||
> specify init <PROJECT_NAME>
|
||||
> specify init .
|
||||
@@ -56,7 +67,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
|
||||
```
|
||||
|
||||
### Step 4: Refine the Spec
|
||||
### Step 4: Refine and Validate the Spec
|
||||
|
||||
**In the chat**, use the `/speckit.clarify` slash command to identify and resolve ambiguities in your specification. You can provide specific focus areas as arguments.
|
||||
|
||||
@@ -64,6 +75,12 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.clarify Focus on security and performance requirements.
|
||||
```
|
||||
|
||||
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
|
||||
|
||||
```bash
|
||||
/speckit.checklist
|
||||
```
|
||||
|
||||
### Step 5: Create a Technical Implementation Plan
|
||||
|
||||
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
|
||||
@@ -72,7 +89,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
|
||||
```
|
||||
|
||||
### Step 6: Break Down and Implement
|
||||
### Step 6: Break Down, Analyze, and Implement
|
||||
|
||||
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
|
||||
|
||||
@@ -80,13 +97,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.tasks
|
||||
```
|
||||
|
||||
Optionally, validate the plan with `/speckit.analyze`:
|
||||
Validate cross-artifact consistency with `/speckit.analyze` before implementation:
|
||||
|
||||
```markdown
|
||||
/speckit.analyze
|
||||
```
|
||||
|
||||
Then, use the `/speckit.implement` slash command to execute the plan.
|
||||
Use the `/speckit.implement` slash command to execute the plan.
|
||||
|
||||
```markdown
|
||||
/speckit.implement
|
||||
@@ -159,7 +176,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 coding agent audit the spec, plan, and tasks with `/speckit.analyze` before implementation:
|
||||
|
||||
```bash
|
||||
/speckit.analyze
|
||||
@@ -179,7 +196,7 @@ Finally, implement the solution:
|
||||
- **Be explicit** about what you're building and why
|
||||
- **Don't focus on tech stack** during specification phase
|
||||
- **Iterate and refine** your specifications before implementation
|
||||
- **Validate** the plan before coding begins
|
||||
- **Validate** requirements and plans before coding begins
|
||||
- **Let the coding agent handle** the implementation details
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -77,6 +77,16 @@ specify version
|
||||
|
||||
Displays the Spec Kit CLI version, Python version, platform, and architecture.
|
||||
|
||||
To inspect local CLI capabilities without checking the network:
|
||||
|
||||
```bash
|
||||
specify version --features
|
||||
specify version --features --json
|
||||
```
|
||||
|
||||
The JSON form is intended for scripts and coding agents that need to choose a
|
||||
workflow based on the installed CLI's supported features.
|
||||
|
||||
A quick version check is also available via:
|
||||
|
||||
```bash
|
||||
|
||||
11
docs/toc.yml
11
docs/toc.yml
@@ -13,6 +13,12 @@
|
||||
href: upgrade.md
|
||||
- name: Install uv
|
||||
href: install/uv.md
|
||||
- name: Install with pipx
|
||||
href: install/pipx.md
|
||||
- name: One-time Usage (uvx)
|
||||
href: install/one-time.md
|
||||
- name: Enterprise / Air-Gapped
|
||||
href: install/air-gapped.md
|
||||
|
||||
# Reference
|
||||
- name: Reference
|
||||
@@ -44,7 +50,12 @@
|
||||
|
||||
# Community
|
||||
- name: Community
|
||||
href: community/overview.md
|
||||
items:
|
||||
- name: Overview
|
||||
href: community/overview.md
|
||||
- name: Extensions
|
||||
href: community/extensions.md
|
||||
- name: Presets
|
||||
href: community/presets.md
|
||||
- name: Walkthroughs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-12T21:40:51Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -68,6 +68,43 @@
|
||||
"created_at": "2026-03-31T00:00:00Z",
|
||||
"updated_at": "2026-03-31T00:00:00Z"
|
||||
},
|
||||
"agent-governance": {
|
||||
"name": "Agent Governance",
|
||||
"id": "agent-governance",
|
||||
"description": "Project-local agent governance memory and context projection.",
|
||||
"author": "bigben",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-agent-governance",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-agent-governance",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "python3",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"governance",
|
||||
"agents",
|
||||
"memory",
|
||||
"context"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
"name": "Intelligent Agent Orchestrator",
|
||||
"id": "agent-orchestrator",
|
||||
@@ -137,6 +174,37 @@
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"updated_at": "2026-05-07T00:00:00Z"
|
||||
},
|
||||
"arch": {
|
||||
"name": "Architecture Workflow",
|
||||
"id": "arch",
|
||||
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-arch/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"4plus1",
|
||||
"workflow",
|
||||
"design"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -1846,6 +1914,37 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"preview": {
|
||||
"name": "Interactive HTML Preview",
|
||||
"id": "preview",
|
||||
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-preview/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"preview",
|
||||
"prototype",
|
||||
"html",
|
||||
"ux"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
"id": "product-forge",
|
||||
@@ -2081,6 +2180,44 @@
|
||||
"created_at": "2026-03-23T13:30:00Z",
|
||||
"updated_at": "2026-03-23T13:30:00Z"
|
||||
},
|
||||
"reqnroll-bdd": {
|
||||
"name": "Reqnroll BDD",
|
||||
"id": "reqnroll-bdd",
|
||||
"description": "Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit.",
|
||||
"author": "LoogaCY Studio",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"homepage": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"documentation": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd#readme",
|
||||
"changelog": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "dotnet",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"bdd",
|
||||
"reqnroll",
|
||||
"dotnet",
|
||||
"gherkin",
|
||||
"acceptance-testing"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-13T00:00:00Z",
|
||||
"updated_at": "2026-05-13T00:00:00Z"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro Extension",
|
||||
"id": "retro",
|
||||
@@ -2475,6 +2612,55 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"speckit-superpowers-bridge": {
|
||||
"name": "Superpowers Implementation Bridge",
|
||||
"id": "speckit-superpowers-bridge",
|
||||
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
||||
"author": "lihan3238",
|
||||
"version": "0.5.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.5.0/speckit-superpowers-bridge-v0.5.0.zip",
|
||||
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
||||
"changelog": "https://github.com/lihan3238/speckit-superpowers-bridge/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10",
|
||||
"tools": [
|
||||
{
|
||||
"name": "powershell",
|
||||
"version": ">=5.1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "bash",
|
||||
"version": ">=4.0",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "jq",
|
||||
"version": ">=1.6",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"bridge",
|
||||
"superpowers",
|
||||
"cross-agent",
|
||||
"tdd",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
@@ -2543,21 +2729,21 @@
|
||||
"squad": {
|
||||
"name": "Squad Bridge",
|
||||
"id": "squad",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks.",
|
||||
"author": "jwill824",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.3.0.zip",
|
||||
"repository": "https://github.com/jwill824/spec-kit-squad",
|
||||
"homepage": "https://github.com/jwill824/spec-kit-squad",
|
||||
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
|
||||
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"speckit_version": ">=0.8.11",
|
||||
"tools": [
|
||||
{
|
||||
"name": "@bradygaster/squad-cli",
|
||||
"version": ">=0.1.0",
|
||||
"version": ">=0.9.4",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
@@ -2577,7 +2763,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-29T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"staff-review": {
|
||||
"name": "Staff Review Extension",
|
||||
@@ -2779,6 +2965,74 @@
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"team-assign": {
|
||||
"name": "Team Assign",
|
||||
"id": "team-assign",
|
||||
"description": "Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard",
|
||||
"author": "tarunkumarbhati",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tarunkumarbhati/spec-kit-team-assign/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
|
||||
"homepage": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
|
||||
"documentation": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/README.md",
|
||||
"changelog": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"team",
|
||||
"assignment",
|
||||
"process",
|
||||
"planning",
|
||||
"subtasks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-20T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"time-machine": {
|
||||
"name": "Time Machine",
|
||||
"id": "time-machine",
|
||||
"description": "Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature",
|
||||
"author": "te3yo",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/teeyo/spec-kit-time-machine/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"homepage": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"documentation": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"changelog": "https://github.com/teeyo/spec-kit-time-machine/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"brownfield",
|
||||
"automation",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"tinyspec": {
|
||||
"name": "TinySpec",
|
||||
"id": "tinyspec",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.9"
|
||||
version = "0.8.12"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -350,7 +350,10 @@ if (-not $DryRun) {
|
||||
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
if ($template -and (Test-Path $template)) {
|
||||
Copy-Item $template $specFile -Force
|
||||
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
121
src/specify_cli/_assets.py
Normal file
121
src/specify_cli/_assets.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Bundle path resolution and version lookup for specify_cli.
|
||||
|
||||
Stdlib-only; zero internal imports so it sits at the base of the dependency
|
||||
graph without risk of circular imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _locate_core_pack() -> Path | None:
|
||||
"""Return the filesystem path to the bundled core_pack directory, or None.
|
||||
|
||||
Only present in wheel installs: hatchling's force-include copies
|
||||
templates/, scripts/ etc. into specify_cli/core_pack/ at build time.
|
||||
|
||||
Source-checkout and editable installs do NOT have this directory.
|
||||
Callers that need to work in both environments must check the repo-root
|
||||
trees (templates/, scripts/) as a fallback when this returns None.
|
||||
"""
|
||||
# Wheel install: core_pack is a sibling directory of this file
|
||||
candidate = Path(__file__).parent / "core_pack"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""Return the source checkout root used for editable installs."""
|
||||
return Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def _locate_bundled_extension(extension_id: str) -> Path | None:
|
||||
"""Return the path to a bundled extension, or None.
|
||||
|
||||
Checks the wheel's core_pack first, then falls back to the
|
||||
source-checkout ``extensions/<id>/`` directory.
|
||||
"""
|
||||
if not re.match(r'^[a-z0-9-]+$', extension_id):
|
||||
return None
|
||||
|
||||
core = _locate_core_pack()
|
||||
if core is not None:
|
||||
candidate = core / "extensions" / extension_id
|
||||
if (candidate / "extension.yml").is_file():
|
||||
return candidate
|
||||
|
||||
# Source-checkout / editable install: look relative to repo root
|
||||
candidate = _repo_root() / "extensions" / extension_id
|
||||
if (candidate / "extension.yml").is_file():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _locate_bundled_workflow(workflow_id: str) -> Path | None:
|
||||
"""Return the path to a bundled workflow directory, or None.
|
||||
|
||||
Checks the wheel's core_pack first, then falls back to the
|
||||
source-checkout ``workflows/<id>/`` directory.
|
||||
"""
|
||||
if not re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id):
|
||||
return None
|
||||
|
||||
core = _locate_core_pack()
|
||||
if core is not None:
|
||||
candidate = core / "workflows" / workflow_id
|
||||
if (candidate / "workflow.yml").is_file():
|
||||
return candidate
|
||||
|
||||
# Source-checkout / editable install: look relative to repo root
|
||||
candidate = _repo_root() / "workflows" / workflow_id
|
||||
if (candidate / "workflow.yml").is_file():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _locate_bundled_preset(preset_id: str) -> Path | None:
|
||||
"""Return the path to a bundled preset, or None.
|
||||
|
||||
Checks the wheel's core_pack first, then falls back to the
|
||||
source-checkout ``presets/<id>/`` directory.
|
||||
"""
|
||||
if not re.match(r'^[a-z0-9-]+$', preset_id):
|
||||
return None
|
||||
|
||||
core = _locate_core_pack()
|
||||
if core is not None:
|
||||
candidate = core / "presets" / preset_id
|
||||
if (candidate / "preset.yml").is_file():
|
||||
return candidate
|
||||
|
||||
# Source-checkout / editable install: look relative to repo root
|
||||
candidate = _repo_root() / "presets" / preset_id
|
||||
if (candidate / "preset.yml").is_file():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_speckit_version() -> str:
|
||||
"""Get current spec-kit version."""
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except Exception:
|
||||
# Fallback: try reading from pyproject.toml
|
||||
try:
|
||||
import tomllib
|
||||
pyproject_path = _repo_root() / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data.get("project", {}).get("version", "unknown")
|
||||
except Exception:
|
||||
# Intentionally ignore any errors while reading/parsing pyproject.toml.
|
||||
# If this lookup fails for any reason, we fall back to returning "unknown" below.
|
||||
pass
|
||||
return "unknown"
|
||||
245
src/specify_cli/_console.py
Normal file
245
src/specify_cli/_console.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Base Rich/Typer console layer for the specify CLI.
|
||||
|
||||
This module is the single source of Rich ``Console`` instances and Typer UI
|
||||
helpers used throughout ``specify_cli``. Nothing in this file should import
|
||||
from other ``specify_cli`` sub-modules; all dependencies must flow *into* this
|
||||
layer, not out of it, to avoid circular imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import readchar
|
||||
import typer
|
||||
from rich.align import Align
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
from typer.core import TyperGroup
|
||||
|
||||
BANNER = """
|
||||
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
|
||||
███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝
|
||||
╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝
|
||||
███████║██║ ███████╗╚██████╗██║██║ ██║
|
||||
╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝
|
||||
"""
|
||||
|
||||
TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
|
||||
|
||||
console = Console(highlight=False)
|
||||
|
||||
class StepTracker:
|
||||
"""Track and render hierarchical steps without emojis, similar to Claude Code tree output.
|
||||
Supports live auto-refresh via an attached refresh callback.
|
||||
"""
|
||||
def __init__(self, title: str):
|
||||
self.title = title
|
||||
self.steps = [] # list of dicts: {key, label, status, detail}
|
||||
self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4}
|
||||
self._refresh_cb: Callable[[], None] | None = None
|
||||
|
||||
def attach_refresh(self, cb: Callable[[], None]) -> None:
|
||||
self._refresh_cb = cb
|
||||
|
||||
def add(self, key: str, label: str):
|
||||
if key not in [s["key"] for s in self.steps]:
|
||||
self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""})
|
||||
self._maybe_refresh()
|
||||
|
||||
def start(self, key: str, detail: str = ""):
|
||||
self._update(key, status="running", detail=detail)
|
||||
|
||||
def complete(self, key: str, detail: str = ""):
|
||||
self._update(key, status="done", detail=detail)
|
||||
|
||||
def error(self, key: str, detail: str = ""):
|
||||
self._update(key, status="error", detail=detail)
|
||||
|
||||
def skip(self, key: str, detail: str = ""):
|
||||
self._update(key, status="skipped", detail=detail)
|
||||
|
||||
def _update(self, key: str, status: str, detail: str):
|
||||
for s in self.steps:
|
||||
if s["key"] == key:
|
||||
s["status"] = status
|
||||
if detail:
|
||||
s["detail"] = detail
|
||||
self._maybe_refresh()
|
||||
return
|
||||
|
||||
self.steps.append({"key": key, "label": key, "status": status, "detail": detail})
|
||||
self._maybe_refresh()
|
||||
|
||||
def _maybe_refresh(self):
|
||||
if self._refresh_cb:
|
||||
try:
|
||||
self._refresh_cb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def render(self):
|
||||
tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50")
|
||||
for step in self.steps:
|
||||
label = step["label"]
|
||||
detail_text = step["detail"].strip() if step["detail"] else ""
|
||||
|
||||
status = step["status"]
|
||||
if status == "done":
|
||||
symbol = "[green]●[/green]"
|
||||
elif status == "pending":
|
||||
symbol = "[green dim]○[/green dim]"
|
||||
elif status == "running":
|
||||
symbol = "[cyan]○[/cyan]"
|
||||
elif status == "error":
|
||||
symbol = "[red]●[/red]"
|
||||
elif status == "skipped":
|
||||
symbol = "[yellow]○[/yellow]"
|
||||
else:
|
||||
symbol = " "
|
||||
|
||||
if status == "pending":
|
||||
# Entire line light gray (pending)
|
||||
if detail_text:
|
||||
line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]"
|
||||
else:
|
||||
line = f"{symbol} [bright_black]{label}[/bright_black]"
|
||||
else:
|
||||
# Label white, detail (if any) light gray in parentheses
|
||||
if detail_text:
|
||||
line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]"
|
||||
else:
|
||||
line = f"{symbol} [white]{label}[/white]"
|
||||
|
||||
tree.add(line)
|
||||
return tree
|
||||
|
||||
|
||||
def get_key():
|
||||
"""Get a single keypress in a cross-platform way using readchar."""
|
||||
key = readchar.readkey()
|
||||
|
||||
if key == readchar.key.UP or key == readchar.key.CTRL_P:
|
||||
return 'up'
|
||||
if key == readchar.key.DOWN or key == readchar.key.CTRL_N:
|
||||
return 'down'
|
||||
|
||||
if key == readchar.key.ENTER:
|
||||
return 'enter'
|
||||
|
||||
if key == readchar.key.ESC:
|
||||
return 'escape'
|
||||
|
||||
if key == readchar.key.CTRL_C:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
return key
|
||||
|
||||
def select_with_arrows(
|
||||
options: dict[str, str],
|
||||
prompt_text: str = "Select an option",
|
||||
default_key: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Interactive selection using arrow keys with Rich Live display.
|
||||
|
||||
Args:
|
||||
options: Dict with keys as option keys and values as descriptions
|
||||
prompt_text: Text to show above the options
|
||||
default_key: Default option key to start with
|
||||
|
||||
Returns:
|
||||
Selected option key
|
||||
"""
|
||||
if not options:
|
||||
raise ValueError("select_with_arrows() requires at least one option.")
|
||||
|
||||
option_keys = list(options.keys())
|
||||
if default_key and default_key in option_keys:
|
||||
selected_index = option_keys.index(default_key)
|
||||
else:
|
||||
selected_index = 0
|
||||
|
||||
selected_key = None
|
||||
|
||||
def create_selection_panel():
|
||||
"""Create the selection panel with current selection highlighted."""
|
||||
table = Table.grid(padding=(0, 2))
|
||||
table.add_column(style="cyan", justify="left", width=3)
|
||||
table.add_column(style="white", justify="left")
|
||||
|
||||
for i, key in enumerate(option_keys):
|
||||
if i == selected_index:
|
||||
table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
|
||||
else:
|
||||
table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]")
|
||||
|
||||
table.add_row("", "")
|
||||
table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]")
|
||||
|
||||
return Panel(
|
||||
table,
|
||||
title=f"[bold]{prompt_text}[/bold]",
|
||||
border_style="cyan",
|
||||
padding=(1, 2)
|
||||
)
|
||||
|
||||
console.print()
|
||||
|
||||
def run_selection_loop():
|
||||
nonlocal selected_key, selected_index
|
||||
with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:
|
||||
while True:
|
||||
try:
|
||||
key = get_key()
|
||||
if key == 'up':
|
||||
selected_index = (selected_index - 1) % len(option_keys)
|
||||
elif key == 'down':
|
||||
selected_index = (selected_index + 1) % len(option_keys)
|
||||
elif key == 'enter':
|
||||
selected_key = option_keys[selected_index]
|
||||
break
|
||||
elif key == 'escape':
|
||||
console.print("\n[yellow]Selection cancelled[/yellow]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
live.update(create_selection_panel(), refresh=True)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Selection cancelled[/yellow]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
run_selection_loop()
|
||||
|
||||
if selected_key is None:
|
||||
console.print("\n[red]Selection failed.[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
return selected_key
|
||||
|
||||
class BannerGroup(TyperGroup):
|
||||
"""Custom group that shows banner before help."""
|
||||
|
||||
def format_help(self, ctx, formatter):
|
||||
# Show banner before help
|
||||
show_banner()
|
||||
super().format_help(ctx, formatter)
|
||||
|
||||
|
||||
def show_banner():
|
||||
"""Display the ASCII art banner."""
|
||||
banner_lines = BANNER.strip().split('\n')
|
||||
colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"]
|
||||
|
||||
styled_banner = Text()
|
||||
for i, line in enumerate(banner_lines):
|
||||
color = colors[i % len(colors)]
|
||||
styled_banner.append(line + "\n", style=color)
|
||||
|
||||
console.print(Align.center(styled_banner))
|
||||
console.print(Align.center(Text(TAGLINE, style="italic bright_yellow")))
|
||||
console.print()
|
||||
282
src/specify_cli/_utils.py
Normal file
282
src/specify_cli/_utils.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""System utilities: subprocess, tool detection, file operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import json5
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from ._console import console
|
||||
|
||||
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
||||
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
||||
|
||||
|
||||
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
|
||||
"""Run a shell command and optionally capture output."""
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
subprocess.run(cmd, check=check_return, shell=shell)
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
if check_return:
|
||||
console.print(f"[red]Error running command:[/red] {' '.join(cmd)}")
|
||||
console.print(f"[red]Exit code:[/red] {e.returncode}")
|
||||
if hasattr(e, 'stderr') and e.stderr:
|
||||
console.print(f"[red]Error output:[/red] {e.stderr}")
|
||||
raise
|
||||
return None
|
||||
|
||||
|
||||
def check_tool(tool: str, tracker=None) -> bool:
|
||||
"""Check if a tool is installed. Optionally update tracker.
|
||||
|
||||
Args:
|
||||
tool: Name of the tool to check
|
||||
tracker: StepTracker | None to update with results
|
||||
|
||||
Returns:
|
||||
True if tool is found, False otherwise
|
||||
"""
|
||||
# Special handling for Claude CLI local installs
|
||||
# See: https://github.com/github/spec-kit/issues/123
|
||||
# See: https://github.com/github/spec-kit/issues/550
|
||||
# Claude Code can be installed in two local paths:
|
||||
# 1. ~/.claude/local/claude (after `claude migrate-installer`)
|
||||
# 2. ~/.claude/local/node_modules/.bin/claude (npm-local install, e.g. via nvm)
|
||||
# Neither path may be on the system PATH, so we check them explicitly.
|
||||
if tool == "claude":
|
||||
if CLAUDE_LOCAL_PATH.is_file() or CLAUDE_NPM_LOCAL_PATH.is_file():
|
||||
if tracker:
|
||||
tracker.complete(tool, "available")
|
||||
return True
|
||||
|
||||
if tool == "kiro-cli":
|
||||
# Kiro currently supports both executable names. Prefer kiro-cli and
|
||||
# accept kiro as a compatibility fallback.
|
||||
found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None
|
||||
else:
|
||||
found = shutil.which(tool) is not None
|
||||
|
||||
if tracker:
|
||||
if found:
|
||||
tracker.complete(tool, "available")
|
||||
else:
|
||||
tracker.error(tool, "not found")
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def is_git_repo(path: Path | None = None) -> bool:
|
||||
"""Check if the specified path is inside a git repository."""
|
||||
if path is None:
|
||||
path = Path.cwd()
|
||||
|
||||
if not path.is_dir():
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
cwd=path,
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
|
||||
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
|
||||
"""Initialize a git repository in the specified path."""
|
||||
try:
|
||||
original_cwd = Path.cwd()
|
||||
os.chdir(project_path)
|
||||
if not quiet:
|
||||
console.print("[cyan]Initializing git repository...[/cyan]")
|
||||
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
|
||||
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
|
||||
if not quiet:
|
||||
console.print("[green]✓[/green] Git repository initialized")
|
||||
return True, None
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
|
||||
if e.stderr:
|
||||
error_msg += f"\nError: {e.stderr.strip()}"
|
||||
elif e.stdout:
|
||||
error_msg += f"\nOutput: {e.stdout.strip()}"
|
||||
if not quiet:
|
||||
console.print(f"[red]Error initializing git repository:[/red] {e}")
|
||||
return False, error_msg
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||
"""Handle merging or copying of .vscode/settings.json files.
|
||||
|
||||
Note: when merge produces changes, rewritten output is normalized JSON and
|
||||
existing JSONC comments/trailing commas are not preserved.
|
||||
"""
|
||||
def log(message, color="green"):
|
||||
if verbose and not tracker:
|
||||
console.print(f"[{color}]{message}[/] {rel_path}")
|
||||
|
||||
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
|
||||
"""Atomically write JSON while preserving existing mode bits when possible."""
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
encoding='utf-8',
|
||||
dir=target_file.parent,
|
||||
prefix=f"{target_file.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as f:
|
||||
temp_path = Path(f.name)
|
||||
json.dump(payload, f, indent=4)
|
||||
f.write('\n')
|
||||
|
||||
if target_file.exists():
|
||||
try:
|
||||
existing_stat = target_file.stat()
|
||||
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
|
||||
if hasattr(os, "chown"):
|
||||
try:
|
||||
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
|
||||
except PermissionError:
|
||||
# Best-effort owner/group preservation without requiring elevated privileges.
|
||||
pass
|
||||
except OSError:
|
||||
# Best-effort metadata preservation; data safety is prioritized.
|
||||
pass
|
||||
|
||||
os.replace(temp_path, target_file)
|
||||
except Exception:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(sub_item, 'r', encoding='utf-8') as f:
|
||||
# json5 natively supports comments and trailing commas (JSONC)
|
||||
new_settings = json5.load(f)
|
||||
|
||||
if dest_file.exists():
|
||||
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
||||
if merged is not None:
|
||||
atomic_write_json(dest_file, merged)
|
||||
log("Merged:", "green")
|
||||
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
|
||||
else:
|
||||
log("Skipped merge (preserved existing settings)", "yellow")
|
||||
else:
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
log("Copied (no existing settings.json):", "blue")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Warning: Could not merge settings: {e}", "yellow")
|
||||
if not dest_file.exists():
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
|
||||
|
||||
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> dict[str, Any] | None:
|
||||
"""Merge new JSON content into existing JSON file.
|
||||
|
||||
Performs a polite deep merge where:
|
||||
- New keys are added
|
||||
- Existing keys are preserved (not overwritten) unless both values are dictionaries
|
||||
- Nested dictionaries are merged recursively only when both sides are dictionaries
|
||||
- Lists and other values are preserved from base if they exist
|
||||
|
||||
Args:
|
||||
existing_path: Path to existing JSON file
|
||||
new_content: New JSON content to merge in
|
||||
verbose: Whether to print merge details
|
||||
|
||||
Returns:
|
||||
Merged JSON content as dict, or None if the existing file should be left untouched.
|
||||
"""
|
||||
# Load existing content first to have a safe fallback
|
||||
existing_content = None
|
||||
exists = existing_path.exists()
|
||||
|
||||
if exists:
|
||||
try:
|
||||
with open(existing_path, 'r', encoding='utf-8') as f:
|
||||
# Handle comments (JSONC) natively with json5
|
||||
# Note: json5 handles BOM automatically
|
||||
existing_content = json5.load(f)
|
||||
except FileNotFoundError:
|
||||
# Handle race condition where file is deleted after exists() check
|
||||
exists = False
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
|
||||
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
|
||||
return None
|
||||
|
||||
# Validate template content
|
||||
if not isinstance(new_content, dict):
|
||||
if verbose:
|
||||
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
|
||||
return None
|
||||
|
||||
if not exists:
|
||||
return new_content
|
||||
|
||||
# If existing content parsed but is not a dict, skip merge to avoid data loss
|
||||
if not isinstance(existing_content, dict):
|
||||
if verbose:
|
||||
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
|
||||
return None
|
||||
|
||||
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge update dict into base dict, preserving base values."""
|
||||
result = base.copy()
|
||||
for key, value in update.items():
|
||||
if key not in result:
|
||||
# Add new key
|
||||
result[key] = value
|
||||
elif isinstance(result[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
result[key] = deep_merge_polite(result[key], value)
|
||||
else:
|
||||
# Key already exists and values are not both dicts; preserve existing value.
|
||||
# This ensures user settings aren't overwritten by template defaults.
|
||||
pass
|
||||
return result
|
||||
|
||||
merged = deep_merge_polite(existing_content, new_content)
|
||||
|
||||
# Detect if anything actually changed. If not, return None so the caller
|
||||
# can skip rewriting the file (preserving user's comments/formatting).
|
||||
if merged == existing_content:
|
||||
return None
|
||||
|
||||
if verbose:
|
||||
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _display_project_path(project_root: Path, path: str | Path) -> str:
|
||||
"""Return a stable POSIX-style display path for paths under a project."""
|
||||
path_obj = Path(path)
|
||||
try:
|
||||
rel_path = path_obj.relative_to(project_root) if path_obj.is_absolute() else path_obj
|
||||
except ValueError:
|
||||
try:
|
||||
rel_path = path_obj.resolve().relative_to(project_root.resolve())
|
||||
except (OSError, ValueError):
|
||||
return path_obj.as_posix()
|
||||
return rel_path.as_posix()
|
||||
173
src/specify_cli/_version.py
Normal file
173
src/specify_cli/_version.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Version checking and self-update commands for specify_cli.
|
||||
|
||||
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
||||
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
||||
all version-related logic lives in one place.
|
||||
|
||||
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
||||
at module level, keeping this layer thin and circular-import-safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from ._console import console
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
|
||||
def _get_installed_version() -> str:
|
||||
"""Return the installed specify-cli distribution version or 'unknown'.
|
||||
|
||||
Uses importlib.metadata so the value reflects what was actually installed
|
||||
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
||||
intentional for `specify self check`, which should reason about the
|
||||
installed distribution rather than a source-tree fallback. Callers must
|
||||
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is not None:
|
||||
metadata_errors.append(invalid_metadata_error)
|
||||
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except tuple(metadata_errors):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_tag(tag: str) -> str:
|
||||
"""Strip exactly one leading 'v' from a release tag.
|
||||
|
||||
Returns the rest of the string unchanged. This handles the common
|
||||
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
||||
aggressively (e.g., two leading 'v's keeps one).
|
||||
"""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
||||
|
||||
Returns False whenever either side is 'unknown' or fails to parse; this
|
||||
keeps the comparison indeterminate (rather than crashing or falsely
|
||||
recommending a downgrade) on edge inputs.
|
||||
"""
|
||||
if latest == "unknown" or current == "unknown":
|
||||
return False
|
||||
try:
|
||||
return Version(latest) > Version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
||||
|
||||
On success: (tag_name, None).
|
||||
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
return tag, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
|
||||
|
||||
# ===== Self Commands =====
|
||||
|
||||
self_app = typer.Typer(
|
||||
name="self",
|
||||
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@self_app.command("check")
|
||||
def self_check() -> None:
|
||||
"""Check whether a newer specify-cli release is available. Read-only.
|
||||
|
||||
This command only checks for updates; it does not modify your installation.
|
||||
The reserved (and currently non-destructive) `specify self upgrade` command
|
||||
is the name that a future release will use for actual self-upgrade — its
|
||||
behavior is not implemented in this release and is intentionally out of
|
||||
scope here. See `specify self upgrade --help` for its current status.
|
||||
"""
|
||||
installed = _get_installed_version()
|
||||
tag, failure_reason = _fetch_latest_release_tag()
|
||||
|
||||
if tag is None:
|
||||
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
||||
# enumerated strings produced by _fetch_latest_release_tag() — it
|
||||
# never contains a URL, headers, response body, or traceback.
|
||||
assert failure_reason is not None
|
||||
console.print(f"Installed: {installed}")
|
||||
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
||||
return
|
||||
|
||||
latest_normalized = _normalize_tag(tag)
|
||||
|
||||
if installed == "unknown":
|
||||
# FR-020: surface the latest release and the recovery action even
|
||||
# when the local distribution metadata is unavailable.
|
||||
console.print("Current version could not be determined.")
|
||||
console.print(f"Latest release: {latest_normalized}")
|
||||
console.print("\nTo reinstall:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
if _is_newer(latest_normalized, installed):
|
||||
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
||||
console.print("\nTo upgrade:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
||||
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
||||
# returns False, and the up-to-date branch is the safer default per
|
||||
# FR-004 / test T016.
|
||||
console.print(f"[green]Up to date:[/green] {installed}")
|
||||
|
||||
|
||||
@self_app.command("upgrade")
|
||||
def self_upgrade() -> None:
|
||||
"""Reserved command surface for self-upgrade; not implemented in this release.
|
||||
|
||||
This command is a documented non-destructive stub in this release: it
|
||||
performs no outbound network request, no install-method detection, and
|
||||
invokes no installer. It prints a three-line guidance message and exits 0.
|
||||
Actual self-upgrade is planned as follow-up work.
|
||||
|
||||
Use `specify self check` today to see whether a newer release is available
|
||||
and to get a copy-pasteable reinstall command.
|
||||
"""
|
||||
console.print("specify self upgrade is not implemented yet.")
|
||||
console.print("Run 'specify self check' to see whether a newer release is available.")
|
||||
console.print("Actual self-upgrade is planned as follow-up work.")
|
||||
@@ -438,6 +438,7 @@ class CommandRegistrar:
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
_resolved_dir: Path = None,
|
||||
) -> List[str]:
|
||||
"""Register commands for a specific agent.
|
||||
|
||||
@@ -448,6 +449,10 @@ class CommandRegistrar:
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
_resolved_dir: Pre-resolved command directory (internal use
|
||||
only — avoids a second ``_resolve_agent_dir`` call and
|
||||
duplicate deprecation warnings when invoked from
|
||||
``register_commands_for_all_agents``).
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
@@ -460,7 +465,9 @@ class CommandRegistrar:
|
||||
raise ValueError(f"Unsupported agent: {agent_name}")
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
commands_dir = _resolved_dir or self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
registered = []
|
||||
@@ -639,6 +646,40 @@ class CommandRegistrar:
|
||||
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_dir(
|
||||
agent_name: str,
|
||||
agent_config: dict[str, Any],
|
||||
project_root: Path,
|
||||
) -> Path:
|
||||
"""Return the agent command directory, falling back to legacy_dir.
|
||||
|
||||
When the canonical directory (``agent_config["dir"]``) does not
|
||||
exist but a ``legacy_dir`` is configured and present on disk,
|
||||
returns the legacy path and emits a deprecation warning advising
|
||||
the user to upgrade.
|
||||
|
||||
Integrations that do not declare ``legacy_dir`` get the canonical
|
||||
path unconditionally — no fallback, no warning.
|
||||
"""
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
if not agent_dir.exists():
|
||||
legacy = agent_config.get("legacy_dir")
|
||||
if legacy:
|
||||
legacy_dir = project_root / legacy
|
||||
if legacy_dir.exists():
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Found legacy '{legacy}' directory for "
|
||||
f"{agent_name}. Run 'specify integration "
|
||||
f"upgrade {agent_name}' to migrate to "
|
||||
f"'{agent_config['dir']}'.",
|
||||
stacklevel=3,
|
||||
)
|
||||
return legacy_dir
|
||||
return agent_dir
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
commands: List[Dict[str, Any]],
|
||||
@@ -663,7 +704,9 @@ class CommandRegistrar:
|
||||
|
||||
self._ensure_configs()
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
@@ -674,6 +717,7 @@ class CommandRegistrar:
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -711,7 +755,9 @@ class CommandRegistrar:
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
@@ -721,6 +767,7 @@ class CommandRegistrar:
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -733,6 +780,11 @@ class CommandRegistrar:
|
||||
) -> None:
|
||||
"""Remove previously registered command files from agent directories.
|
||||
|
||||
When a ``legacy_dir`` is configured, files are removed from
|
||||
*both* the canonical and the legacy directory so that orphaned
|
||||
commands left behind after an ``integration upgrade`` are
|
||||
cleaned up as well.
|
||||
|
||||
Args:
|
||||
registered_commands: Dict mapping agent names to command name lists
|
||||
project_root: Path to project root
|
||||
@@ -743,24 +795,39 @@ class CommandRegistrar:
|
||||
continue
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
commands_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
|
||||
# Collect all directories to clean: canonical (or resolved
|
||||
# legacy) plus the legacy dir if it exists separately.
|
||||
dirs_to_clean = [commands_dir]
|
||||
legacy = agent_config.get("legacy_dir")
|
||||
if legacy:
|
||||
legacy_dir = project_root / legacy
|
||||
if legacy_dir.exists() and legacy_dir != commands_dir:
|
||||
dirs_to_clean.append(legacy_dir)
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
output_name = self._compute_output_name(
|
||||
agent_name, cmd_name, agent_config
|
||||
)
|
||||
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
# For SKILL.md agents each command lives in its own subdirectory
|
||||
# (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the
|
||||
# parent dir when it becomes empty to avoid orphaned directories.
|
||||
parent = cmd_file.parent
|
||||
if parent != commands_dir and parent.exists():
|
||||
try:
|
||||
parent.rmdir() # no-op if dir still has other files
|
||||
except OSError:
|
||||
pass
|
||||
for target_dir in dirs_to_clean:
|
||||
cmd_file = (
|
||||
target_dir / f"{output_name}{agent_config['extension']}"
|
||||
)
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
# For SKILL.md agents each command lives in its own
|
||||
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
|
||||
# SKILL.md). Remove the parent dir when it becomes
|
||||
# empty to avoid orphaned directories.
|
||||
parent = cmd_file.parent
|
||||
if parent != target_dir and parent.exists():
|
||||
try:
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if agent_name == "copilot":
|
||||
prompt_file = (
|
||||
|
||||
@@ -25,6 +25,8 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"checklist",
|
||||
@@ -107,13 +109,8 @@ def normalize_priority(value: Any, default: int = 10) -> int:
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry:
|
||||
class CatalogEntry(BaseCatalogEntry):
|
||||
"""Represents a single catalog entry in the catalog stack."""
|
||||
url: str
|
||||
name: str
|
||||
priority: int
|
||||
install_allowed: bool
|
||||
description: str = ""
|
||||
|
||||
|
||||
class ExtensionManifest:
|
||||
@@ -1190,7 +1187,7 @@ class ExtensionManager:
|
||||
# was used during project initialisation (feature parity).
|
||||
registered_skills = self._register_extension_skills(manifest, dest_dir)
|
||||
|
||||
# Register hooks
|
||||
# Register hooks and update installed list in extensions.yml
|
||||
hook_executor = HookExecutor(self.project_root)
|
||||
hook_executor.register_hooks(manifest)
|
||||
|
||||
@@ -1666,12 +1663,16 @@ class CommandRegistrar:
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
|
||||
|
||||
class ExtensionCatalog:
|
||||
class ExtensionCatalog(CatalogStackBase):
|
||||
"""Manages extension catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
CONFIG_FILENAME = "extension-catalogs.yml"
|
||||
ENTRY_CLASS = CatalogEntry
|
||||
ERROR_TYPE = ValidationError
|
||||
VALIDATION_ERROR_TYPE = ValidationError
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize extension catalog manager.
|
||||
@@ -1685,27 +1686,6 @@ class ExtensionCatalog:
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
ValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
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 auth headers when a provider matches.
|
||||
|
||||
@@ -1722,81 +1702,6 @@ class ExtensionCatalog:
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to extension-catalogs.yml
|
||||
|
||||
Returns:
|
||||
Ordered list of CatalogEntry objects, or None if file doesn't exist.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any catalog entry has an invalid URL,
|
||||
the file cannot be parsed, a priority value is invalid,
|
||||
or the file exists but contains no valid catalog entries
|
||||
(fail-closed for security).
|
||||
"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as e:
|
||||
raise ValidationError(
|
||||
f"Failed to read catalog config {config_path}: {e}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
# File exists but has no catalogs key or empty list - fail closed
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||
)
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
||||
)
|
||||
entries: List[CatalogEntry] = []
|
||||
skipped_entries: List[int] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
skipped_entries.append(idx)
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
||||
else:
|
||||
install_allowed = bool(raw_install)
|
||||
entries.append(CatalogEntry(
|
||||
url=url,
|
||||
name=str(item.get("name", f"catalog-{idx + 1}")),
|
||||
priority=priority,
|
||||
install_allowed=install_allowed,
|
||||
description=str(item.get("description", "")),
|
||||
))
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
if not entries:
|
||||
# All entries were invalid (missing URLs) - fail closed for security
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
|
||||
f"(entries at indices {skipped_entries} were skipped). "
|
||||
f"Each catalog entry must have a 'url' field."
|
||||
)
|
||||
return entries
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
@@ -1826,24 +1731,44 @@ class ExtensionCatalog:
|
||||
file=sys.stderr,
|
||||
)
|
||||
self._non_default_catalog_warning_shown = True
|
||||
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
|
||||
return [
|
||||
self._entry(
|
||||
url=catalog_url,
|
||||
name="custom",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Custom catalog via SPECKIT_CATALOG_URL",
|
||||
)
|
||||
]
|
||||
|
||||
# 2. Project-level config overrides all defaults
|
||||
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
|
||||
project_config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
catalogs = self._load_catalog_config(project_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 3. User-level config
|
||||
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
||||
user_config_path = Path.home() / ".specify" / self.CONFIG_FILENAME
|
||||
catalogs = self._load_catalog_config(user_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 4. Built-in default stack
|
||||
return [
|
||||
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
|
||||
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
|
||||
self._entry(
|
||||
url=self.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Built-in catalog of installable extensions",
|
||||
),
|
||||
self._entry(
|
||||
url=self.COMMUNITY_CATALOG_URL,
|
||||
name="community",
|
||||
priority=2,
|
||||
install_allowed=False,
|
||||
description="Community-contributed extensions (discovery only)",
|
||||
),
|
||||
]
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
@@ -2481,7 +2406,32 @@ class HookExecutor:
|
||||
}
|
||||
|
||||
try:
|
||||
return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
|
||||
result = yaml.safe_load(self.config_file.read_text(encoding="utf-8"))
|
||||
# Coerce non-dict root (including None for an empty file) to the
|
||||
# fully-normalized default so callers always get guaranteed fields.
|
||||
if not isinstance(result, dict):
|
||||
return {
|
||||
"installed": [],
|
||||
"settings": {"auto_execute_hooks": True},
|
||||
"hooks": {},
|
||||
}
|
||||
# Normalize nested fields so read-only callers like get_hooks_for_event()
|
||||
# never see non-dict hooks or non-list installed (Feedback)
|
||||
if not isinstance(result.get("hooks"), dict):
|
||||
result["hooks"] = {}
|
||||
if not isinstance(result.get("installed"), list):
|
||||
result["installed"] = []
|
||||
if not isinstance(result.get("settings"), dict):
|
||||
result["settings"] = {"auto_execute_hooks": True}
|
||||
# Sanitize hook event values: coerce non-list values to [] and filter
|
||||
# non-dict items so get_hooks_for_event() can safely call .get() (Feedback)
|
||||
for event_key in list(result["hooks"]):
|
||||
event_val = result["hooks"][event_key]
|
||||
if not isinstance(event_val, list):
|
||||
result["hooks"][event_key] = []
|
||||
else:
|
||||
result["hooks"][event_key] = [h for h in event_val if isinstance(h, dict)]
|
||||
return result
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return {
|
||||
"installed": [],
|
||||
@@ -2501,25 +2451,141 @@ class HookExecutor:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def register_extension(self, extension_id: str):
|
||||
"""Add extension to the installed list in project config.
|
||||
|
||||
Args:
|
||||
extension_id: ID of extension to register
|
||||
"""
|
||||
config = self.get_project_config()
|
||||
|
||||
# Ensure config is a dict (defensive)
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
|
||||
raw_installed = config.get("installed")
|
||||
sanitized = self._sanitize_installed_list(raw_installed, add_id=extension_id)
|
||||
|
||||
if sanitized != raw_installed:
|
||||
config["installed"] = sanitized
|
||||
self.save_project_config(config)
|
||||
|
||||
def unregister_extension(self, extension_id: str):
|
||||
"""Remove extension from the installed list in project config.
|
||||
|
||||
Args:
|
||||
extension_id: ID of extension to unregister
|
||||
"""
|
||||
config = self.get_project_config()
|
||||
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
|
||||
raw_installed = config.get("installed")
|
||||
sanitized = self._sanitize_installed_list(raw_installed, remove_id=extension_id)
|
||||
|
||||
# Always persist if sanitized state differs from raw config (ensures normalization)
|
||||
if sanitized != raw_installed:
|
||||
config["installed"] = sanitized
|
||||
self.save_project_config(config)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_installed_list(
|
||||
raw: object,
|
||||
*,
|
||||
add_id: str = "",
|
||||
remove_id: str = "",
|
||||
) -> list:
|
||||
"""Normalize, deduplicate, and optionally add/remove an extension id.
|
||||
|
||||
Shared by register_extension() and unregister_extension() to prevent
|
||||
the two paths from drifting.
|
||||
|
||||
Args:
|
||||
raw: The raw value from config["installed"] (may be non-list).
|
||||
add_id: If non-empty, ensure this id is present (plain-string fallback).
|
||||
remove_id: If non-empty, remove this id from the list.
|
||||
|
||||
Returns:
|
||||
A sanitized, deduplicated, alphabetically-sorted list.
|
||||
"""
|
||||
_VALID_ID = re.compile(r'^[a-z0-9-]+$')
|
||||
|
||||
installed = raw if isinstance(raw, list) else []
|
||||
|
||||
# Keep only entries whose resolved id is a non-empty string matching
|
||||
# the extension-id format (^[a-z0-9-]+$), same rule ExtensionManifest enforces.
|
||||
def _valid_entry(x: object) -> bool:
|
||||
if isinstance(x, str):
|
||||
return bool(_VALID_ID.match(x.strip()))
|
||||
if isinstance(x, dict):
|
||||
eid = x.get("id")
|
||||
return isinstance(eid, str) and bool(_VALID_ID.match(eid.strip()))
|
||||
return False
|
||||
|
||||
valid = [x for x in installed if _valid_entry(x)]
|
||||
|
||||
# Deduplicate by id: prefer dict (richer metadata) over plain string
|
||||
seen: dict = {} # id -> entry (dict preferred over str)
|
||||
for x in valid:
|
||||
eid = x.strip() if isinstance(x, str) else x.get("id", "").strip()
|
||||
if eid not in seen or isinstance(x, dict):
|
||||
seen[eid] = x
|
||||
|
||||
# Validate add_id against the same regex before inserting
|
||||
if add_id and _VALID_ID.match(add_id.strip()) and add_id not in seen:
|
||||
seen[add_id] = add_id
|
||||
|
||||
if remove_id:
|
||||
seen.pop(remove_id, None)
|
||||
|
||||
def _sort_key(x: object) -> str:
|
||||
return x if isinstance(x, str) else x.get("id", "") # type: ignore[return-value]
|
||||
|
||||
return sorted(seen.values(), key=_sort_key)
|
||||
|
||||
def register_hooks(self, manifest: ExtensionManifest):
|
||||
"""Register extension hooks in project config.
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest with hooks to register
|
||||
"""
|
||||
# Always ensure the extension is in the installed list
|
||||
self.register_extension(manifest.id)
|
||||
|
||||
if not hasattr(manifest, "hooks") or not manifest.hooks:
|
||||
return
|
||||
|
||||
config = self.get_project_config()
|
||||
|
||||
# Ensure hooks dict exists
|
||||
if "hooks" not in config:
|
||||
# Ensure config is a dict (defensive)
|
||||
changed = False
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
changed = True
|
||||
|
||||
# Ensure hooks dict exists and is a mapping
|
||||
if "hooks" not in config or not isinstance(config["hooks"], dict):
|
||||
config["hooks"] = {}
|
||||
changed = True
|
||||
else:
|
||||
# Sanitize existing hook lists to prevent crashes in downstream code (Feedback)
|
||||
for h_name in list(config["hooks"].keys()):
|
||||
h_list = config["hooks"][h_name]
|
||||
if not isinstance(h_list, list):
|
||||
config["hooks"][h_name] = []
|
||||
changed = True
|
||||
else:
|
||||
sanitized_h_list = [h for h in h_list if isinstance(h, dict)]
|
||||
if len(sanitized_h_list) != len(h_list):
|
||||
config["hooks"][h_name] = sanitized_h_list
|
||||
changed = True
|
||||
|
||||
# Register each hook
|
||||
for hook_name, hook_config in manifest.hooks.items():
|
||||
if hook_name not in config["hooks"]:
|
||||
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
|
||||
config["hooks"][hook_name] = []
|
||||
changed = True
|
||||
|
||||
# Add hook entry
|
||||
hook_entry = {
|
||||
@@ -2534,22 +2600,22 @@ class HookExecutor:
|
||||
"condition": hook_config.get("condition"),
|
||||
}
|
||||
|
||||
# Check if already registered
|
||||
existing = [
|
||||
h
|
||||
for h in config["hooks"][hook_name]
|
||||
if h.get("extension") == manifest.id
|
||||
# Deduplicate: remove all existing entries for this extension on this
|
||||
# hook event, then append the single canonical entry. This prevents
|
||||
# multiple hooks firing when hand-edited or older versions leave
|
||||
# duplicate entries behind. (Feedback from review)
|
||||
original_list = config["hooks"][hook_name]
|
||||
deduped = [
|
||||
h for h in original_list
|
||||
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
||||
]
|
||||
deduped.append(hook_entry)
|
||||
if deduped != original_list:
|
||||
config["hooks"][hook_name] = deduped
|
||||
changed = True
|
||||
|
||||
if not existing:
|
||||
config["hooks"][hook_name].append(hook_entry)
|
||||
else:
|
||||
# Update existing
|
||||
for i, h in enumerate(config["hooks"][hook_name]):
|
||||
if h.get("extension") == manifest.id:
|
||||
config["hooks"][hook_name][i] = hook_entry
|
||||
|
||||
self.save_project_config(config)
|
||||
if changed:
|
||||
self.save_project_config(config)
|
||||
|
||||
def unregister_hooks(self, extension_id: str):
|
||||
"""Remove extension hooks from project config.
|
||||
@@ -2557,17 +2623,30 @@ class HookExecutor:
|
||||
Args:
|
||||
extension_id: ID of extension to unregister
|
||||
"""
|
||||
# Always remove from installed list (Feedback from review)
|
||||
self.unregister_extension(extension_id)
|
||||
|
||||
config = self.get_project_config()
|
||||
|
||||
if "hooks" not in config:
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
# We don't save yet, as there are no hooks to unregister,
|
||||
# but unregister_extension above might have already saved a normalized config.
|
||||
return
|
||||
|
||||
if "hooks" not in config or not isinstance(config["hooks"], dict):
|
||||
return
|
||||
|
||||
# Remove hooks for this extension
|
||||
for hook_name in config["hooks"]:
|
||||
for hook_name in list(config["hooks"].keys()):
|
||||
hook_list = config["hooks"][hook_name]
|
||||
if not isinstance(hook_list, list):
|
||||
config["hooks"][hook_name] = []
|
||||
continue
|
||||
config["hooks"][hook_name] = [
|
||||
h
|
||||
for h in config["hooks"][hook_name]
|
||||
if h.get("extension") != extension_id
|
||||
for h in hook_list
|
||||
if isinstance(h, dict) and h.get("extension") != extension_id
|
||||
]
|
||||
|
||||
# Clean up empty hook arrays
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -11,6 +12,67 @@ INTEGRATION_JSON = ".specify/integration.json"
|
||||
INTEGRATION_STATE_SCHEMA = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationReadError:
|
||||
"""Structured failure from :func:`try_read_integration_json`.
|
||||
|
||||
Callers map ``kind`` to whatever surface they need (loud CLI error,
|
||||
silent fallback, etc.) without re-implementing the parse/validation logic.
|
||||
"""
|
||||
|
||||
kind: str # "decode", "os", "not_object", "schema_too_new"
|
||||
detail: str = ""
|
||||
schema: int | None = None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This is the single low-level reader; both the CLI's loud
|
||||
``_read_integration_json`` and the workflow engine's silent
|
||||
``_load_project_integration`` consume it so the schema guard and parse
|
||||
logic cannot drift between them.
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
|
||||
# on some OSErrors (e.g. permission errors during stat), which would
|
||||
# silently treat an unreadable-but-present file as missing. Attempt the
|
||||
# read directly and distinguish FileNotFoundError (genuinely absent) from
|
||||
# other OSErrors (which become loud errors via the IntegrationReadError
|
||||
# path).
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None, None
|
||||
except IsADirectoryError as exc:
|
||||
return None, IntegrationReadError(
|
||||
kind="os",
|
||||
detail=f"{path} exists but is not a regular file: {exc}",
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
return None, IntegrationReadError(kind="decode", detail=str(exc))
|
||||
except OSError as exc:
|
||||
return None, IntegrationReadError(kind="os", detail=str(exc))
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, IntegrationReadError(kind="decode", detail=str(exc))
|
||||
if not isinstance(data, dict):
|
||||
return None, IntegrationReadError(kind="not_object", detail=type(data).__name__)
|
||||
schema = data.get("integration_state_schema")
|
||||
if (
|
||||
isinstance(schema, int)
|
||||
and not isinstance(schema, bool)
|
||||
and schema > INTEGRATION_STATE_SCHEMA
|
||||
):
|
||||
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
|
||||
return normalize_integration_state(data), None
|
||||
|
||||
|
||||
def clean_integration_key(key: Any) -> str | None:
|
||||
"""Return a stripped integration key, or None for empty/non-string values."""
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
|
||||
@@ -6,7 +6,22 @@ Commands are deprecated; ``--skills`` defaults to ``True``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so Codex maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
# Without this, Codex emits ``/speckit.git.commit`` (which does not
|
||||
# resolve) instead of ``/speckit-git-commit``.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
class CodexIntegration(SkillsIntegration):
|
||||
@@ -54,3 +69,68 @@ class CodexIntegration(SkillsIntegration):
|
||||
help="Install as agent skills (default for Codex)",
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject the dot-to-hyphen hook command note."""
|
||||
return self._inject_hook_command_note(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Codex skills, then inject the hook command note."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
for path in created:
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content = path.read_bytes().decode("utf-8")
|
||||
updated = self.post_process_skill_content(content)
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -8,12 +8,13 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
config = {
|
||||
"name": "opencode",
|
||||
"folder": ".opencode/",
|
||||
"commands_subdir": "command",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": "https://opencode.ai",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".opencode/command",
|
||||
"dir": ".opencode/commands",
|
||||
"legacy_dir": ".opencode/command",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
|
||||
@@ -1048,9 +1048,9 @@ class PresetManager:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
desc = SKILL_DESCRIPTIONS.get(
|
||||
desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get(
|
||||
short_name.replace(".", "-"),
|
||||
fm.get("description", f"Command: {short_name}"),
|
||||
f"Command: {short_name}",
|
||||
)
|
||||
init_opts = load_init_options(self.project_root)
|
||||
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
|
||||
@@ -1314,9 +1314,9 @@ class PresetManager:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
frontmatter = dict(frontmatter)
|
||||
frontmatter["description"] = enhanced_desc
|
||||
@@ -1417,9 +1417,9 @@ class PresetManager:
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
|
||||
@@ -19,6 +19,10 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..integration_state import (
|
||||
default_integration_key,
|
||||
try_read_integration_json,
|
||||
)
|
||||
from .base import RunStatus, StepContext, StepResult, StepStatus
|
||||
|
||||
|
||||
@@ -143,6 +147,35 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"Must be 'string', 'number', or 'boolean'."
|
||||
)
|
||||
|
||||
# Validate the default eagerly so authoring mistakes (e.g. a
|
||||
# default not in the declared enum, or a non-numeric default for
|
||||
# a number input) surface at install/validation time instead of
|
||||
# at workflow-execution time. ``"auto"`` for the integration
|
||||
# input is a runtime-resolved sentinel, so only the
|
||||
# enum-membership check is exempted for that exact case — the
|
||||
# declared type is still enforced (e.g. ``type: number`` paired
|
||||
# with ``default: "auto"`` is still rejected).
|
||||
if "default" in input_def:
|
||||
default_value = input_def["default"]
|
||||
is_auto_integration = (
|
||||
input_name == "integration" and default_value == "auto"
|
||||
)
|
||||
validation_input_def: dict[str, Any] = input_def
|
||||
if is_auto_integration and "enum" in input_def:
|
||||
validation_input_def = {
|
||||
key: value
|
||||
for key, value in input_def.items()
|
||||
if key != "enum"
|
||||
}
|
||||
try:
|
||||
WorkflowEngine._coerce_input(
|
||||
input_name, default_value, validation_input_def
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(
|
||||
f"Input {input_name!r} has invalid default: {exc}"
|
||||
)
|
||||
|
||||
# -- Steps ------------------------------------------------------------
|
||||
if not isinstance(definition.steps, list):
|
||||
errors.append("'steps' must be a list.")
|
||||
@@ -711,16 +744,73 @@ class WorkflowEngine:
|
||||
if not isinstance(input_def, dict):
|
||||
continue
|
||||
if name in provided:
|
||||
resolved[name] = self._coerce_input(
|
||||
name, provided[name], input_def
|
||||
)
|
||||
# Resolve sentinels for explicitly-provided values too: a
|
||||
# caller passing ``{"integration": "auto"}`` (which the
|
||||
# workflow prompt advertises as a valid value) must be
|
||||
# treated identically to omitting the input and letting the
|
||||
# default flow through, so dispatch never sees the literal
|
||||
# sentinel.
|
||||
value = self._resolve_default(name, provided[name])
|
||||
elif "default" in input_def:
|
||||
resolved[name] = input_def["default"]
|
||||
value = self._resolve_default(name, input_def["default"])
|
||||
elif input_def.get("required", False):
|
||||
msg = f"Required input {name!r} not provided."
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
continue
|
||||
|
||||
# When the ``integration`` default could not be resolved against
|
||||
# project state and falls back to the literal ``"auto"``
|
||||
# sentinel, strip ``enum`` from the input definition before
|
||||
# coercion so a workflow that lists specific integrations in
|
||||
# ``enum`` does not crash at runtime on the sentinel value.
|
||||
# NOTE: only enum-membership is skipped; ``_coerce_input``
|
||||
# still enforces the declared ``type`` against the filtered
|
||||
# definition (``string`` rejects non-strings, ``number`` rejects
|
||||
# bools and uncoercible values, ``boolean`` rejects non-bools),
|
||||
# so ill-typed values still fail fast here.
|
||||
coerce_input_def = input_def
|
||||
if (
|
||||
name == "integration"
|
||||
and value == "auto"
|
||||
and "enum" in input_def
|
||||
):
|
||||
coerce_input_def = {
|
||||
key: val
|
||||
for key, val in input_def.items()
|
||||
if key != "enum"
|
||||
}
|
||||
resolved[name] = self._coerce_input(name, value, coerce_input_def)
|
||||
return resolved
|
||||
|
||||
def _resolve_default(self, name: str, default: Any) -> Any:
|
||||
"""Resolve special default sentinels against project state.
|
||||
|
||||
For the ``integration`` input, ``"auto"`` resolves to the integration
|
||||
recorded in ``.specify/integration.json`` so workflows dispatch to the
|
||||
AI the project was actually initialized with, instead of a hardcoded
|
||||
value baked into the workflow YAML.
|
||||
"""
|
||||
if name == "integration" and default == "auto":
|
||||
resolved = self._load_project_integration()
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return default
|
||||
|
||||
def _load_project_integration(self) -> str | None:
|
||||
"""Read the default integration key from ``.specify/integration.json``.
|
||||
|
||||
Delegates parsing and schema validation to
|
||||
:func:`try_read_integration_json` — the same low-level helper used by
|
||||
the CLI — so the engine cannot drift from CLI behavior on the parse
|
||||
path. Returns ``None`` when the file is missing, malformed, or
|
||||
written by a newer CLI; callers fall back to the literal default.
|
||||
"""
|
||||
state, error = try_read_integration_json(self.project_root)
|
||||
if state is None or error is not None:
|
||||
return None
|
||||
return default_integration_key(state)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_input(
|
||||
name: str, value: Any, input_def: dict[str, Any]
|
||||
@@ -730,6 +820,13 @@ class WorkflowEngine:
|
||||
enum_values = input_def.get("enum")
|
||||
|
||||
if input_type == "number":
|
||||
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
||||
# ``float(True)`` succeeds and would silently coerce a YAML
|
||||
# authoring mistake like ``type: number`` + ``default: true``
|
||||
# into ``1``. Fail fast instead.
|
||||
if isinstance(value, bool):
|
||||
msg = f"Input {name!r} expected a number, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
value = float(value)
|
||||
if value == int(value):
|
||||
@@ -746,6 +843,17 @@ class WorkflowEngine:
|
||||
else:
|
||||
msg = f"Input {name!r} expected a boolean, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
elif not isinstance(value, bool):
|
||||
msg = f"Input {name!r} expected a boolean, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
elif input_type == "string":
|
||||
# Without this, ``type: string`` accepts any Python value
|
||||
# (numbers, lists, dicts) because nothing else rejects it —
|
||||
# YAML ``default: 5`` would slip through. Require an actual
|
||||
# string so authoring mistakes fail at resolve time.
|
||||
if not isinstance(value, str):
|
||||
msg = f"Input {name!r} expected a string, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
|
||||
if enum_values is not None and value not in enum_values:
|
||||
msg = (
|
||||
|
||||
@@ -1219,6 +1219,30 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
assert "contains invalid JSON" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A non-UTF8 ``integration.json`` must surface a clear error and
|
||||
avoid falling through to the catalog lookup, mirroring the malformed-JSON
|
||||
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
|
||||
project = self._make_project(tmp_path)
|
||||
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
|
||||
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
|
||||
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise AssertionError("catalog search should not be called")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not valid UTF-8" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
|
||||
@@ -487,13 +487,15 @@ class TestClaudeDisableModelInvocation:
|
||||
assert "disable-model-invocation" not in fm
|
||||
assert "user-invocable" not in fm
|
||||
|
||||
def test_non_claude_post_process_is_identity(self, tmp_path):
|
||||
"""Non-Claude integrations should not modify skill content."""
|
||||
codex = get_integration("codex")
|
||||
if codex is None:
|
||||
return # codex not registered in this build
|
||||
def test_skills_default_post_process_is_identity(self, tmp_path):
|
||||
"""SkillsIntegration agents without an override leave content unchanged."""
|
||||
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
||||
# so it stands in for the base-class default behavior.
|
||||
agy = get_integration("agy")
|
||||
if agy is None:
|
||||
return # agy not registered in this build
|
||||
content = "---\nname: test\n---\nBody"
|
||||
assert codex.post_process_skill_content(content) == content
|
||||
assert agy.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestClaudeHookCommandNote:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for CodexIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
@@ -25,3 +28,89 @@ class TestCodexAutoPromote:
|
||||
|
||||
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestCodexHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections.
|
||||
|
||||
Hook commands in ``extensions.yml`` use dotted ids like
|
||||
``speckit.git.commit`` but Codex skills are named with hyphens
|
||||
(``speckit-git-commit``). Without this note, Codex emits
|
||||
``/speckit.git.commit``, which does not resolve.
|
||||
"""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("codex")
|
||||
m = IntegrationManifest("codex", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = CodexIntegration._inject_hook_command_note(content)
|
||||
twice = CodexIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_hook_note_when_instruction_is_final_line_without_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the file
|
||||
ends without a trailing newline and the preceding line is not blank.
|
||||
"""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
# No blank line before the instruction and no trailing newline:
|
||||
# this is the case where the captured ``eol`` is empty and the
|
||||
# captured indent is also empty, so a missing line separator would
|
||||
# cause the note and instruction to collapse onto one line.
|
||||
content = (
|
||||
"---\nname: test\n---\n"
|
||||
"Body line\n"
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line_idx = next(
|
||||
i for i, l in enumerate(lines) if "replace dots" in l
|
||||
)
|
||||
instruction_line_idx = next(
|
||||
i for i, l in enumerate(lines)
|
||||
if l.lstrip().startswith("- For each executable hook")
|
||||
)
|
||||
assert note_line_idx < instruction_line_idx, (
|
||||
"Note must appear before the instruction"
|
||||
)
|
||||
assert "For each executable hook" not in lines[note_line_idx], (
|
||||
"Note and instruction must not be on the same line"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Tests for OpencodeIntegration."""
|
||||
|
||||
import warnings
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
@@ -8,8 +12,8 @@ from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
class TestOpencodeIntegration(MarkdownIntegrationTests):
|
||||
KEY = "opencode"
|
||||
FOLDER = ".opencode/"
|
||||
COMMANDS_SUBDIR = "command"
|
||||
REGISTRAR_DIR = ".opencode/command"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".opencode/commands"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_build_exec_args_uses_run_command_dispatch(self):
|
||||
@@ -57,3 +61,140 @@ class TestOpencodeIntegration(MarkdownIntegrationTests):
|
||||
args = integration.build_exec_args("explain this repository", output_json=False)
|
||||
|
||||
assert args == ["opencode", "run", "explain this repository"]
|
||||
|
||||
def test_registrar_config_has_legacy_dir(self):
|
||||
integration = get_integration(self.KEY)
|
||||
assert integration.registrar_config["legacy_dir"] == ".opencode/command"
|
||||
|
||||
def test_legacy_dir_extension_registration(self, tmp_path):
|
||||
"""Extensions register in legacy .opencode/command/ with a warning."""
|
||||
# Seed a legacy project with only .opencode/command/
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "speckit.specify.md").write_text("# existing", encoding="utf-8")
|
||||
|
||||
# Create a source command file for the registrar
|
||||
src_dir = tmp_path / "_ext_src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "myext.md").write_text(
|
||||
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.myext", "file": "myext.md"}]
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
results = registrar.register_commands_for_all_agents(
|
||||
commands, "test-ext", src_dir, tmp_path,
|
||||
)
|
||||
|
||||
# Should have registered in the legacy directory
|
||||
assert "opencode" in results
|
||||
assert (legacy_dir / "speckit.myext.md").exists()
|
||||
# Canonical directory should NOT have been created
|
||||
assert not (tmp_path / ".opencode" / "commands").exists()
|
||||
# Should have emitted a deprecation warning
|
||||
opencode_warnings = [
|
||||
w for w in caught
|
||||
if "legacy" in str(w.message) and "opencode" in str(w.message)
|
||||
]
|
||||
assert len(opencode_warnings) == 1, (
|
||||
f"Expected exactly 1 legacy-dir warning, got {len(opencode_warnings)}"
|
||||
)
|
||||
assert "specify integration upgrade" in str(opencode_warnings[0].message)
|
||||
|
||||
def test_legacy_dir_unregister(self, tmp_path):
|
||||
"""Unregister finds commands in legacy .opencode/command/ dir."""
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
cmd_file = legacy_dir / "speckit.myext.md"
|
||||
cmd_file.write_text("# ext command", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
registrar.unregister_commands(
|
||||
{"opencode": ["speckit.myext"]}, tmp_path,
|
||||
)
|
||||
|
||||
assert not cmd_file.exists()
|
||||
|
||||
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
|
||||
"""Unregister removes files from legacy dir even when canonical exists."""
|
||||
# Set up both canonical and legacy dirs
|
||||
canonical_dir = tmp_path / ".opencode" / "commands"
|
||||
canonical_dir.mkdir(parents=True)
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
|
||||
# Place a command file in the legacy dir (orphaned after upgrade)
|
||||
legacy_cmd = legacy_dir / "speckit.myext.md"
|
||||
legacy_cmd.write_text("# orphaned ext command", encoding="utf-8")
|
||||
# Place the same command in the canonical dir (current)
|
||||
canonical_cmd = canonical_dir / "speckit.myext.md"
|
||||
canonical_cmd.write_text("# ext command", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
registrar.unregister_commands(
|
||||
{"opencode": ["speckit.myext"]}, tmp_path,
|
||||
)
|
||||
|
||||
# Both files should be removed
|
||||
assert not canonical_cmd.exists(), (
|
||||
"Command file in canonical dir should be removed"
|
||||
)
|
||||
assert not legacy_cmd.exists(), (
|
||||
"Orphaned command file in legacy dir should also be removed"
|
||||
)
|
||||
|
||||
def test_canonical_dir_preferred_over_legacy(self, tmp_path):
|
||||
"""When both dirs exist, canonical .opencode/commands/ is used."""
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
canonical_dir = tmp_path / ".opencode" / "commands"
|
||||
canonical_dir.mkdir(parents=True)
|
||||
(canonical_dir / "speckit.specify.md").write_text("# cmd", encoding="utf-8")
|
||||
|
||||
# Create a source command file for the registrar
|
||||
src_dir = tmp_path / "_ext_src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "myext.md").write_text(
|
||||
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.myext", "file": "myext.md"}]
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
results = registrar.register_commands_for_all_agents(
|
||||
commands, "test-ext", src_dir, tmp_path,
|
||||
)
|
||||
|
||||
# Should register in canonical dir, not legacy
|
||||
assert "opencode" in results
|
||||
assert (canonical_dir / "speckit.myext.md").exists()
|
||||
assert not (legacy_dir / "speckit.myext.md").exists()
|
||||
# No legacy warning when canonical dir exists
|
||||
opencode_warnings = [
|
||||
w for w in caught
|
||||
if "legacy" in str(w.message) and "opencode" in str(w.message)
|
||||
]
|
||||
assert len(opencode_warnings) == 0
|
||||
|
||||
def test_setup_writes_to_canonical_dir(self, tmp_path):
|
||||
"""New installs always write to .opencode/commands/ (plural)."""
|
||||
integration = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
integration.setup(tmp_path, manifest)
|
||||
|
||||
canonical = tmp_path / ".opencode" / "commands"
|
||||
legacy = tmp_path / ".opencode" / "command"
|
||||
assert canonical.is_dir()
|
||||
assert not legacy.exists()
|
||||
assert any(canonical.glob("speckit.*.md"))
|
||||
|
||||
@@ -163,7 +163,30 @@ class TestIntegrationInstall:
|
||||
assert "already installed" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "specify integration upgrade copilot" in normalized
|
||||
assert "specify integration uninstall copilot" in normalized
|
||||
assert "already the default integration" in normalized
|
||||
assert "No files were changed" in normalized
|
||||
assert "specify integration uninstall copilot" not in normalized
|
||||
|
||||
def test_install_already_installed_non_default_guides_use(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "install", "codex"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "already installed" in normalized
|
||||
assert "specify integration use codex" in normalized
|
||||
assert "specify integration upgrade codex" in normalized
|
||||
assert "specify integration uninstall codex" not in normalized
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -176,7 +199,11 @@ class TestIntegrationInstall:
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "--force" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
|
||||
def test_install_multi_safe_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -261,7 +288,11 @@ class TestIntegrationInstall:
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "multi-install safe" in result.output
|
||||
assert "--force" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
|
||||
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -762,7 +793,7 @@ class TestIntegrationSwitch:
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Git extension commands should exist for opencode
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
|
||||
|
||||
# Old kimi extension skills should be removed
|
||||
@@ -837,7 +868,7 @@ class TestIntegrationSwitch:
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
|
||||
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
|
||||
|
||||
@@ -858,7 +889,7 @@ class TestIntegrationSwitch:
|
||||
result = _run_in_project(project, ["extension", "disable", "git"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
|
||||
|
||||
result = _run_in_project(project, [
|
||||
@@ -1168,6 +1199,49 @@ class TestIntegrationUpgrade:
|
||||
assert data["integration"] == "gemini"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
|
||||
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
|
||||
project = _init_project(tmp_path, "opencode")
|
||||
|
||||
# Simulate a legacy project: rename commands/ back to command/
|
||||
canonical = project / ".opencode" / "commands"
|
||||
legacy = project / ".opencode" / "command"
|
||||
assert canonical.is_dir(), "init should have created .opencode/commands/"
|
||||
canonical.rename(legacy)
|
||||
assert legacy.is_dir()
|
||||
assert not canonical.exists()
|
||||
|
||||
# Patch the manifest to reflect old paths (command/ not commands/)
|
||||
manifest_path = project / ".specify" / "integrations" / "opencode.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
patched_files = {}
|
||||
for path, info in manifest_data.get("files", {}).items():
|
||||
patched_files[path.replace(".opencode/commands/", ".opencode/command/")] = info
|
||||
manifest_data["files"] = patched_files
|
||||
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
|
||||
|
||||
old_commands = sorted(legacy.glob("speckit.*.md"))
|
||||
assert len(old_commands) > 0, "Legacy dir should have speckit command files"
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "opencode",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
])
|
||||
assert result.exit_code == 0, f"upgrade failed: {result.output}"
|
||||
|
||||
# New commands in canonical dir
|
||||
assert canonical.is_dir(), ".opencode/commands/ should exist after upgrade"
|
||||
new_commands = sorted(canonical.glob("speckit.*.md"))
|
||||
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
|
||||
|
||||
# Stale files removed from legacy dir
|
||||
remaining = list(legacy.glob("speckit.*.md"))
|
||||
assert len(remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no speckit files after upgrade, "
|
||||
f"found: {[f.name for f in remaining]}"
|
||||
)
|
||||
|
||||
|
||||
# ── Full lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -832,7 +832,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
@@ -843,7 +843,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
@@ -852,7 +852,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
|
||||
@@ -22,7 +22,9 @@ class TestCheckToolClaude:
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert check_tool("claude") is True
|
||||
|
||||
@@ -36,7 +38,9 @@ class TestCheckToolClaude:
|
||||
fake_migrate = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_migrate), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert check_tool("claude") is True
|
||||
|
||||
@@ -45,7 +49,9 @@ class TestCheckToolClaude:
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value="/usr/local/bin/claude"):
|
||||
assert check_tool("claude") is True
|
||||
|
||||
@@ -54,7 +60,9 @@ class TestCheckToolClaude:
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert check_tool("claude") is False
|
||||
|
||||
@@ -68,7 +76,9 @@ class TestCheckToolClaude:
|
||||
tracker = MagicMock()
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("shutil.which", return_value=None):
|
||||
result = check_tool("claude", tracker=tracker)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the --version CLI flag."""
|
||||
"""Tests for CLI version reporting."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
@@ -33,3 +34,46 @@ class TestVersionFlag:
|
||||
result = runner.invoke(app, ["--version", "init"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 0.7.2" in result.output
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the `specify version` subcommand."""
|
||||
|
||||
def test_version_features_text(self):
|
||||
"""specify version --features prints local capability flags."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Spec Kit CLI: 1.2.3" in result.output
|
||||
assert "Features:" in result.output
|
||||
assert "- controlled multi install integrations: yes" in result.output
|
||||
assert "- integration use command: yes" in result.output
|
||||
assert "- self check command: yes" in result.output
|
||||
|
||||
def test_version_features_json(self):
|
||||
"""specify version --features --json prints machine-readable capabilities."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {
|
||||
"version": "1.2.3",
|
||||
"features": {
|
||||
"controlled_multi_install_integrations": True,
|
||||
"integration_use_command": True,
|
||||
"multi_install_safe_registry_metadata": True,
|
||||
"integration_upgrade_command": True,
|
||||
"self_check_command": True,
|
||||
"workflow_catalog": True,
|
||||
"bundled_templates": True,
|
||||
},
|
||||
}
|
||||
|
||||
def test_version_json_requires_features(self):
|
||||
"""specify version --json is rejected until a JSON surface exists."""
|
||||
result = runner.invoke(app, ["version", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--json requires --features" in result.output
|
||||
|
||||
46
tests/test_console_imports.py
Normal file
46
tests/test_console_imports.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Regression guard: console symbols must remain importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
console,
|
||||
StepTracker,
|
||||
get_key,
|
||||
select_with_arrows,
|
||||
BannerGroup,
|
||||
show_banner,
|
||||
BANNER,
|
||||
TAGLINE,
|
||||
)
|
||||
|
||||
|
||||
def test_console_symbols_importable():
|
||||
from rich.console import Console
|
||||
assert isinstance(console, Console)
|
||||
|
||||
|
||||
def test_console_symbols_available_from_star_import():
|
||||
namespace = {}
|
||||
exec("from specify_cli import *", namespace)
|
||||
|
||||
for symbol in (
|
||||
"console",
|
||||
"StepTracker",
|
||||
"get_key",
|
||||
"select_with_arrows",
|
||||
"BannerGroup",
|
||||
"show_banner",
|
||||
"BANNER",
|
||||
"TAGLINE",
|
||||
):
|
||||
assert symbol in namespace
|
||||
|
||||
|
||||
def test_step_tracker_instantiable():
|
||||
tracker = StepTracker("test")
|
||||
tracker.add("step1", "Step One")
|
||||
tracker.complete("step1", "done")
|
||||
assert tracker.steps[0]["status"] == "done"
|
||||
|
||||
|
||||
def test_select_with_arrows_raises_on_empty_options():
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="at least one option"):
|
||||
select_with_arrows({})
|
||||
497
tests/test_extension_registration.py
Normal file
497
tests/test_extension_registration.py
Normal file
@@ -0,0 +1,497 @@
|
||||
import pytest
|
||||
import yaml
|
||||
from specify_cli.extensions import HookExecutor, ExtensionManifest
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = tmp_path / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
return proj_dir
|
||||
|
||||
class TestExtensionRegistration:
|
||||
"""Tests for the 'installed' list management in HookExecutor."""
|
||||
|
||||
def test_register_extension_new(self, project_dir):
|
||||
"""Standard registration: Adding an extension should add it to the list."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "installed" in config
|
||||
assert config["installed"] == ["test-ext"]
|
||||
|
||||
def test_register_extension_sorting(self, project_dir):
|
||||
"""Order Stability: Extensions should be stored in alphabetical order."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("zebra-ext")
|
||||
executor.register_extension("apple-ext")
|
||||
executor.register_extension("middle-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
|
||||
|
||||
def test_register_extension_idempotency(self, project_dir):
|
||||
"""Idempotency: Adding the same extension twice should not result in duplicates."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("test-ext")
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["test-ext"]
|
||||
assert len(config["installed"]) == 1
|
||||
|
||||
def test_unregister_extension(self, project_dir):
|
||||
"""Standard unregistration: Removing an extension should prune it from the list."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("ext-1")
|
||||
executor.register_extension("ext-2")
|
||||
|
||||
executor.unregister_extension("ext-1")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["ext-2"]
|
||||
|
||||
def test_unregister_extension_not_present(self, project_dir):
|
||||
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("ext-1")
|
||||
|
||||
# Should not raise or change the list
|
||||
executor.unregister_extension("ext-nonexistent")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["ext-1"]
|
||||
|
||||
def test_register_hooks_triggers_registration(self, project_dir, tmp_path):
|
||||
"""Full Workflow: register_hooks should automatically register the extension."""
|
||||
# Create a mock manifest
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hook-ext",
|
||||
"name": "Hook Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.hook-ext.run"}
|
||||
}
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# This should call register_extension internally
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" in config["installed"]
|
||||
|
||||
def test_missing_installed_key_initialization(self, project_dir):
|
||||
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually create a config without 'installed'
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
|
||||
|
||||
# This should detect the missing key and initialize it
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "installed" in config
|
||||
assert config["installed"] == ["new-ext"]
|
||||
|
||||
def test_unregister_hooks_full_workflow(self, project_dir, tmp_path):
|
||||
"""Full Workflow: unregister_hooks should remove hooks and prune installed list."""
|
||||
# Create a manifest with hooks
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hook-ext",
|
||||
"name": "Hook Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.hook-ext.run"}
|
||||
}
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Register hooks first
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" in config["installed"]
|
||||
assert "after_tasks" in config["hooks"]
|
||||
|
||||
# Now unregister hooks
|
||||
executor.unregister_hooks("hook-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" not in config["installed"]
|
||||
# unregister_hooks() removes the empty hook array entirely, so the key is absent
|
||||
assert "after_tasks" not in config["hooks"]
|
||||
|
||||
def test_unregister_hooks_no_hooks_key(self, project_dir):
|
||||
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Register extension without hooks
|
||||
executor.register_extension("ext-no-hooks")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-no-hooks" in config["installed"]
|
||||
|
||||
# Unregister should not crash even if no hooks key exists
|
||||
executor.unregister_hooks("ext-no-hooks")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-no-hooks" not in config["installed"]
|
||||
|
||||
def test_unregister_hooks_corrupted_config(self, project_dir):
|
||||
"""Resilience: unregister_hooks should gracefully handle corrupted config."""
|
||||
# Create a corrupted config (root is a list)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump(["corrupted", "list"]))
|
||||
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Should not raise even with corrupted config
|
||||
executor.unregister_hooks("non-existent")
|
||||
|
||||
# Config should remain as-is or be handled gracefully
|
||||
config = executor.get_project_config()
|
||||
# If it's corrupted, it's returned as-is or handled by defensive logic
|
||||
assert config is not None
|
||||
|
||||
def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path):
|
||||
"""Multiple Extensions: unregister_hooks should only remove target extension's hooks."""
|
||||
# Create two manifests
|
||||
manifest_data_1 = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-1",
|
||||
"name": "Ext 1",
|
||||
"version": "1.0.0",
|
||||
"description": "Test 1",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.ext-1.run"}
|
||||
}
|
||||
}
|
||||
manifest_data_2 = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-2",
|
||||
"name": "Ext 2",
|
||||
"version": "1.0.0",
|
||||
"description": "Test 2",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.ext-2.run"}
|
||||
}
|
||||
}
|
||||
|
||||
manifest_path_1 = tmp_path / "extension1.yml"
|
||||
manifest_path_2 = tmp_path / "extension2.yml"
|
||||
with open(manifest_path_1, "w") as f:
|
||||
yaml.dump(manifest_data_1, f)
|
||||
with open(manifest_path_2, "w") as f:
|
||||
yaml.dump(manifest_data_2, f)
|
||||
|
||||
manifest1 = ExtensionManifest(manifest_path_1)
|
||||
manifest2 = ExtensionManifest(manifest_path_2)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Register both extensions
|
||||
executor.register_hooks(manifest1)
|
||||
executor.register_hooks(manifest2)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-1" in config["installed"]
|
||||
assert "ext-2" in config["installed"]
|
||||
assert len(config["hooks"]["after_tasks"]) == 2
|
||||
|
||||
# Unregister first extension
|
||||
executor.unregister_hooks("ext-1")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-1" not in config["installed"]
|
||||
assert "ext-2" in config["installed"]
|
||||
# ext-2's hook should still be there
|
||||
assert len(config["hooks"]["after_tasks"]) == 1
|
||||
assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2"
|
||||
|
||||
def test_register_hooks_no_hooks_still_registers(self, project_dir, tmp_path):
|
||||
"""Commands-only manifest: register_hooks() must still update installed even with no hooks."""
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "commands-only-ext",
|
||||
"name": "Commands Only",
|
||||
"version": "1.0.0",
|
||||
"description": "No hooks, only commands",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": [{"name": "speckit.commands-only-ext.run", "file": "commands/run.md"}]},
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "commands-only-ext" in config["installed"]
|
||||
|
||||
def test_register_extension_mixed_type_installed(self, project_dir):
|
||||
"""Regression: installed list with non-string entries must not crash on sort."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually write a corrupted installed list with non-string entries
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"installed": [1, True, "existing-ext"]}))
|
||||
|
||||
# Should not raise TypeError on sort
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
# Non-string entries are dropped; valid strings are preserved
|
||||
assert "existing-ext" in config["installed"]
|
||||
assert "new-ext" in config["installed"]
|
||||
assert 1 not in config["installed"]
|
||||
assert True not in config["installed"]
|
||||
|
||||
def test_unregister_hooks_null_hook_values(self, project_dir):
|
||||
"""Regression: hooks: {after_tasks: null} must not crash in unregister_hooks()."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually write a config with null hook event value
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["broken-ext"],
|
||||
"hooks": {"after_tasks": None}
|
||||
}))
|
||||
|
||||
# Should not raise TypeError when iterating None
|
||||
executor.unregister_hooks("broken-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "broken-ext" not in config["installed"]
|
||||
|
||||
def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path):
|
||||
"""Regression: register_hooks() must handle non-list hook event values in config."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually write a config with null hook event value
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": None}
|
||||
}))
|
||||
|
||||
# Create a manifest with a hook for the same event
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "new-ext",
|
||||
"name": "New Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {"after_tasks": {"command": "speckit.new-ext.run"}}
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
# Should not raise TypeError when trying to append to None
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "new-ext" in config["installed"]
|
||||
assert isinstance(config["hooks"]["after_tasks"], list)
|
||||
assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"])
|
||||
|
||||
def test_register_extension_already_present_in_corrupted_list(self, project_dir):
|
||||
"""Regression: if extension is already present but list has non-strings, it must still be sanitized."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Extension is present, but list has garbage
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"installed": [1, "test-ext", True]}))
|
||||
|
||||
# This should trigger sanitization and save, even though "test-ext" is already there
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["test-ext"]
|
||||
# Verify it was actually saved to disk
|
||||
raw_config = yaml.safe_load(config_path.read_text())
|
||||
assert raw_config["installed"] == ["test-ext"]
|
||||
|
||||
def test_register_extension_with_dict_entry(self, project_dir):
|
||||
"""Review Feedback: register_extension should support and preserve dict entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
# Setup config with a pinned extension (dict)
|
||||
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": [pinned_ext, "string-ext"]
|
||||
}))
|
||||
|
||||
# Register a new extension
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
|
||||
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
|
||||
|
||||
def test_unregister_extension_with_dict_entry(self, project_dir):
|
||||
"""Review Feedback: unregister_extension should support removing matching dict entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": [pinned_ext, "other-ext"]
|
||||
}))
|
||||
|
||||
# Unregister by ID
|
||||
executor.unregister_extension("to-remove")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["other-ext"]
|
||||
|
||||
def test_unregister_extension_corrupted_installed(self, project_dir):
|
||||
"""Hardening: unregister_extension should handle non-list installed key."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": "not-a-list"
|
||||
}))
|
||||
|
||||
# Should not crash and should normalize to []
|
||||
executor.unregister_extension("any-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == []
|
||||
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
|
||||
"""Regression: register_hooks() must sanitize hook event lists by dropping non-dicts."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": [1, "corrupted", {"extension": "other", "command": "cmd"}]}
|
||||
}))
|
||||
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "new-ext",
|
||||
"name": "New Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": "Test author"
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "new-cmd"}
|
||||
}
|
||||
}
|
||||
manifest_path.write_text(yaml.dump(manifest_data))
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
hooks = config["hooks"]["after_tasks"]
|
||||
|
||||
# Should have 2 valid dict hooks, and 0 non-dict items
|
||||
assert len(hooks) == 2
|
||||
assert all(isinstance(h, dict) for h in hooks)
|
||||
assert any(h.get("extension") == "other" for h in hooks)
|
||||
assert any(h.get("extension") == "new-ext" for h in hooks)
|
||||
|
||||
def test_unregister_extension_scalar_root(self, project_dir):
|
||||
"""Hardening: unregister_extension should handle scalar root config."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
config_path.write_text(yaml.dump(123))
|
||||
|
||||
# Should not crash and should normalize to {}
|
||||
executor.unregister_extension("any-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert isinstance(config, dict)
|
||||
assert config["installed"] == []
|
||||
|
||||
def test_unregister_hooks_scalar_hook_values(self, project_dir):
|
||||
"""Regression: unregister_hooks() must handle scalar hook event values."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": 123}
|
||||
}))
|
||||
|
||||
# Should not raise TypeError when iterating
|
||||
executor.unregister_hooks("some-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "some-ext" not in config["installed"]
|
||||
assert "after_tasks" not in config["hooks"]
|
||||
109
tests/test_extension_update_hardening.py
Normal file
109
tests/test_extension_update_hardening.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = tmp_path / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
# Create required files for a project
|
||||
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
|
||||
return proj_dir
|
||||
|
||||
def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
|
||||
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
|
||||
# chdir into project_dir so _require_specify_project() succeeds
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
# Corrupt extensions.yml
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump(123))
|
||||
|
||||
# Mock ExtensionManager to return an installed extension for resolution
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
|
||||
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
|
||||
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
|
||||
mock_zip = project_dir / "mock.zip"
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
||||
|
||||
# Mock confirmation to true
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
# Run update
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
||||
assert result.exit_code == 1
|
||||
assert "AttributeError" not in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
|
||||
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["test-ext"],
|
||||
"hooks": ["not", "a", "dict"]
|
||||
}))
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
# Use tmp_path-scoped zip so the test is hermetic and returns a Path for zip_path.exists()
|
||||
mock_zip = project_dir / "mock.zip"
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
||||
assert result.exit_code == 1
|
||||
assert "AttributeError" not in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
|
||||
"""Regression: extension update rollback must handle corrupted extensions.yml."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
# Write config with hooks: null; get_project_config() normalizes this to {}
|
||||
# so the backup captures {} and the restored config will have hooks: {}.
|
||||
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
|
||||
|
||||
# Mock update process to fail after backup
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
|
||||
# Force failure in download_extension to trigger rollback
|
||||
def mock_download_fail(*args, **kwargs):
|
||||
# Corrupt the config BEFORE rollback is triggered
|
||||
config_path.write_text(yaml.dump("CORRUPTED"))
|
||||
raise Exception("Download failed")
|
||||
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
# Should handle Exception and NOT crash with AttributeError during rollback
|
||||
assert result.exit_code == 1
|
||||
assert "Download failed" in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
|
||||
restored_config = yaml.safe_load(config_path.read_text())
|
||||
assert isinstance(restored_config, dict)
|
||||
assert "hooks" in restored_config
|
||||
assert restored_config["hooks"] == {}
|
||||
@@ -1846,7 +1846,7 @@ Run {SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
|
||||
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
|
||||
@@ -2580,7 +2580,8 @@ class TestExtensionCatalog:
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile, io
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
@@ -2854,6 +2855,110 @@ class TestCatalogStack:
|
||||
assert len(entries) == 1
|
||||
assert entries[0].url == "http://localhost:8000/catalog.json"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_content", ["[]\n", "false\n", "0\n", "''\n", "- item\n"]
|
||||
)
|
||||
def test_load_catalog_config_rejects_non_mapping_roots(
|
||||
self, temp_dir, config_content
|
||||
):
|
||||
"""Malformed roots raise ValidationError, not fallback or AttributeError."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="expected a YAML mapping at the root"
|
||||
) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert str(config_path) in str(exc_info.value)
|
||||
|
||||
def test_load_catalog_config_rejects_boolean_priority(self, temp_dir):
|
||||
"""Boolean priorities are rejected instead of being coerced to 1 or 0."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "bad-priority",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="Invalid priority|expected integer"
|
||||
) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert str(config_path) in str(exc_info.value)
|
||||
|
||||
def test_load_catalog_config_defaults_blank_names(self, temp_dir):
|
||||
"""Blank and null names normalize by valid catalog order."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"name": "skipped", "url": " "},
|
||||
{"name": None, "url": "https://one.example.com/catalog.json"},
|
||||
{"name": " ", "url": "https://two.example.com/catalog.json"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
assert [entry.name for entry in catalog.get_active_catalogs()] == [
|
||||
"catalog-1",
|
||||
"catalog-2",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_detail"),
|
||||
[
|
||||
("relative/catalog.json", "HTTPS"),
|
||||
("https:///no-host", "valid URL with a host"),
|
||||
],
|
||||
)
|
||||
def test_load_catalog_config_invalid_url_includes_context(
|
||||
self, temp_dir, url, expected_detail
|
||||
):
|
||||
"""Invalid catalog URLs include the config path and entry index."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump({"catalogs": [{"name": "bad", "url": url}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
message = str(exc_info.value)
|
||||
assert "Invalid catalog URL" in message
|
||||
assert str(config_path) in message
|
||||
assert "index 0" in message
|
||||
assert expected_detail in message
|
||||
|
||||
# --- Merge conflict resolution ---
|
||||
|
||||
def test_merge_conflict_higher_priority_wins(self, temp_dir):
|
||||
|
||||
@@ -2266,6 +2266,37 @@ class TestPresetSkills:
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
def _create_command_preset(self, temp_dir, preset_id, command_name, description, body):
|
||||
preset_dir = temp_dir / preset_id
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
command_file = f"{command_name}.md"
|
||||
(preset_dir / "commands" / command_file).write_text(
|
||||
f"---\ndescription: {description}\n---\n\n{body}\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": preset_id,
|
||||
"name": preset_id,
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": command_name,
|
||||
"file": f"commands/{command_file}",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
return preset_dir
|
||||
|
||||
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was used, a preset command override should update the skill."""
|
||||
# Simulate --ai-skills having been used: write init-options + create skill
|
||||
@@ -2290,6 +2321,120 @@ class TestPresetSkills:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir):
|
||||
"""Preset skill overrides for core commands should keep preset frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
preset_dir = temp_dir / "taskstoissues-description"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: COMMAND-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"# Repro command body\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "taskstoissues-description",
|
||||
"name": "Taskstoissues Description",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.taskstoissues",
|
||||
"file": "commands/speckit.repro.taskstoissues.md",
|
||||
"description": "MANIFEST-DESCRIPTION",
|
||||
"replaces": "speckit.taskstoissues",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: preset:taskstoissues-description" in content
|
||||
|
||||
def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir):
|
||||
"""Core skill restore should keep core command frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
(core_cmds / "taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: CORE-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"core taskstoissues body\n"
|
||||
)
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"taskstoissues-restore",
|
||||
"speckit.taskstoissues",
|
||||
"PRESET-FRONTMATTER-DESCRIPTION",
|
||||
"preset taskstoissues body\n",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
manager.remove("taskstoissues-restore")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: CORE-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: templates/commands/taskstoissues.md" in content
|
||||
assert "core taskstoissues body" in content
|
||||
|
||||
def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir):
|
||||
"""Override skill reconciliation should keep override frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "speckit.taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: OVERRIDE-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"override taskstoissues body\n"
|
||||
)
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"taskstoissues-reconcile",
|
||||
"speckit.taskstoissues",
|
||||
"PRESET-FRONTMATTER-DESCRIPTION",
|
||||
"preset taskstoissues body\n",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: override:speckit.taskstoissues" in content
|
||||
assert "override taskstoissues body" in content
|
||||
|
||||
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
||||
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
||||
|
||||
@@ -115,6 +115,36 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell scripts and a BOM-prefixed template."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
templates_dir = tmp_path / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
# Write a BOM-prefixed template to ensure the WriteAllText fix is actually exercised.
|
||||
# If WriteAllText regresses, the output file will contain the BOM.
|
||||
bom = b"\xef\xbb\xbf"
|
||||
template_content = "# Feature Spec\n\nDescribe the feature here.\n"
|
||||
(templates_dir / "spec-template.md").write_bytes(bom + template_content.encode("utf-8"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_git_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temp directory without git, but with scripts."""
|
||||
@@ -381,6 +411,7 @@ class TestGetFeaturePathsSinglePrefix:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||
@@ -650,6 +681,45 @@ class TestAllowExistingBranchPowerShell:
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
@pytest.mark.skipif(
|
||||
os.name != "nt" or shutil.which("powershell.exe") is None,
|
||||
reason="Windows PowerShell not installed",
|
||||
)
|
||||
def test_ps_spec_file_written_without_bom(self, ps_git_repo: Path):
|
||||
"""spec.md generated from a BOM-prefixed template must not contain a UTF-8 BOM."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(CREATE_FEATURE_PS),
|
||||
"-ShortName",
|
||||
"bom-check",
|
||||
"BOM check feature",
|
||||
],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
spec_file = next((ps_git_repo / "specs").rglob("spec.md"), None)
|
||||
assert spec_file is not None, (
|
||||
f"spec.md was not created.\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
|
||||
raw = spec_file.read_bytes()
|
||||
assert not raw.startswith(b"\xef\xbb\xbf"), (
|
||||
f"spec.md must not start with a UTF-8 BOM — got first 3 bytes: {raw[:3]!r}"
|
||||
)
|
||||
# Verify template content was copied (not just an empty New-Item fallback)
|
||||
assert "Feature Spec" in raw.decode("utf-8"), (
|
||||
"spec.md does not contain template content — WriteAllText path was not exercised"
|
||||
)
|
||||
|
||||
|
||||
class TestGitExtensionParity:
|
||||
def test_bash_extension_surfaces_checkout_errors(self):
|
||||
@@ -904,30 +974,6 @@ def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell scripts and .specify dir."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
|
||||
class TestPowerShellDryRun:
|
||||
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
|
||||
@@ -1259,13 +1305,13 @@ class TestFeatureDirectoryResolution:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
|
||||
|
||||
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestDescriptionQuoting:
|
||||
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
|
||||
|
||||
Regression tests for https://github.com/github/spec-kit/issues/2339
|
||||
"""
|
||||
|
||||
@@ -1273,9 +1319,9 @@ class TestDescriptionQuoting:
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
"Fix the \"login\" bug",
|
||||
'Fix the "login" bug',
|
||||
"Handle path\\with\\backslashes",
|
||||
"It's a \"complex\" feature\\here",
|
||||
'It\'s a "complex" feature\\here',
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
@@ -1290,16 +1336,22 @@ class TestDescriptionQuoting:
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
"Fix the \"login\" bug",
|
||||
'Fix the "login" bug',
|
||||
"Handle path\\with\\backslashes",
|
||||
"It's a \"complex\" feature\\here",
|
||||
'It\'s a "complex" feature\\here',
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||
script = (
|
||||
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
ext_git_repo
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "git"
|
||||
/ "scripts"
|
||||
/ "bash"
|
||||
/ "create-new-feature.sh"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||
@@ -1321,3 +1373,4 @@ class TestDescriptionQuoting:
|
||||
"""Plain description without special characters continues to work."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -16,12 +16,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import (
|
||||
_get_installed_version,
|
||||
from specify_cli import app
|
||||
from specify_cli._version import (
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestNormalizeTag:
|
||||
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
@@ -162,7 +162,7 @@ class TestUserStory1:
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
@@ -174,7 +174,7 @@ class TestUserStory1:
|
||||
assert "git+https://" not in output
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
@@ -185,7 +185,7 @@ class TestUserStory1:
|
||||
assert "Up to date" in output
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
@@ -197,7 +197,7 @@ class TestUserStory1:
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
@@ -269,7 +269,7 @@ class TestUserStory2:
|
||||
def test_failure_prints_installed_plus_one_line_reason(
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -283,7 +283,7 @@ class TestUserStory2:
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -293,7 +293,7 @@ class TestUserStory2:
|
||||
def test_failure_output_contains_no_traceback_no_url(
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -390,7 +390,7 @@ class TestUserStory3:
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -403,7 +403,7 @@ class TestUserStory3:
|
||||
):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
|
||||
21
tests/test_utils_assets_imports.py
Normal file
21
tests/test_utils_assets_imports.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
run_command, check_tool, is_git_repo, init_git_repo,
|
||||
handle_vscode_settings, merge_json_files,
|
||||
get_speckit_version,
|
||||
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
def test_utils_symbols_importable():
|
||||
assert callable(check_tool)
|
||||
assert callable(merge_json_files)
|
||||
assert callable(is_git_repo)
|
||||
|
||||
def test_get_speckit_version_returns_string():
|
||||
version = get_speckit_version()
|
||||
assert isinstance(version, str) and len(version) > 0
|
||||
|
||||
def test_claude_paths_are_paths():
|
||||
assert isinstance(CLAUDE_LOCAL_PATH, Path)
|
||||
assert isinstance(CLAUDE_NPM_LOCAL_PATH, Path)
|
||||
41
tests/test_version_imports.py
Normal file
41
tests/test_version_imports.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Regression guard: version symbols must remain importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
GITHUB_API_LATEST,
|
||||
self_check,
|
||||
self_upgrade,
|
||||
)
|
||||
|
||||
|
||||
def test_version_symbols_importable():
|
||||
assert isinstance(GITHUB_API_LATEST, str)
|
||||
assert GITHUB_API_LATEST.startswith("https://")
|
||||
assert callable(self_check)
|
||||
assert callable(self_upgrade)
|
||||
|
||||
|
||||
def test_version_symbols_available_from_star_import():
|
||||
namespace = {}
|
||||
exec("from specify_cli import *", namespace)
|
||||
|
||||
for symbol in ("GITHUB_API_LATEST", "self_check", "self_upgrade"):
|
||||
assert symbol in namespace
|
||||
|
||||
|
||||
def test_version_module_symbols_directly_importable():
|
||||
from specify_cli._version import (
|
||||
GITHUB_API_LATEST,
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
self_app,
|
||||
self_check,
|
||||
self_upgrade,
|
||||
)
|
||||
assert callable(_get_installed_version)
|
||||
assert callable(_normalize_tag)
|
||||
assert callable(_is_newer)
|
||||
assert callable(_fetch_latest_release_tag)
|
||||
assert callable(self_check)
|
||||
assert callable(self_upgrade)
|
||||
assert self_app is not None
|
||||
@@ -1495,6 +1495,394 @@ steps:
|
||||
with pytest.raises(ValueError, match="Required input"):
|
||||
engine.execute(definition, {})
|
||||
|
||||
def test_integration_auto_default_uses_project_integration(self, project_dir):
|
||||
"""`integration: auto` should resolve to .specify/integration.json's integration."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps({"integration": "opencode", "version": "0.7.4"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-default"
|
||||
name: "Auto Default"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "opencode"
|
||||
|
||||
def test_integration_auto_default_falls_back_when_no_integration_json(self, project_dir):
|
||||
"""`integration: auto` should keep the literal "auto" when project state is missing.
|
||||
|
||||
The engine itself must not invent an integration when
|
||||
``.specify/integration.json`` is absent; any later validation or
|
||||
command resolution will handle an unresolved ``"auto"`` value.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-fallback"
|
||||
name: "Auto Fallback"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_integration_explicit_input_overrides_auto(self, project_dir):
|
||||
"""An explicit --input integration=X must win over `auto` even when integration.json exists."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps({"integration": "opencode"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "explicit-wins"
|
||||
name: "Explicit Wins"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {"integration": "claude"})
|
||||
assert resolved["integration"] == "claude"
|
||||
|
||||
def test_integration_explicit_auto_resolves_like_default(self, project_dir):
|
||||
"""Passing ``integration=auto`` explicitly must resolve the sentinel,
|
||||
not pass it through as a literal — the workflow prompt advertises
|
||||
``auto`` as a valid value, so the dispatch path must never see it.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps({"integration": "opencode"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "explicit-auto"
|
||||
name: "Explicit Auto"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {"integration": "auto"})
|
||||
assert resolved["integration"] == "opencode"
|
||||
|
||||
def test_integration_auto_ignores_malformed_integration_json(self, project_dir):
|
||||
"""A malformed integration.json must not crash — fall back to the literal default."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text("{not json", encoding="utf-8")
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-malformed"
|
||||
name: "Auto Malformed"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_integration_auto_ignores_non_utf8_integration_json(self, project_dir):
|
||||
"""A non-UTF8 integration.json must not crash — fall back to the literal default."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 0xFF is invalid as the leading byte of a UTF-8 sequence, so
|
||||
# ``Path.read_text(encoding="utf-8")`` raises UnicodeDecodeError.
|
||||
(specify_dir / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-non-utf8"
|
||||
name: "Auto Non UTF-8"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_integration_auto_resolves_modern_normalized_state(self, project_dir):
|
||||
"""`integration: auto` must resolve modern state files that record
|
||||
``default_integration`` / ``installed_integrations`` and omit the
|
||||
legacy ``integration`` field."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": "0.8.3",
|
||||
"integration_state_schema": 1,
|
||||
"default_integration": "claude",
|
||||
"installed_integrations": ["claude", "copilot"],
|
||||
"integration_settings": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-modern"
|
||||
name: "Auto Modern"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "claude"
|
||||
|
||||
def test_integration_auto_rejects_future_state_schema(self, project_dir):
|
||||
"""`integration: auto` must not silently use a state file written by a newer
|
||||
CLI (``integration_state_schema`` greater than the current supported value);
|
||||
the resolver falls back to the literal default rather than guessing."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.integration_state import INTEGRATION_STATE_SCHEMA
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": "99.0.0",
|
||||
"integration_state_schema": INTEGRATION_STATE_SCHEMA + 1,
|
||||
"default_integration": "claude",
|
||||
"installed_integrations": ["claude"],
|
||||
"integration_settings": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-future-schema"
|
||||
name: "Auto Future Schema"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_default_value_is_validated_against_enum(self, project_dir):
|
||||
"""Defaults must run through the same coercion/enum check as provided inputs."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "default-enum"
|
||||
name: "Default Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
default: "not-in-enum"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="not in allowed values"):
|
||||
engine._resolve_inputs(definition, {})
|
||||
|
||||
def test_default_value_is_coerced_to_declared_type(self, project_dir):
|
||||
"""A numeric default declared as a string should still be coerced like a provided input."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "default-coerce"
|
||||
name: "Default Coerce"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
retries:
|
||||
type: number
|
||||
default: "3"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["retries"] == 3
|
||||
assert isinstance(resolved["retries"], int)
|
||||
|
||||
def test_validate_workflow_rejects_invalid_default(self):
|
||||
"""Authoring-time validation should reject defaults that violate enum."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bad-default"
|
||||
name: "Bad Default"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
default: "not-in-enum"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_exempts_integration_auto_sentinel(self):
|
||||
"""``integration: auto`` is a runtime-resolved sentinel and must not fail validation."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-ok"
|
||||
name: "Auto OK"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
enum: ["copilot", "claude", "gemini"]
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert not any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_still_checks_type_for_auto_sentinel(self):
|
||||
"""The ``auto`` exemption only skips enum-membership; declared type is still enforced."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-bad-type"
|
||||
name: "Auto Bad Type"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: number
|
||||
default: "auto"
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_rejects_bool_default_for_number_type(self):
|
||||
"""``type: number`` paired with a bool default must fail — bool is a
|
||||
subclass of int so ``float(True)`` would otherwise silently coerce
|
||||
``true`` to ``1``.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bool-as-number"
|
||||
name: "Bool As Number"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
count:
|
||||
type: number
|
||||
default: true
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_rejects_non_string_default_for_string_type(self):
|
||||
"""``type: string`` must require an actual string — a numeric YAML
|
||||
default like ``5`` would otherwise slip through unvalidated.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "number-as-string"
|
||||
name: "Number As String"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
label:
|
||||
type: string
|
||||
default: 5
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
|
||||
# ===== State Persistence Tests =====
|
||||
|
||||
|
||||
@@ -7,9 +7,23 @@ workflow:
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.7.2"
|
||||
# 0.8.5 is the first release with engine-side resolution of the
|
||||
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||
# as a literal integration key and fail at dispatch.
|
||||
speckit_version: ">=0.8.5"
|
||||
integrations:
|
||||
any: ["copilot", "claude", "gemini"]
|
||||
# The four commands below (specify, plan, tasks, implement) are core
|
||||
# spec-kit commands provided by every integration. The list here is an
|
||||
# advisory, non-exhaustive compatibility hint following the documented
|
||||
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||
# against any integration the project was initialized with, including
|
||||
# ones not listed below, as long as that integration provides the four
|
||||
# core commands referenced in ``steps``.
|
||||
any:
|
||||
- "claude"
|
||||
- "copilot"
|
||||
- "gemini"
|
||||
- "opencode"
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
@@ -18,8 +32,8 @@ inputs:
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "copilot"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
||||
default: "auto"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
|
||||
Reference in New Issue
Block a user