mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44de9235a8 |
169
.github/skills/add-community-extension/SKILL.md
vendored
169
.github/skills/add-community-extension/SKILL.md
vendored
@@ -1,169 +0,0 @@
|
||||
---
|
||||
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.
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,21 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
229
README.md
229
README.md
@@ -35,7 +35,8 @@
|
||||
- [🔧 Prerequisites](#-prerequisites)
|
||||
- [📖 Learn More](#-learn-more)
|
||||
- [📋 Detailed Process](#-detailed-process)
|
||||
- [ Support](#-support)
|
||||
- [🔍 Troubleshooting](#-troubleshooting)
|
||||
- [💬 Support](#-support)
|
||||
- [🙏 Acknowledgements](#-acknowledgements)
|
||||
- [📄 License](#-license)
|
||||
|
||||
@@ -47,22 +48,83 @@ Spec-Driven Development **flips the script** on traditional software development
|
||||
|
||||
### 1. Install Specify CLI
|
||||
|
||||
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):
|
||||
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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
See the [Installation Guide](./docs/installation.md) for alternative methods, verification, upgrade, and troubleshooting.
|
||||
|
||||
### 2. Initialize a project
|
||||
Then verify the correct version is installed:
|
||||
|
||||
```bash
|
||||
specify init my-project --integration copilot
|
||||
cd my-project
|
||||
specify version
|
||||
```
|
||||
|
||||
### 3. Establish project principles
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -72,7 +134,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
|
||||
```
|
||||
|
||||
### 4. Create the spec
|
||||
### 3. 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.
|
||||
|
||||
@@ -80,7 +142,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.
|
||||
```
|
||||
|
||||
### 5. Create a technical implementation plan
|
||||
### 4. Create a technical implementation plan
|
||||
|
||||
Use the **`/speckit.plan`** command to provide your tech stack and architecture choices.
|
||||
|
||||
@@ -88,7 +150,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.
|
||||
```
|
||||
|
||||
### 6. Break down into tasks
|
||||
### 5. Break down into tasks
|
||||
|
||||
Use **`/speckit.tasks`** to create an actionable task list from your implementation plan.
|
||||
|
||||
@@ -96,7 +158,7 @@ Use **`/speckit.tasks`** to create an actionable task list from your implementat
|
||||
/speckit.tasks
|
||||
```
|
||||
|
||||
### 7. Execute implementation
|
||||
### 6. Execute implementation
|
||||
|
||||
Use **`/speckit.implement`** to execute all tasks and build your feature according to the plan.
|
||||
|
||||
@@ -114,10 +176,124 @@ 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**. Review extension source code before installation and use at your own discretion.
|
||||
> 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) |
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
|
||||
@@ -531,7 +707,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 in `.specify/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](base/memory/constitution.md) as the foundational piece that it must adhere to when establishing the plan.
|
||||
|
||||
### **STEP 6:** Generate task breakdown with /speckit.tasks
|
||||
|
||||
@@ -577,7 +753,26 @@ Once the implementation is complete, test the application and resolve any runtim
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
## 🔍 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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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).
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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,9 +43,7 @@ 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. 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:
|
||||
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing — including entirely different SDD processes:
|
||||
|
||||
- **AIDE** — 7-step AI-driven engineering lifecycle
|
||||
- **Canon** — baseline-driven workflows (spec-first, code-first, spec-drift)
|
||||
@@ -53,6 +51,8 @@ Including entirely different SDD processes:
|
||||
- **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/overview.md" class="nav-card">
|
||||
<a href="community/presets.md" class="nav-card">
|
||||
<strong>Community</strong>
|
||||
<span>Extensions, presets, walkthroughs, and friend projects</span>
|
||||
<span>Presets, walkthroughs, and friend projects</span>
|
||||
</a>
|
||||
<a href="local-development.md" class="nav-card">
|
||||
<strong>Development</strong>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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,35 +10,38 @@
|
||||
|
||||
## 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.
|
||||
|
||||
### Persistent Installation (Recommended)
|
||||
### Initialize a New Project
|
||||
|
||||
Install once and use everywhere. Replace `vX.Y.Z` with a tag from [Releases](https://github.com/github/spec-kit/releases):
|
||||
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):
|
||||
|
||||
> [!NOTE]
|
||||
> The command below requires **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uv`, [install uv first](./install/uv.md).
|
||||
> 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.
|
||||
|
||||
```bash
|
||||
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
# 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>
|
||||
```
|
||||
|
||||
Then initialize a project:
|
||||
> [!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:
|
||||
|
||||
```bash
|
||||
specify init <PROJECT_NAME> --integration copilot
|
||||
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
|
||||
```
|
||||
|
||||
### 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`.
|
||||
@@ -46,11 +49,11 @@ Interactive terminals prompt you to choose a coding agent integration during ini
|
||||
You can proactively specify your coding agent integration during initialization:
|
||||
|
||||
```bash
|
||||
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
|
||||
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 Script Type (Shell vs PowerShell)
|
||||
@@ -66,8 +69,8 @@ Auto behavior:
|
||||
Force a specific script type:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --script sh
|
||||
specify init <project_name> --script ps
|
||||
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
|
||||
```
|
||||
|
||||
### Ignore Agent Tools Check
|
||||
@@ -75,7 +78,7 @@ specify init <project_name> --script ps
|
||||
If you prefer to get the templates without checking for the right tools:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --integration claude --ignore-agent-tools
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <project_name> --integration claude --ignore-agent-tools
|
||||
```
|
||||
|
||||
## Verification
|
||||
@@ -100,8 +103,61 @@ The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
|
||||
|
||||
### Enterprise / Air-Gapped Installation
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Git Credential Manager on Linux
|
||||
|
||||
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.
|
||||
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
|
||||
```
|
||||
|
||||
11
docs/toc.yml
11
docs/toc.yml
@@ -13,12 +13,6 @@
|
||||
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
|
||||
@@ -50,12 +44,7 @@
|
||||
|
||||
# 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-14T00:00:00Z",
|
||||
"updated_at": "2026-05-12T21:40:51Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -68,43 +68,6 @@
|
||||
"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.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.0.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-14T00:00:00Z"
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
"name": "Intelligent Agent Orchestrator",
|
||||
"id": "agent-orchestrator",
|
||||
@@ -2118,44 +2081,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.10"
|
||||
version = "0.8.9"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -27,10 +27,14 @@ Or install globally:
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
import json5
|
||||
import stat
|
||||
import shlex
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -41,10 +45,15 @@ from packaging.version import InvalidVersion, Version
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.live import Live
|
||||
from rich.align import Align
|
||||
from rich.table import Table
|
||||
from rich.tree import Tree
|
||||
from typer.core import TyperGroup
|
||||
|
||||
from .integration_runtime import (
|
||||
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||
resolve_integration_options as _resolve_integration_options_impl,
|
||||
@@ -66,35 +75,8 @@ from .shared_infra import (
|
||||
refresh_shared_templates as _refresh_shared_templates_impl,
|
||||
)
|
||||
|
||||
from ._console import (
|
||||
BANNER as BANNER,
|
||||
TAGLINE as TAGLINE,
|
||||
BannerGroup,
|
||||
StepTracker,
|
||||
console,
|
||||
get_key as get_key,
|
||||
select_with_arrows,
|
||||
show_banner,
|
||||
)
|
||||
from ._assets import (
|
||||
_locate_bundled_extension,
|
||||
_locate_bundled_preset,
|
||||
_locate_bundled_workflow,
|
||||
_locate_core_pack,
|
||||
_repo_root,
|
||||
get_speckit_version as get_speckit_version,
|
||||
)
|
||||
from ._utils import (
|
||||
CLAUDE_LOCAL_PATH as CLAUDE_LOCAL_PATH,
|
||||
CLAUDE_NPM_LOCAL_PATH as CLAUDE_NPM_LOCAL_PATH,
|
||||
_display_project_path,
|
||||
check_tool as check_tool,
|
||||
handle_vscode_settings as handle_vscode_settings,
|
||||
init_git_repo as init_git_repo,
|
||||
is_git_repo as is_git_repo,
|
||||
merge_json_files as merge_json_files,
|
||||
run_command as run_command,
|
||||
)
|
||||
# For cross-platform keyboard input
|
||||
import readchar
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
@@ -176,6 +158,210 @@ def _stdin_is_interactive() -> bool:
|
||||
|
||||
SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
|
||||
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
||||
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
||||
|
||||
BANNER = """
|
||||
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
|
||||
███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝
|
||||
╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝
|
||||
███████║██║ ███████╗╚██████╗██║██║ ██║
|
||||
╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝
|
||||
"""
|
||||
|
||||
TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit"
|
||||
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 = None # callable to trigger UI refresh
|
||||
|
||||
def attach_refresh(self, cb):
|
||||
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, prompt_text: str = "Select an option", default_key: str = 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
|
||||
"""
|
||||
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(1)
|
||||
|
||||
live.update(create_selection_panel(), refresh=True)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Selection cancelled[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
run_selection_loop()
|
||||
|
||||
if selected_key is None:
|
||||
console.print("\n[red]Selection failed.[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
return selected_key
|
||||
|
||||
console = Console(highlight=False)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
name="specify",
|
||||
help="Setup tool for Specify spec-driven development projects",
|
||||
@@ -184,6 +370,20 @@ app = typer.Typer(
|
||||
cls=BannerGroup,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
def _version_callback(value: bool):
|
||||
if value:
|
||||
console.print(f"specify {get_speckit_version()}")
|
||||
@@ -200,6 +400,351 @@ def callback(
|
||||
console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
|
||||
console.print()
|
||||
|
||||
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]:
|
||||
"""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: StepTracker = None) -> bool:
|
||||
"""Check if a tool is installed. Optionally update tracker.
|
||||
|
||||
Args:
|
||||
tool: Name of the tool to check
|
||||
tracker: Optional StepTracker 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) -> 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, Optional[str]]:
|
||||
"""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: Optional[Path] = 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) -> Optional[dict[str, Any]]:
|
||||
"""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 _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.
|
||||
"""
|
||||
import re as _re
|
||||
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.
|
||||
"""
|
||||
import re as _re
|
||||
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.
|
||||
"""
|
||||
import re as _re
|
||||
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 _refresh_shared_templates(
|
||||
project_path: Path,
|
||||
*,
|
||||
@@ -1368,6 +1913,27 @@ preset_catalog_app = typer.Typer(
|
||||
preset_app.add_typer(preset_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def get_speckit_version() -> str:
|
||||
"""Get current spec-kit version."""
|
||||
import importlib.metadata
|
||||
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"
|
||||
|
||||
|
||||
# ===== Integration Commands =====
|
||||
|
||||
integration_app = typer.Typer(
|
||||
@@ -1564,6 +2130,19 @@ def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def _require_specify_project() -> Path:
|
||||
"""Return the current project root if it is a spec-kit project, else exit."""
|
||||
project_root = Path.cwd()
|
||||
@@ -4295,10 +4874,6 @@ def extension_update(
|
||||
failed_updates = []
|
||||
registrar = CommandRegistrar()
|
||||
hook_executor = HookExecutor(project_root)
|
||||
from .agents import CommandRegistrar as _AgentReg # used in backup and rollback paths
|
||||
|
||||
# UNSET sentinel: backup not yet captured (exception before backup step)
|
||||
UNSET = object()
|
||||
|
||||
for update in updates_available:
|
||||
extension_id = update["id"]
|
||||
@@ -4312,9 +4887,8 @@ def extension_update(
|
||||
backup_config_dir = backup_base / "config"
|
||||
|
||||
# Store backup state
|
||||
backup_registry_entry = None # None means registry entry not yet captured
|
||||
backup_installed = UNSET # Original installed list from extensions.yml
|
||||
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
|
||||
backup_registry_entry = None
|
||||
backup_hooks = None # None means no hooks key in config; {} means hooks key existed
|
||||
backed_up_command_files = {}
|
||||
|
||||
try:
|
||||
@@ -4339,7 +4913,8 @@ def extension_update(
|
||||
shutil.copy2(cfg_file, backup_config_dir / cfg_file.name)
|
||||
|
||||
# 3. Backup command files for all agents
|
||||
registered_commands = backup_registry_entry.get("registered_commands", {}) if isinstance(backup_registry_entry, dict) else {}
|
||||
from .agents import CommandRegistrar as _AgentReg
|
||||
registered_commands = backup_registry_entry.get("registered_commands", {})
|
||||
for agent_name, cmd_names in registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
@@ -4364,20 +4939,14 @@ def extension_update(
|
||||
shutil.copy2(prompt_file, backup_prompt_path)
|
||||
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
|
||||
|
||||
# 4. Backup hooks and installed list from extensions.yml
|
||||
# get_project_config() always normalizes installed->[] and hooks->{},
|
||||
# so no sentinel is needed to distinguish key-absent from key-empty.
|
||||
# 4. Backup hooks from extensions.yml
|
||||
# Use backup_hooks=None to indicate config had no "hooks" key (don't create on restore)
|
||||
# Use backup_hooks={} to indicate config had "hooks" key with no hooks for this extension
|
||||
config = hook_executor.get_project_config()
|
||||
if isinstance(config, dict):
|
||||
import copy
|
||||
# Deep-copy so nested mapping entries (e.g. version-pin dicts)
|
||||
# are not affected by in-place mutations during the update.
|
||||
backup_installed = copy.deepcopy(config.get("installed", []))
|
||||
backup_hooks = {}
|
||||
for hook_name, hook_list in config.get("hooks", {}).items():
|
||||
if not isinstance(hook_list, list):
|
||||
continue
|
||||
ext_hooks = [h for h in hook_list if isinstance(h, dict) and h.get("extension") == extension_id]
|
||||
if "hooks" in config:
|
||||
backup_hooks = {} # Config has hooks key - preserve this fact
|
||||
for hook_name, hook_list in config["hooks"].items():
|
||||
ext_hooks = [h for h in hook_list if h.get("extension") == extension_id]
|
||||
if ext_hooks:
|
||||
backup_hooks[hook_name] = ext_hooks
|
||||
|
||||
@@ -4530,51 +5099,35 @@ def extension_update(
|
||||
original_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(backup_file, original_file)
|
||||
|
||||
# Restore metadata in extensions.yml (hooks and installed list).
|
||||
# Only run if backup step 4 was reached (backup_hooks is not None);
|
||||
# otherwise we have no safe baseline to restore from and could corrupt
|
||||
# the config by removing pre-existing hooks.
|
||||
if backup_hooks is not None:
|
||||
config = hook_executor.get_project_config()
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
|
||||
# Restore hooks in extensions.yml
|
||||
# - backup_hooks=None means original config had no "hooks" key
|
||||
# - backup_hooks={} or {...} means config had hooks key
|
||||
config = hook_executor.get_project_config()
|
||||
if "hooks" in config:
|
||||
modified = False
|
||||
|
||||
# 1. Restore hooks in extensions.yml
|
||||
if not isinstance(config.get("hooks"), dict):
|
||||
config["hooks"] = {}
|
||||
if backup_hooks is None:
|
||||
# Original config had no "hooks" key; remove it entirely
|
||||
del config["hooks"]
|
||||
modified = True
|
||||
else:
|
||||
# Remove any hooks for this extension added by failed install
|
||||
for hook_name, hooks_list in config["hooks"].items():
|
||||
original_len = len(hooks_list)
|
||||
config["hooks"][hook_name] = [
|
||||
h for h in hooks_list
|
||||
if h.get("extension") != extension_id
|
||||
]
|
||||
if len(config["hooks"][hook_name]) != original_len:
|
||||
modified = True
|
||||
|
||||
# Remove any hooks for this extension added by the failed install
|
||||
for hook_name in list(config["hooks"].keys()):
|
||||
hooks_list = config["hooks"][hook_name]
|
||||
if not isinstance(hooks_list, list):
|
||||
config["hooks"][hook_name] = []
|
||||
modified = True
|
||||
continue
|
||||
|
||||
original_len = len(hooks_list)
|
||||
config["hooks"][hook_name] = [
|
||||
h for h in hooks_list
|
||||
if isinstance(h, dict) and h.get("extension") != extension_id
|
||||
]
|
||||
if len(config["hooks"][hook_name]) != original_len:
|
||||
modified = True
|
||||
|
||||
# Add back the backed-up hooks
|
||||
if backup_hooks:
|
||||
for hook_name, hooks in backup_hooks.items():
|
||||
if not isinstance(config["hooks"].get(hook_name), list):
|
||||
config["hooks"][hook_name] = []
|
||||
config["hooks"][hook_name].extend(hooks)
|
||||
modified = True
|
||||
|
||||
# 2. Restore installed list in extensions.yml
|
||||
if backup_installed is not UNSET:
|
||||
if config.get("installed") != backup_installed:
|
||||
config["installed"] = backup_installed
|
||||
modified = True
|
||||
# Add back the backed up hooks if any
|
||||
if backup_hooks:
|
||||
for hook_name, hooks in backup_hooks.items():
|
||||
if hook_name not in config["hooks"]:
|
||||
config["hooks"][hook_name] = []
|
||||
config["hooks"][hook_name].extend(hooks)
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
hook_executor.save_project_config(config)
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,245 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,282 +0,0 @@
|
||||
"""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()
|
||||
@@ -438,7 +438,6 @@ class CommandRegistrar:
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
_resolved_dir: Path = None,
|
||||
) -> List[str]:
|
||||
"""Register commands for a specific agent.
|
||||
|
||||
@@ -449,10 +448,6 @@ 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
|
||||
@@ -465,9 +460,7 @@ class CommandRegistrar:
|
||||
raise ValueError(f"Unsupported agent: {agent_name}")
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
commands_dir = _resolved_dir or self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
registered = []
|
||||
@@ -646,40 +639,6 @@ 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]],
|
||||
@@ -704,9 +663,7 @@ class CommandRegistrar:
|
||||
|
||||
self._ensure_configs()
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
@@ -717,7 +674,6 @@ class CommandRegistrar:
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -755,9 +711,7 @@ class CommandRegistrar:
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
@@ -767,7 +721,6 @@ class CommandRegistrar:
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -780,11 +733,6 @@ 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
|
||||
@@ -795,39 +743,24 @@ class CommandRegistrar:
|
||||
continue
|
||||
|
||||
agent_config = self.AGENT_CONFIGS[agent_name]
|
||||
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)
|
||||
commands_dir = project_root / agent_config["dir"]
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
output_name = self._compute_output_name(
|
||||
agent_name, cmd_name, agent_config
|
||||
)
|
||||
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
|
||||
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
|
||||
|
||||
if agent_name == "copilot":
|
||||
prompt_file = (
|
||||
|
||||
@@ -1190,7 +1190,7 @@ class ExtensionManager:
|
||||
# was used during project initialisation (feature parity).
|
||||
registered_skills = self._register_extension_skills(manifest, dest_dir)
|
||||
|
||||
# Register hooks and update installed list in extensions.yml
|
||||
# Register hooks
|
||||
hook_executor = HookExecutor(self.project_root)
|
||||
hook_executor.register_hooks(manifest)
|
||||
|
||||
@@ -2481,32 +2481,7 @@ class HookExecutor:
|
||||
}
|
||||
|
||||
try:
|
||||
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
|
||||
return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return {
|
||||
"installed": [],
|
||||
@@ -2526,141 +2501,25 @@ 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 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):
|
||||
# Ensure hooks dict exists
|
||||
if "hooks" not in config:
|
||||
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"] or not isinstance(config["hooks"][hook_name], list):
|
||||
if hook_name not in config["hooks"]:
|
||||
config["hooks"][hook_name] = []
|
||||
changed = True
|
||||
|
||||
# Add hook entry
|
||||
hook_entry = {
|
||||
@@ -2675,22 +2534,22 @@ class HookExecutor:
|
||||
"condition": hook_config.get("condition"),
|
||||
}
|
||||
|
||||
# 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)
|
||||
# Check if already registered
|
||||
existing = [
|
||||
h
|
||||
for h in config["hooks"][hook_name]
|
||||
if h.get("extension") == manifest.id
|
||||
]
|
||||
deduped.append(hook_entry)
|
||||
if deduped != original_list:
|
||||
config["hooks"][hook_name] = deduped
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self.save_project_config(config)
|
||||
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)
|
||||
|
||||
def unregister_hooks(self, extension_id: str):
|
||||
"""Remove extension hooks from project config.
|
||||
@@ -2698,30 +2557,17 @@ 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 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):
|
||||
if "hooks" not in config:
|
||||
return
|
||||
|
||||
# Remove hooks for this extension
|
||||
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
|
||||
for hook_name in config["hooks"]:
|
||||
config["hooks"][hook_name] = [
|
||||
h
|
||||
for h in hook_list
|
||||
if isinstance(h, dict) and h.get("extension") != extension_id
|
||||
for h in config["hooks"][hook_name]
|
||||
if h.get("extension") != extension_id
|
||||
]
|
||||
|
||||
# Clean up empty hook arrays
|
||||
|
||||
@@ -8,13 +8,12 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
config = {
|
||||
"name": "opencode",
|
||||
"folder": ".opencode/",
|
||||
"commands_subdir": "commands",
|
||||
"commands_subdir": "command",
|
||||
"install_url": "https://opencode.ai",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".opencode/commands",
|
||||
"legacy_dir": ".opencode/command",
|
||||
"dir": ".opencode/command",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""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
|
||||
|
||||
@@ -12,8 +8,8 @@ from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
class TestOpencodeIntegration(MarkdownIntegrationTests):
|
||||
KEY = "opencode"
|
||||
FOLDER = ".opencode/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".opencode/commands"
|
||||
COMMANDS_SUBDIR = "command"
|
||||
REGISTRAR_DIR = ".opencode/command"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_build_exec_args_uses_run_command_dispatch(self):
|
||||
@@ -61,140 +57,3 @@ 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"))
|
||||
|
||||
@@ -762,7 +762,7 @@ class TestIntegrationSwitch:
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Git extension commands should exist for opencode
|
||||
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
||||
opencode_git_feature = project / ".opencode" / "command" / "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 +837,7 @@ class TestIntegrationSwitch:
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
||||
opencode_git_feature = project / ".opencode" / "command" / "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 +858,7 @@ class TestIntegrationSwitch:
|
||||
result = _run_in_project(project, ["extension", "disable", "git"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
opencode_git_feature = project / ".opencode" / "commands" / "speckit.git.feature.md"
|
||||
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
|
||||
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
|
||||
|
||||
result = _run_in_project(project, [
|
||||
@@ -1168,49 +1168,6 @@ 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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ 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
|
||||
|
||||
@@ -38,9 +36,7 @@ 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
|
||||
|
||||
@@ -49,9 +45,7 @@ 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
|
||||
|
||||
@@ -60,9 +54,7 @@ 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
|
||||
|
||||
@@ -76,9 +68,7 @@ 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,46 +0,0 @@
|
||||
"""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({})
|
||||
@@ -1,497 +0,0 @@
|
||||
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"]
|
||||
@@ -1,109 +0,0 @@
|
||||
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"] == {}
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user