mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44de9235a8 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,3 +1 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace
|
||||
14
.github/aw/actions-lock.json
vendored
14
.github/aw/actions-lock.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"entries": {
|
||||
"actions/github-script@v9.0.0": {
|
||||
"repo": "actions/github-script",
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.74.8": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.74.8",
|
||||
"sha": "efa55847f72aadb03490d955263ff911bf758700"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
.github/dependabot.yml
vendored
21
.github/dependabot.yml
vendored
@@ -1,12 +1,11 @@
|
||||
updates:
|
||||
- directory: /
|
||||
package-ecosystem: pip
|
||||
schedule:
|
||||
interval: weekly
|
||||
- directory: /
|
||||
ignore:
|
||||
- dependency-name: "github/gh-aw-actions/**" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
|
||||
package-ecosystem: github-actions
|
||||
schedule:
|
||||
interval: weekly
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
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.
|
||||
1579
.github/workflows/add-community-extension.lock.yml
vendored
1579
.github/workflows/add-community-extension.lock.yml
vendored
File diff suppressed because it is too large
Load Diff
288
.github/workflows/add-community-extension.md
vendored
288
.github/workflows/add-community-extension.md
vendored
@@ -1,288 +0,0 @@
|
||||
---
|
||||
description: "Process community extension submission issues — validate, add to catalog, and open a PR for maintainer review"
|
||||
emoji: "🧩"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
skip-bots: [github-actions, copilot, dependabot]
|
||||
|
||||
tools:
|
||||
edit:
|
||||
bash: ["echo", "cat", "head", "tail", "grep", "wc", "sort", "python3", "jq", "date"]
|
||||
github:
|
||||
toolsets: [issues, repos]
|
||||
web-fetch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
safe-outputs:
|
||||
create-pull-request:
|
||||
title-prefix: "[extension] "
|
||||
labels: [extension-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
add-comment:
|
||||
max: 2
|
||||
add-labels:
|
||||
allowed: [extension-submission, validation-passed, validation-failed, needs-info]
|
||||
max: 3
|
||||
---
|
||||
|
||||
# Add Community Extension from Issue Submission
|
||||
|
||||
You are a catalog maintenance agent for the Spec Kit project. Your job is to
|
||||
process community extension submission issues and create pull requests that add
|
||||
or update entries in the community extension catalog.
|
||||
|
||||
## Triggering Conditions
|
||||
|
||||
This workflow triggers on issue events. **Only process the issue if ALL of these
|
||||
conditions are met:**
|
||||
|
||||
1. The issue has the `extension-submission` label
|
||||
2. The issue title starts with `[Extension]:`
|
||||
|
||||
If the issue does not meet these conditions, add a brief comment explaining that
|
||||
this workflow only processes extension submission issues, then stop.
|
||||
|
||||
## Step 1 — Read and Parse the Issue
|
||||
|
||||
Read issue #${{ github.event.issue.number }}.
|
||||
|
||||
Extract the following fields from the structured issue body (GitHub issue form
|
||||
fields):
|
||||
|
||||
| Field | Issue Form ID | Required |
|
||||
|-------|--------------|----------|
|
||||
| Extension ID | `extension-id` | Yes |
|
||||
| Extension Name | `extension-name` | Yes |
|
||||
| Version | `version` | Yes |
|
||||
| Description | `description` | Yes |
|
||||
| Author | `author` | Yes |
|
||||
| Repository URL | `repository` | Yes |
|
||||
| Download URL | `download-url` | Yes |
|
||||
| License | `license` | Yes |
|
||||
| Homepage | `homepage` | No |
|
||||
| Documentation URL | `documentation` | No |
|
||||
| Changelog URL | `changelog` | No |
|
||||
| Required Spec Kit Version | `speckit-version` | Yes |
|
||||
| Required Tools | `required-tools` | No |
|
||||
| Number of Commands | `commands-count` | Yes |
|
||||
| Number of Hooks | `hooks-count` | No (default 0) |
|
||||
| Tags | `tags` | Yes |
|
||||
| Proposed Catalog Entry | `catalog-entry` | Yes |
|
||||
|
||||
The issue body uses GitHub's issue form format. Each field appears under a
|
||||
heading matching the field label (e.g., `### Extension ID` followed by the
|
||||
value). Parse accordingly.
|
||||
|
||||
## Step 2 — Validate the Submission
|
||||
|
||||
Run **all** of the following validation checks. Collect all results before
|
||||
deciding pass/fail:
|
||||
|
||||
### 2a. Extension ID format
|
||||
- Must match regex: `^[a-z][a-z0-9-]*$`
|
||||
- Must be lowercase with hyphens only
|
||||
|
||||
### 2b. Version format
|
||||
- Must follow semver: `X.Y.Z` (digits only, no `v` prefix)
|
||||
|
||||
### 2c. Repository validation
|
||||
- Fetch the repository URL — confirm it exists and is publicly accessible
|
||||
- Confirm the repository contains an `extension.yml` file
|
||||
- Confirm the repository contains a `README.md` file
|
||||
- Confirm the repository contains a `LICENSE` file
|
||||
|
||||
### 2d. Release and download URL validation
|
||||
- The download URL should follow the pattern
|
||||
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
|
||||
or
|
||||
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
|
||||
- Verify a GitHub release exists matching the submitted version
|
||||
|
||||
### 2e. Submission checklists
|
||||
- Confirm that all required checkboxes in the Testing Checklist and Submission
|
||||
Requirements sections are checked (`[x]`)
|
||||
|
||||
### Validation outcome
|
||||
|
||||
If **any** validation fails:
|
||||
1. Add a comment on the issue listing each failed check with a clear explanation
|
||||
of what's wrong and how to fix it
|
||||
2. Add the `validation-failed` label
|
||||
3. **Stop — do not proceed further**
|
||||
|
||||
If all validations pass:
|
||||
1. Add the `validation-passed` label
|
||||
2. Continue to Step 3
|
||||
|
||||
## Step 3 — Determine Add vs Update
|
||||
|
||||
Search `extensions/catalog.community.json` for the extension ID.
|
||||
|
||||
- **Not found** → this is a **new addition**
|
||||
- **Found** → this is an **update** — replace the existing entry in-place;
|
||||
preserve `created_at`, `downloads`, and `stars` from the existing entry
|
||||
|
||||
## Step 4 — Update `extensions/catalog.community.json`
|
||||
|
||||
Edit `extensions/catalog.community.json` to add or update the extension entry.
|
||||
|
||||
### For a new extension
|
||||
|
||||
Insert the entry in **alphabetical order by extension ID** within the
|
||||
`"extensions"` object. Use this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"<id>": {
|
||||
"name": "<name>",
|
||||
"id": "<id>",
|
||||
"description": "<description>",
|
||||
"author": "<author>",
|
||||
"version": "<version>",
|
||||
"download_url": "<download_url>",
|
||||
"repository": "<repository>",
|
||||
"homepage": "<homepage or repository>",
|
||||
"documentation": "<documentation or repository README>",
|
||||
"changelog": "<changelog or empty string>",
|
||||
"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 }]
|
||||
```
|
||||
|
||||
### For an update
|
||||
|
||||
Replace only the changed fields (typically `version`, `download_url`,
|
||||
`description`, `provides`, `requires`, `tags`, `updated_at`). **Preserve**
|
||||
`created_at`, `downloads`, and `stars` from the existing entry.
|
||||
|
||||
### After editing
|
||||
|
||||
Update the **top-level `"updated_at"` timestamp** in the catalog to today's date
|
||||
in ISO 8601 format.
|
||||
|
||||
Validate the JSON by running:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('extensions/catalog.community.json')); print('Valid JSON')"
|
||||
```
|
||||
|
||||
If validation fails, fix the JSON and re-validate before continuing.
|
||||
|
||||
## Step 5 — Update `docs/community/extensions.md`
|
||||
|
||||
Edit `docs/community/extensions.md` to add or update a row in the Community
|
||||
Extensions table.
|
||||
|
||||
### For a new extension
|
||||
|
||||
Insert a new row in **alphabetical order by extension name**:
|
||||
|
||||
```
|
||||
| <Name> | <Description> | `<category>` | <Effect> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
Determine the category from the extension's behavior:
|
||||
- `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
|
||||
|
||||
Determine the effect:
|
||||
- `Read-only` — produces reports only
|
||||
- `Read+Write` — modifies project files
|
||||
|
||||
### For an update
|
||||
|
||||
Find the existing row and update any changed fields in-place.
|
||||
|
||||
## Step 6 — Create Pull Request
|
||||
|
||||
Create a pull request with the changes. Use this branch naming convention:
|
||||
|
||||
- **New extension:** `add-<extension-id>-extension`
|
||||
- **Update:** `update-<extension-id>-extension`
|
||||
|
||||
### Commit message
|
||||
|
||||
For a new extension:
|
||||
```
|
||||
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>
|
||||
```
|
||||
|
||||
For an update:
|
||||
```
|
||||
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>
|
||||
```
|
||||
|
||||
### PR description
|
||||
|
||||
Include:
|
||||
- A summary of what changed
|
||||
- Validation results (all checks passed)
|
||||
- `Closes #${{ github.event.issue.number }}`
|
||||
- `cc @<issue-author>` — mention the submitter
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and
|
||||
by name in the docs table
|
||||
- **Always 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 rather than blindly trusting the submitter's JSON
|
||||
- **Preserve `created_at` on updates** — keep the original value; only update
|
||||
`updated_at`
|
||||
- **Preserve `downloads` and `stars` on updates** — these reflect usage metrics
|
||||
and must not be reset
|
||||
- **Do not modify any other files** — only `extensions/catalog.community.json`
|
||||
and `docs/community/extensions.md`
|
||||
1579
.github/workflows/add-community-preset.lock.yml
vendored
1579
.github/workflows/add-community-preset.lock.yml
vendored
File diff suppressed because it is too large
Load Diff
282
.github/workflows/add-community-preset.md
vendored
282
.github/workflows/add-community-preset.md
vendored
@@ -1,282 +0,0 @@
|
||||
---
|
||||
description: "Process community preset submission issues — validate, add to catalog, and open a PR for maintainer review"
|
||||
emoji: "🎨"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
skip-bots: [github-actions, copilot, dependabot]
|
||||
|
||||
tools:
|
||||
edit:
|
||||
bash: ["echo", "cat", "head", "tail", "grep", "wc", "sort", "python3", "jq", "date"]
|
||||
github:
|
||||
toolsets: [issues, repos]
|
||||
web-fetch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
safe-outputs:
|
||||
create-pull-request:
|
||||
title-prefix: "[preset] "
|
||||
labels: [preset-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
add-comment:
|
||||
max: 2
|
||||
add-labels:
|
||||
allowed: [preset-submission, validation-passed, validation-failed, needs-info]
|
||||
max: 3
|
||||
---
|
||||
|
||||
# Add Community Preset from Issue Submission
|
||||
|
||||
You are a catalog maintenance agent for the Spec Kit project. Your job is to
|
||||
process community preset submission issues and create pull requests that add
|
||||
or update entries in the community preset catalog.
|
||||
|
||||
## Triggering Conditions
|
||||
|
||||
This workflow triggers on issue events. **Only process the issue if ALL of these
|
||||
conditions are met:**
|
||||
|
||||
1. The issue has the `preset-submission` label
|
||||
2. The issue title starts with `[Preset]:`
|
||||
|
||||
If the issue does not meet these conditions, add a brief comment explaining that
|
||||
this workflow only processes preset submission issues, then stop.
|
||||
|
||||
## Step 1 — Read and Parse the Issue
|
||||
|
||||
Read issue #${{ github.event.issue.number }}.
|
||||
|
||||
Extract the following fields from the structured issue body (GitHub issue form
|
||||
fields):
|
||||
|
||||
| Field | Issue Form ID | Required |
|
||||
|-------|--------------|----------|
|
||||
| Preset ID | `preset-id` | Yes |
|
||||
| Preset Name | `preset-name` | Yes |
|
||||
| Version | `version` | Yes |
|
||||
| Description | `description` | Yes |
|
||||
| Author | `author` | Yes |
|
||||
| Repository URL | `repository` | Yes |
|
||||
| Download URL | `download-url` | Yes |
|
||||
| License | `license` | Yes |
|
||||
| Required Spec Kit Version | `speckit-version` | Yes |
|
||||
| Required Extensions | `required-extensions` | No |
|
||||
| Templates Provided | `templates-provided` | Yes |
|
||||
| Commands Provided | `commands-provided` | Yes |
|
||||
| Number of Scripts | `scripts-count` | No (default 0) |
|
||||
| Tags | `tags` | Yes |
|
||||
|
||||
The issue body uses GitHub's issue form format. Each field appears under a
|
||||
heading matching the field label (e.g., `### Preset ID` followed by the
|
||||
value). Parse accordingly.
|
||||
|
||||
## Step 2 — Validate the Submission
|
||||
|
||||
Run **all** of the following validation checks. Collect all results before
|
||||
deciding pass/fail:
|
||||
|
||||
### 2a. Preset ID format
|
||||
- Must match regex: `^[a-z][a-z0-9-]*$`
|
||||
- Must be lowercase with hyphens only
|
||||
|
||||
### 2b. Version format
|
||||
- Must follow semver: `X.Y.Z` (digits only, no `v` prefix)
|
||||
|
||||
### 2c. Repository validation
|
||||
- Fetch the repository URL — confirm it exists and is publicly accessible
|
||||
- Confirm the repository contains a `preset.yml` file
|
||||
- Confirm the repository contains a `README.md` file
|
||||
- Confirm the repository contains a `LICENSE` file
|
||||
|
||||
### 2d. Release and download URL validation
|
||||
- The download URL should follow the pattern
|
||||
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
|
||||
or
|
||||
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
|
||||
- Verify a GitHub release exists matching the submitted version
|
||||
|
||||
### 2e. Submission checklists
|
||||
- Confirm that all required checkboxes in the Testing Checklist and Submission
|
||||
Requirements sections are checked (`[x]`)
|
||||
|
||||
### Validation outcome
|
||||
|
||||
If **any** validation fails:
|
||||
1. Add a comment on the issue listing each failed check with a clear explanation
|
||||
of what's wrong and how to fix it
|
||||
2. Add the `validation-failed` label
|
||||
3. **Stop — do not proceed further**
|
||||
|
||||
If all validations pass:
|
||||
1. Add the `validation-passed` label
|
||||
2. Continue to Step 3
|
||||
|
||||
## Step 3 — Determine Add vs Update
|
||||
|
||||
Search `presets/catalog.community.json` for the preset ID.
|
||||
|
||||
- **Not found** → this is a **new addition**
|
||||
- **Found** → this is an **update** — replace the existing entry in-place;
|
||||
preserve `created_at` from the existing entry
|
||||
|
||||
## Step 4 — Update `presets/catalog.community.json`
|
||||
|
||||
Edit `presets/catalog.community.json` to add or update the preset entry.
|
||||
|
||||
### For a new preset
|
||||
|
||||
Insert the entry in **alphabetical order by preset ID** within the
|
||||
`"presets"` object. Use this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"<id>": {
|
||||
"name": "<name>",
|
||||
"id": "<id>",
|
||||
"version": "<version>",
|
||||
"description": "<description>",
|
||||
"author": "<author>",
|
||||
"repository": "<repository>",
|
||||
"download_url": "<download_url>",
|
||||
"homepage": "<homepage or repository>",
|
||||
"documentation": "<documentation or repository README>",
|
||||
"license": "<license>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>"
|
||||
},
|
||||
"provides": {
|
||||
"templates": <N>,
|
||||
"commands": <N>
|
||||
},
|
||||
"tags": ["<tag1>", "<tag2>"],
|
||||
"created_at": "<today>T00:00:00Z",
|
||||
"updated_at": "<today>T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the preset has required extensions, add an `"extensions"` array inside
|
||||
`"requires"`:
|
||||
|
||||
```json
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>",
|
||||
"extensions": ["<extension-id>"]
|
||||
}
|
||||
```
|
||||
|
||||
If the preset provides scripts, add `"scripts": <N>` inside `"provides"`.
|
||||
|
||||
### For an update
|
||||
|
||||
Replace only the changed fields (typically `version`, `download_url`,
|
||||
`description`, `provides`, `requires`, `tags`, `updated_at`). **Preserve**
|
||||
`created_at` from the existing entry.
|
||||
|
||||
### Counting templates and commands
|
||||
|
||||
Parse the "Templates Provided" and "Commands Provided" issue fields:
|
||||
- Count the number of list items (lines starting with `-`)
|
||||
- If the field says "None", the count is 0
|
||||
|
||||
### After editing
|
||||
|
||||
Update the **top-level `"updated_at"` timestamp** in the catalog to today's date
|
||||
in ISO 8601 format.
|
||||
|
||||
Validate the JSON by running:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('presets/catalog.community.json')); print('Valid JSON')"
|
||||
```
|
||||
|
||||
If validation fails, fix the JSON and re-validate before continuing.
|
||||
|
||||
## Step 5 — Update `docs/community/presets.md`
|
||||
|
||||
Edit `docs/community/presets.md` to add or update a row in the Community
|
||||
Presets table.
|
||||
|
||||
### For a new preset
|
||||
|
||||
Insert a new row in **alphabetical order by preset name**:
|
||||
|
||||
```
|
||||
| <Name> | <Description> | <N> templates, <N> commands | <Requires> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
For the Requires column:
|
||||
- Use `—` if no extensions are required
|
||||
- List required extension names if any (e.g., `AIDE extension`)
|
||||
|
||||
If the preset provides scripts, include them: `<N> templates, <N> commands, <N> scripts`
|
||||
|
||||
### For an update
|
||||
|
||||
Find the existing row and update any changed fields in-place.
|
||||
|
||||
## Step 6 — Create Pull Request
|
||||
|
||||
Create a pull request with the changes. Use this branch naming convention:
|
||||
|
||||
- **New preset:** `add-<preset-id>-preset`
|
||||
- **Update:** `update-<preset-id>-preset`
|
||||
|
||||
### Commit message
|
||||
|
||||
For a new preset:
|
||||
```
|
||||
Add <Name> preset to community catalog
|
||||
|
||||
Add <id> preset submitted by @<issue-author> to:
|
||||
- presets/catalog.community.json (alphabetical order)
|
||||
- docs/community/presets.md community presets table
|
||||
|
||||
Closes #<issue-number>
|
||||
```
|
||||
|
||||
For an update:
|
||||
```
|
||||
Update <Name> preset to v<version>
|
||||
|
||||
Update <id> preset submitted by @<issue-author>:
|
||||
- presets/catalog.community.json (version, download_url, etc.)
|
||||
- docs/community/presets.md community presets table
|
||||
|
||||
Closes #<issue-number>
|
||||
```
|
||||
|
||||
### PR description
|
||||
|
||||
Include:
|
||||
- A summary of what changed
|
||||
- Validation results (all checks passed)
|
||||
- `Closes #${{ github.event.issue.number }}`
|
||||
- `cc @<issue-author>` — mention the submitter
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and
|
||||
by name in the docs table
|
||||
- **Always 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
|
||||
- **Preserve `created_at` on updates** — keep the original value; only update
|
||||
`updated_at`
|
||||
- **Do not modify any other files** — only `presets/catalog.community.json`
|
||||
and `docs/community/presets.md`
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
22
.github/workflows/lint.yml
vendored
22
.github/workflows/lint.yml
vendored
@@ -13,28 +13,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run git diff --check
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/checks/pr-base"
|
||||
git diff --check refs/checks/pr-base HEAD
|
||||
elif [ "$PUSH_BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
git diff-tree --check --no-commit-id --root -r "$GITHUB_SHA"
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin "+${PUSH_BEFORE_SHA}:refs/checks/push-before"
|
||||
git diff --check refs/checks/push-before HEAD
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
with:
|
||||
# Days of inactivity before an issue or PR becomes stale
|
||||
days-before-stale: 150
|
||||
|
||||
26
AGENTS.md
26
AGENTS.md
@@ -379,32 +379,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
|
||||
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
|
||||
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there
|
||||
|
||||
## Branch Naming Convention
|
||||
|
||||
All branches **must** follow this pattern:
|
||||
|
||||
```
|
||||
<type>/<number>-<short-slug>
|
||||
```
|
||||
|
||||
Where `<number>` is either an issue number or a PR number — whichever is created first.
|
||||
|
||||
| Prefix | When to use | Example |
|
||||
|---|---|---|
|
||||
| `feat/` | New features | `feat/2342-workflow-cli-alignment` |
|
||||
| `fix/` | Bug fixes | `fix/2653-paths-only-validation` |
|
||||
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention` |
|
||||
| `community/` | Community catalog additions | `community/2492-add-mde-extension` |
|
||||
| `chore/` | Maintenance, tooling, CI | `chore/2366-editorconfig` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Always include the issue or PR number immediately after the prefix — this is what makes branches traceable
|
||||
2. Use kebab-case for the slug
|
||||
3. Keep the slug short — enough to identify the work without looking up the issue
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -2,80 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.14] - 2026-05-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Add util for windows sub-process (#2598)
|
||||
- refactor: create commands/ package and move init handler (PR-4/8) (#2615)
|
||||
- Add Product Spec Extension to community catalog (#2705)
|
||||
- fix init-options speckit version refresh (#2647)
|
||||
- chore(deps): bump github/gh-aw-actions from 0.74.8 to 0.74.9 (#2658)
|
||||
- docs: add branch naming convention to AGENTS.md and CONTRIBUTING.md (#2678)
|
||||
- chore(deps): bump actions/stale from 10.2.0 to 10.3.0 (#2657)
|
||||
- chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#2656)
|
||||
- chore: release 0.8.13, begin 0.8.14.dev0 development (#2669)
|
||||
|
||||
## [0.8.13] - 2026-05-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: while/do-while loop condition reads stale iteration-0 step output (#2662)
|
||||
- docs: fix directory hierarchy in README examples (#2639)
|
||||
- fix(catalogs): reject boolean priority in extension and preset catalog readers (#2589)
|
||||
- Update Agent Governance extension to v1.2.0 (#2659)
|
||||
- Add agentic workflows for community catalog submissions (#2655)
|
||||
- feat: add self-check tip to check output (#2574)
|
||||
- fix(cli): clarify exception diagnostics (#2602)
|
||||
- ci: add diff whitespace check (#2572)
|
||||
- chore: release 0.8.12, begin 0.8.13.dev0 development (#2648)
|
||||
|
||||
## [0.8.12] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(codex): inject dot-to-hyphen hook command note in Codex skills (#2503)
|
||||
- Update Squad Bridge extension to v1.3.0 (#2645)
|
||||
- Update Superpowers Implementation Bridge extension to v0.5.0 (#2644)
|
||||
- Add Team Assign extension to community catalog (#2642)
|
||||
- refactor: migrate extension catalog stack parsing to shared base (#2576)
|
||||
- Update Architecture Workflow extension to v1.1.0 (#2588)
|
||||
- fix(workflow): support integration: auto to follow project's initialized AI (#2421)
|
||||
- Add Superpowers Implementation Bridge extension to community catalog (#2586)
|
||||
- Add Interactive HTML Preview extension to community catalog (#2585)
|
||||
- chore: release 0.8.11, begin 0.8.12.dev0 development (#2584)
|
||||
- Update Agent Governance extension to v1.1.0 (#2583)
|
||||
|
||||
## [0.8.11] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
|
||||
- Add Time Machine extension to community catalog (#2580)
|
||||
- fix(powershell): ensure UTF-8 templates are written without BOM (#2280)
|
||||
- docs: document high-assurance spec workflow (#2518)
|
||||
- docs: fix script name in directory tree examples (#2555)
|
||||
- Fix preset skill description precedence (#2538)
|
||||
- fix(integration): clarify multi-install guidance (#2549)
|
||||
- feat: add version feature reporting (#2548)
|
||||
- Add Architecture Workflow extension to community catalog (#2565)
|
||||
- chore: release 0.8.10, begin 0.8.11.dev0 development (#2562)
|
||||
|
||||
## [0.8.10] - 2026-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: streamline install section and add community overview (#2561)
|
||||
- Move community extensions table from README to docs site (#2560)
|
||||
- Add Agent Governance extension to community catalog (#2559)
|
||||
- Add Reqnroll BDD extension to community catalog (#2545)
|
||||
- fix(cli): harden extension registration and discovery workflows (#2499)
|
||||
- refactor: extract _assets.py and _utils.py from __init__.py (PR-2/8) (#2543)
|
||||
- fix(opencode): use commands/ directory (plural) to match OpenCode docs (#2453)
|
||||
- refactor: extract _console.py from __init__.py (PR-1/8) (#2474)
|
||||
- Fix constitution reference in README (#2491)
|
||||
- chore: release 0.8.9, begin 0.8.10.dev0 development (#2532)
|
||||
|
||||
## [0.8.9] - 2026-05-12
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -38,7 +38,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler
|
||||
1. Fork and clone the repository
|
||||
1. Configure and install the dependencies: `uv sync --extra test`
|
||||
1. Make sure the CLI works on your machine: `uv run specify --help`
|
||||
1. Create a new branch: `git checkout -b <type>/<number>-<short-slug>` (see [Branch naming](#branch-naming) below)
|
||||
1. Create a new branch: `git checkout -b my-branch-name`
|
||||
1. Make your change, add tests, and make sure everything still works
|
||||
1. Test the CLI functionality with a sample project if relevant
|
||||
1. Push to your fork and submit a pull request
|
||||
@@ -55,20 +55,6 @@ Here are a few things you can do that will increase the likelihood of your pull
|
||||
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
|
||||
|
||||
### Branch naming
|
||||
|
||||
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
|
||||
|
||||
| Prefix | When to use | Example |
|
||||
|---|---|---|
|
||||
| `feat/` | New features | `feat/2342-workflow-cli-alignment` |
|
||||
| `fix/` | Bug fixes | `fix/2653-paths-only-validation` |
|
||||
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention` |
|
||||
| `community/` | Community catalog additions | `community/2492-add-mde-extension` |
|
||||
| `chore/` | Maintenance, tooling, CI | `chore/2366-editorconfig` |
|
||||
|
||||
Including the issue or PR number makes branches traceable — especially useful since the project uses squash merges and `git branch --merged` won't detect merged branches. If you start with a PR (no issue), use the PR number once it's assigned.
|
||||
|
||||
## Development workflow
|
||||
|
||||
When working on spec-kit:
|
||||
|
||||
311
README.md
311
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).
|
||||
|
||||
@@ -400,24 +576,22 @@ The produced specification should contain a set of user stories and functional r
|
||||
At this stage, your project folder contents should resemble the following:
|
||||
|
||||
```text
|
||||
.
|
||||
├── .specify
|
||||
│ ├── memory
|
||||
│ │ └── constitution.md
|
||||
│ ├── scripts
|
||||
│ │ └── bash
|
||||
│ │ ├── check-prerequisites.sh
|
||||
│ │ ├── common.sh
|
||||
│ │ ├── create-new-feature.sh
|
||||
│ │ ├── setup-plan.sh
|
||||
│ │ └── setup-tasks.sh
|
||||
│ └── templates
|
||||
│ ├── plan-template.md
|
||||
│ ├── spec-template.md
|
||||
│ └── tasks-template.md
|
||||
└── specs
|
||||
└── 001-create-taskify
|
||||
└── spec.md
|
||||
└── .specify
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ └── spec.md
|
||||
└── templates
|
||||
├── plan-template.md
|
||||
├── spec-template.md
|
||||
└── tasks-template.md
|
||||
```
|
||||
|
||||
### **STEP 3:** Functional specification clarification (required before planning)
|
||||
@@ -464,31 +638,29 @@ The output of this step will include a number of implementation detail documents
|
||||
```text
|
||||
.
|
||||
├── CLAUDE.md
|
||||
├── .specify
|
||||
│ ├── memory
|
||||
│ │ └── constitution.md
|
||||
│ ├── scripts
|
||||
│ │ └── bash
|
||||
│ │ ├── check-prerequisites.sh
|
||||
│ │ ├── common.sh
|
||||
│ │ ├── create-new-feature.sh
|
||||
│ │ ├── setup-plan.sh
|
||||
│ │ └── setup-tasks.sh
|
||||
│ └── templates
|
||||
│ ├── CLAUDE-template.md
|
||||
│ ├── plan-template.md
|
||||
│ ├── spec-template.md
|
||||
│ └── tasks-template.md
|
||||
└── specs
|
||||
└── 001-create-taskify
|
||||
├── contracts
|
||||
│ ├── api-spec.json
|
||||
│ └── signalr-spec.md
|
||||
├── data-model.md
|
||||
├── plan.md
|
||||
├── quickstart.md
|
||||
├── research.md
|
||||
└── spec.md
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ ├── contracts
|
||||
│ │ ├── api-spec.json
|
||||
│ │ └── signalr-spec.md
|
||||
│ ├── data-model.md
|
||||
│ ├── plan.md
|
||||
│ ├── quickstart.md
|
||||
│ ├── research.md
|
||||
│ └── spec.md
|
||||
└── templates
|
||||
├── CLAUDE-template.md
|
||||
├── plan-template.md
|
||||
├── spec-template.md
|
||||
└── tasks-template.md
|
||||
```
|
||||
|
||||
Check the `research.md` document to ensure that the right tech stack is used, based on your instructions. You can ask Claude Code to refine it if any of the components stand out, or even have it check the locally-installed version of the platform/framework you want to use (e.g., .NET).
|
||||
@@ -535,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
|
||||
|
||||
@@ -581,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,130 +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 | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
||||
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
||||
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
|
||||
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
||||
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
||||
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
|
||||
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
|
||||
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
|
||||
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
||||
| MAQA GitHub Projects Integration | GitHub Projects v2 integration for MAQA — syncs draft issues and Status columns as features progress | `integration` | Read+Write | [spec-kit-maqa-github-projects](https://github.com/GenieRobot/spec-kit-maqa-github-projects) |
|
||||
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
|
||||
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
|
||||
| MDE | Minimal model-driven engineering workflow with setup, next, and status commands | `process` | Read+Write | [spec-kit-mde](https://github.com/AI-MDE/spec-kit-mde) |
|
||||
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
|
||||
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
| Reqnroll BDD | Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit | `process` | Read+Write | [spec-kit-reqnroll-bdd](https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd) |
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
|
||||
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
|
||||
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
|
||||
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
|
||||
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
|
||||
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
|
||||
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
|
||||
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
|
||||
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
|
||||
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
|
||||
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
|
||||
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
@@ -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
|
||||
@@ -94,17 +97,67 @@ After initialization, you should see the following commands available in your co
|
||||
- `/speckit.plan` - Generate implementation plans
|
||||
- `/speckit.tasks` - Break down into actionable tasks
|
||||
|
||||
Scripts are installed into a variant subdirectory matching the chosen script type:
|
||||
|
||||
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
|
||||
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
|
||||
The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
@@ -5,19 +5,11 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
|
||||
> [!NOTE]
|
||||
> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`.
|
||||
|
||||
## Recommended Workflow
|
||||
## The 6-Step Process
|
||||
|
||||
> [!TIP]
|
||||
> **Context Awareness**: Spec Kit commands automatically detect the active feature based on your current Git branch (e.g., `001-feature-name`). To switch between different specifications, simply switch Git branches.
|
||||
|
||||
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
|
||||
|
||||
```text
|
||||
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
|
||||
```
|
||||
|
||||
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
|
||||
|
||||
### Step 1: Install Specify
|
||||
|
||||
**In your terminal**, run the `specify` CLI command to initialize your project:
|
||||
@@ -32,13 +24,10 @@ uvx --from git+https://github.com/github/spec-kit.git specify init .
|
||||
|
||||
> [!NOTE]
|
||||
> You can also install the CLI persistently with `pipx`:
|
||||
>
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git
|
||||
> ```
|
||||
>
|
||||
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
|
||||
>
|
||||
> ```bash
|
||||
> specify init <PROJECT_NAME>
|
||||
> specify init .
|
||||
@@ -67,7 +56,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
|
||||
```
|
||||
|
||||
### Step 4: Refine and Validate the Spec
|
||||
### Step 4: Refine the Spec
|
||||
|
||||
**In the chat**, use the `/speckit.clarify` slash command to identify and resolve ambiguities in your specification. You can provide specific focus areas as arguments.
|
||||
|
||||
@@ -75,12 +64,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.clarify Focus on security and performance requirements.
|
||||
```
|
||||
|
||||
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
|
||||
|
||||
```bash
|
||||
/speckit.checklist
|
||||
```
|
||||
|
||||
### Step 5: Create a Technical Implementation Plan
|
||||
|
||||
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
|
||||
@@ -89,7 +72,7 @@ Then validate the requirements with `/speckit.checklist` before creating the tec
|
||||
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
|
||||
```
|
||||
|
||||
### Step 6: Break Down, Analyze, and Implement
|
||||
### Step 6: Break Down and Implement
|
||||
|
||||
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
|
||||
|
||||
@@ -97,13 +80,13 @@ Then validate the requirements with `/speckit.checklist` before creating the tec
|
||||
/speckit.tasks
|
||||
```
|
||||
|
||||
Validate cross-artifact consistency with `/speckit.analyze` before implementation:
|
||||
Optionally, validate the plan with `/speckit.analyze`:
|
||||
|
||||
```markdown
|
||||
/speckit.analyze
|
||||
```
|
||||
|
||||
Use the `/speckit.implement` slash command to execute the plan.
|
||||
Then, use the `/speckit.implement` slash command to execute the plan.
|
||||
|
||||
```markdown
|
||||
/speckit.implement
|
||||
@@ -176,7 +159,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
|
||||
|
||||
### Step 7: Validate and Implement
|
||||
|
||||
Have your coding agent audit the spec, plan, and tasks with `/speckit.analyze` before implementation:
|
||||
Have your coding agent audit the implementation plan using `/speckit.analyze`:
|
||||
|
||||
```bash
|
||||
/speckit.analyze
|
||||
@@ -196,7 +179,7 @@ Finally, implement the solution:
|
||||
- **Be explicit** about what you're building and why
|
||||
- **Don't focus on tech stack** during specification phase
|
||||
- **Iterate and refine** your specifications before implementation
|
||||
- **Validate** requirements and plans before coding begins
|
||||
- **Validate** the plan before coding begins
|
||||
- **Let the coding agent handle** the implementation details
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -69,8 +69,6 @@ specify check
|
||||
|
||||
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
|
||||
|
||||
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.
|
||||
|
||||
## Version Information
|
||||
|
||||
```bash
|
||||
@@ -79,16 +77,6 @@ specify version
|
||||
|
||||
Displays the Spec Kit CLI version, Python version, platform, and architecture.
|
||||
|
||||
To inspect local CLI capabilities without checking the network:
|
||||
|
||||
```bash
|
||||
specify version --features
|
||||
specify version --features --json
|
||||
```
|
||||
|
||||
The JSON form is intended for scripts and coding agents that need to choose a
|
||||
workflow based on the installed CLI's supported features.
|
||||
|
||||
A quick version check is also available via:
|
||||
|
||||
```bash
|
||||
|
||||
11
docs/toc.yml
11
docs/toc.yml
@@ -13,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
|
||||
|
||||
@@ -388,14 +388,6 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
### "CLI upgrade doesn't seem to work"
|
||||
|
||||
If a command behaves like an older Spec Kit version, first check for local CLI drift:
|
||||
|
||||
```bash
|
||||
specify self check
|
||||
```
|
||||
|
||||
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
|
||||
|
||||
Verify the installation:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-26T00: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": "Generate agent-platform repository governance files from Spec Kit metadata.",
|
||||
"author": "bigben",
|
||||
"version": "1.2.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.2.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": "uv",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"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-21T00:00:00Z"
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
"name": "Intelligent Agent Orchestrator",
|
||||
"id": "agent-orchestrator",
|
||||
@@ -174,37 +137,6 @@
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"updated_at": "2026-05-07T00:00:00Z"
|
||||
},
|
||||
"arch": {
|
||||
"name": "Architecture Workflow",
|
||||
"id": "arch",
|
||||
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-arch/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"4plus1",
|
||||
"workflow",
|
||||
"design"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -1914,69 +1846,6 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"preview": {
|
||||
"name": "Interactive HTML Preview",
|
||||
"id": "preview",
|
||||
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-preview/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"preview",
|
||||
"prototype",
|
||||
"html",
|
||||
"ux"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"product": {
|
||||
"name": "Product Spec Extension",
|
||||
"id": "product",
|
||||
"description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.",
|
||||
"author": "spec-kit-product contributors",
|
||||
"version": "0.1.3",
|
||||
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.1.3/product-0.1.3.zip",
|
||||
"repository": "https://github.com/d0whc3r/spec-kit-product",
|
||||
"homepage": "https://github.com/d0whc3r/spec-kit-product",
|
||||
"documentation": "https://github.com/d0whc3r/spec-kit-product/blob/main/README.md",
|
||||
"changelog": "https://github.com/d0whc3r/spec-kit-product/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 6
|
||||
},
|
||||
"tags": [
|
||||
"product",
|
||||
"spec",
|
||||
"prd",
|
||||
"design",
|
||||
"documentation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-26T00:00:00Z",
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
},
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
"id": "product-forge",
|
||||
@@ -2212,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",
|
||||
@@ -2644,55 +2475,6 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"speckit-superpowers-bridge": {
|
||||
"name": "Superpowers Implementation Bridge",
|
||||
"id": "speckit-superpowers-bridge",
|
||||
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
||||
"author": "lihan3238",
|
||||
"version": "0.5.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.5.0/speckit-superpowers-bridge-v0.5.0.zip",
|
||||
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
||||
"changelog": "https://github.com/lihan3238/speckit-superpowers-bridge/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10",
|
||||
"tools": [
|
||||
{
|
||||
"name": "powershell",
|
||||
"version": ">=5.1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "bash",
|
||||
"version": ">=4.0",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "jq",
|
||||
"version": ">=1.6",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"bridge",
|
||||
"superpowers",
|
||||
"cross-agent",
|
||||
"tdd",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
@@ -2761,21 +2543,21 @@
|
||||
"squad": {
|
||||
"name": "Squad Bridge",
|
||||
"id": "squad",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks.",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
|
||||
"author": "jwill824",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.3.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/jwill824/spec-kit-squad",
|
||||
"homepage": "https://github.com/jwill824/spec-kit-squad",
|
||||
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
|
||||
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.11",
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "@bradygaster/squad-cli",
|
||||
"version": ">=0.9.4",
|
||||
"version": ">=0.1.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
@@ -2795,7 +2577,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-29T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
},
|
||||
"staff-review": {
|
||||
"name": "Staff Review Extension",
|
||||
@@ -2997,74 +2779,6 @@
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"team-assign": {
|
||||
"name": "Team Assign",
|
||||
"id": "team-assign",
|
||||
"description": "Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard",
|
||||
"author": "tarunkumarbhati",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tarunkumarbhati/spec-kit-team-assign/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
|
||||
"homepage": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
|
||||
"documentation": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/README.md",
|
||||
"changelog": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"team",
|
||||
"assignment",
|
||||
"process",
|
||||
"planning",
|
||||
"subtasks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-20T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"time-machine": {
|
||||
"name": "Time Machine",
|
||||
"id": "time-machine",
|
||||
"description": "Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature",
|
||||
"author": "te3yo",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/teeyo/spec-kit-time-machine/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"homepage": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"documentation": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"changelog": "https://github.com/teeyo/spec-kit-time-machine/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"brownfield",
|
||||
"automation",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"tinyspec": {
|
||||
"name": "TinySpec",
|
||||
"id": "tinyspec",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.14"
|
||||
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 = [
|
||||
|
||||
@@ -350,10 +350,7 @@ if (-not $DryRun) {
|
||||
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
Copy-Item $template $specFile -Force
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -36,10 +36,8 @@ New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
"""Agent configuration constants derived from the integration registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _build_agent_config() -> dict[str, dict[str, Any]]:
|
||||
from .integrations import INTEGRATION_REGISTRY
|
||||
config: dict[str, dict[str, Any]] = {}
|
||||
for key, integration in INTEGRATION_REGISTRY.items():
|
||||
if integration.config:
|
||||
config[key] = dict(integration.config)
|
||||
return config
|
||||
|
||||
|
||||
AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
AI_ASSISTANT_ALIASES: dict[str, str] = {
|
||||
"kiro": "kiro-cli",
|
||||
}
|
||||
|
||||
|
||||
def _build_ai_assistant_help() -> str:
|
||||
non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
|
||||
base_help = (
|
||||
f"AI assistant to use: {', '.join(non_generic_agents)}, "
|
||||
"or generic (requires --ai-commands-dir)."
|
||||
)
|
||||
if not AI_ASSISTANT_ALIASES:
|
||||
return base_help
|
||||
alias_phrases = []
|
||||
for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
|
||||
alias_phrases.append(f"'{alias}' as an alias for '{target}'")
|
||||
if len(alias_phrases) == 1:
|
||||
aliases_text = alias_phrases[0]
|
||||
else:
|
||||
aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1]
|
||||
return base_help + " Use " + aliases_text + "."
|
||||
|
||||
|
||||
AI_ASSISTANT_HELP: str = _build_ai_assistant_help()
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
@@ -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()
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Version checking and self-update commands for specify_cli.
|
||||
|
||||
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
||||
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
||||
all version-related logic lives in one place.
|
||||
|
||||
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
||||
at module level, keeping this layer thin and circular-import-safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from ._console import console
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
|
||||
def _get_installed_version() -> str:
|
||||
"""Return the installed specify-cli distribution version or 'unknown'.
|
||||
|
||||
Uses importlib.metadata so the value reflects what was actually installed
|
||||
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
||||
intentional for `specify self check`, which should reason about the
|
||||
installed distribution rather than a source-tree fallback. Callers must
|
||||
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is not None:
|
||||
metadata_errors.append(invalid_metadata_error)
|
||||
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except tuple(metadata_errors):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_tag(tag: str) -> str:
|
||||
"""Strip exactly one leading 'v' from a release tag.
|
||||
|
||||
Returns the rest of the string unchanged. This handles the common
|
||||
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
||||
aggressively (e.g., two leading 'v's keeps one).
|
||||
"""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
||||
|
||||
Returns False whenever either side is 'unknown' or fails to parse; this
|
||||
keeps the comparison indeterminate (rather than crashing or falsely
|
||||
recommending a downgrade) on edge inputs.
|
||||
"""
|
||||
if latest == "unknown" or current == "unknown":
|
||||
return False
|
||||
try:
|
||||
return Version(latest) > Version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
||||
|
||||
On success: (tag_name, None).
|
||||
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
return tag, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
|
||||
|
||||
# ===== Self Commands =====
|
||||
|
||||
self_app = typer.Typer(
|
||||
name="self",
|
||||
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@self_app.command("check")
|
||||
def self_check() -> None:
|
||||
"""Check whether a newer specify-cli release is available. Read-only.
|
||||
|
||||
This command only checks for updates; it does not modify your installation.
|
||||
The reserved (and currently non-destructive) `specify self upgrade` command
|
||||
is the name that a future release will use for actual self-upgrade — its
|
||||
behavior is not implemented in this release and is intentionally out of
|
||||
scope here. See `specify self upgrade --help` for its current status.
|
||||
"""
|
||||
installed = _get_installed_version()
|
||||
tag, failure_reason = _fetch_latest_release_tag()
|
||||
|
||||
if tag is None:
|
||||
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
||||
# enumerated strings produced by _fetch_latest_release_tag() — it
|
||||
# never contains a URL, headers, response body, or traceback.
|
||||
assert failure_reason is not None
|
||||
console.print(f"Installed: {installed}")
|
||||
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
||||
return
|
||||
|
||||
latest_normalized = _normalize_tag(tag)
|
||||
|
||||
if installed == "unknown":
|
||||
# FR-020: surface the latest release and the recovery action even
|
||||
# when the local distribution metadata is unavailable.
|
||||
console.print("Current version could not be determined.")
|
||||
console.print(f"Latest release: {latest_normalized}")
|
||||
console.print("\nTo reinstall:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
if _is_newer(latest_normalized, installed):
|
||||
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
||||
console.print("\nTo upgrade:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
||||
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
||||
# returns False, and the up-to-date branch is the safer default per
|
||||
# FR-004 / test T016.
|
||||
console.print(f"[green]Up to date:[/green] {installed}")
|
||||
|
||||
|
||||
@self_app.command("upgrade")
|
||||
def self_upgrade() -> None:
|
||||
"""Reserved command surface for self-upgrade; not implemented in this release.
|
||||
|
||||
This command is a documented non-destructive stub in this release: it
|
||||
performs no outbound network request, no install-method detection, and
|
||||
invokes no installer. It prints a three-line guidance message and exits 0.
|
||||
Actual self-upgrade is planned as follow-up work.
|
||||
|
||||
Use `specify self check` today to see whether a newer release is available
|
||||
and to get a copy-pasteable reinstall command.
|
||||
"""
|
||||
console.print("specify self upgrade is not implemented yet.")
|
||||
console.print("Run 'specify self check' to see whether a newer release is available.")
|
||||
console.print("Actual self-upgrade is planned as follow-up work.")
|
||||
@@ -438,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 = (
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"""CLI command groups extracted from the main application.
|
||||
|
||||
Implemented command modules expose a ``register(app)`` function. Placeholder
|
||||
modules are import-only anchors for command groups that still live in the main
|
||||
application module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify extension * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -1,743 +0,0 @@
|
||||
"""specify init command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
from .._assets import (
|
||||
_locate_bundled_extension,
|
||||
_locate_bundled_preset,
|
||||
_locate_bundled_workflow,
|
||||
get_speckit_version,
|
||||
)
|
||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||
|
||||
def _build_integration_equivalent(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
parts = [f"--integration {integration_key}"]
|
||||
if integration_key == "generic" and ai_commands_dir:
|
||||
parts.append(
|
||||
f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_ai_deprecation_warning(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
replacement = _build_integration_equivalent(
|
||||
integration_key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
return (
|
||||
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
|
||||
f"Use [bold]{replacement}[/bold] instead."
|
||||
)
|
||||
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
|
||||
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
"""Copy constitution template to memory if it doesn't exist."""
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
if not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
|
||||
try:
|
||||
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(template_constitution, memory_constitution)
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||
except Exception as e:
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", str(e))
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
|
||||
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
||||
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project.
|
||||
|
||||
Project files are scaffolded from assets bundled inside the specify-cli
|
||||
package, so initialization does not need network access and templates
|
||||
match the installed CLI version.
|
||||
|
||||
This command will:
|
||||
1. Check that required tools are installed (git is optional)
|
||||
2. Let you choose your coding agent integration, or default to Copilot
|
||||
in non-interactive sessions
|
||||
3. Install bundled Spec Kit templates, scripts, workflow, and shared
|
||||
project infrastructure
|
||||
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
5. Set up coding agent integration commands and optional presets
|
||||
|
||||
Examples:
|
||||
specify init my-project
|
||||
specify init my-project --integration claude
|
||||
specify init my-project --integration copilot --no-git
|
||||
specify init --ignore-agent-tools my-project
|
||||
specify init . --integration claude # Initialize in current directory
|
||||
specify init . # Initialize in current directory (interactive integration selection)
|
||||
specify init --here --integration claude # Alternative syntax for current directory
|
||||
specify init --here --integration codex --integration-options="--skills"
|
||||
specify init --here --integration codebuddy
|
||||
specify init --here --integration vibe # Initialize with Mistral Vibe support
|
||||
specify init --here
|
||||
specify init --here --force # Skip confirmation when current directory not empty
|
||||
specify init my-project --integration claude # Claude installs skills by default
|
||||
specify init --here --integration gemini
|
||||
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
|
||||
specify init my-project --integration claude --preset healthcare-compliance # With preset
|
||||
"""
|
||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||
from .. import (
|
||||
_install_shared_infra_or_exit,
|
||||
_parse_integration_options,
|
||||
_print_cli_warning,
|
||||
_write_integration_json,
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
)
|
||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||
|
||||
show_banner()
|
||||
ai_deprecation_warning: str | None = None
|
||||
|
||||
if ai_assistant and ai_assistant.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
|
||||
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_commands_dir and ai_commands_dir.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
|
||||
|
||||
if integration and ai_assistant:
|
||||
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from ..integrations import INTEGRATION_REGISTRY, get_integration
|
||||
if integration:
|
||||
resolved_integration = get_integration(integration)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
ai_assistant = integration
|
||||
elif ai_assistant:
|
||||
resolved_integration = get_integration(ai_assistant)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}")
|
||||
raise typer.Exit(1)
|
||||
ai_deprecation_warning = _build_ai_deprecation_warning(
|
||||
resolved_integration.key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
|
||||
if ai_assistant or integration:
|
||||
if ai_skills:
|
||||
from ..integrations.base import SkillsIntegration as _SkillsCheck
|
||||
if isinstance(resolved_integration, _SkillsCheck):
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills is not needed; "
|
||||
"skills are the default for this integration.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills has no effect with "
|
||||
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
|
||||
)
|
||||
if ai_commands_dir and resolved_integration.key != "generic":
|
||||
console.print(
|
||||
"[dim]Note: --ai-commands-dir is deprecated; "
|
||||
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
|
||||
)
|
||||
|
||||
if no_git:
|
||||
console.print(
|
||||
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
|
||||
"[yellow]The git extension will no longer be enabled by default "
|
||||
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
|
||||
)
|
||||
|
||||
if project_name == ".":
|
||||
here = True
|
||||
project_name = None
|
||||
|
||||
if here and project_name:
|
||||
console.print("[red]Error:[/red] Cannot specify both project name and --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not here and not project_name:
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_skills and not ai_assistant:
|
||||
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||
raise typer.Exit(1)
|
||||
|
||||
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
dir_existed_before = False
|
||||
if here:
|
||||
project_name = Path.cwd().name
|
||||
project_path = Path.cwd()
|
||||
dir_existed_before = True
|
||||
|
||||
existing_items = list(project_path.iterdir())
|
||||
if existing_items:
|
||||
console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)")
|
||||
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
||||
if force:
|
||||
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
|
||||
else:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
project_path = Path(project_name).resolve()
|
||||
dir_existed_before = project_path.exists()
|
||||
if project_path.exists():
|
||||
if not project_path.is_dir():
|
||||
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
|
||||
raise typer.Exit(1)
|
||||
existing_items = list(project_path.iterdir())
|
||||
if force:
|
||||
if existing_items:
|
||||
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
|
||||
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
||||
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
"Please choose a different project name or remove the existing directory.\n"
|
||||
"Use [bold]--force[/bold] to merge into the existing directory.",
|
||||
title="[red]Directory Conflict[/red]",
|
||||
border_style="red",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(error_panel)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
if ai_assistant not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
elif not _stdin_is_interactive():
|
||||
console.print(
|
||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
"Use --integration to choose a different agent.[/dim]"
|
||||
)
|
||||
selected_ai = DEFAULT_INIT_INTEGRATION
|
||||
else:
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your coding agent integration:",
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
)
|
||||
|
||||
if not ai_assistant:
|
||||
resolved_integration = get_integration(selected_ai)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if selected_ai == "generic" and not integration_options:
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
|
||||
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
||||
raise typer.Exit(1)
|
||||
|
||||
current_dir = Path.cwd()
|
||||
|
||||
setup_lines = [
|
||||
"[cyan]Specify Project Setup[/cyan]",
|
||||
"",
|
||||
f"{'Project':<15} [green]{project_path.name}[/green]",
|
||||
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
|
||||
]
|
||||
|
||||
if not here:
|
||||
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
||||
|
||||
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
||||
|
||||
should_init_git = False
|
||||
if not no_git:
|
||||
should_init_git = check_tool("git")
|
||||
if not should_init_git:
|
||||
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config and agent_config["requires_cli"]:
|
||||
install_url = agent_config["install_url"]
|
||||
if not check_tool(selected_ai):
|
||||
error_panel = Panel(
|
||||
f"[cyan]{selected_ai}[/cyan] not found\n"
|
||||
f"Install from: [cyan]{install_url}[/cyan]\n"
|
||||
f"{agent_config['name']} is required to continue with this project type.\n\n"
|
||||
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
|
||||
title="[red]Agent Detection Error[/red]",
|
||||
border_style="red",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(error_panel)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if script_type:
|
||||
if script_type not in SCRIPT_TYPE_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_script = script_type
|
||||
else:
|
||||
default_script = "ps" if os.name == "nt" else "sh"
|
||||
|
||||
if _stdin_is_interactive():
|
||||
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
|
||||
else:
|
||||
selected_script = default_script
|
||||
|
||||
console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}")
|
||||
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
|
||||
|
||||
tracker = StepTracker("Initialize Specify Project")
|
||||
|
||||
tracker.add("precheck", "Check required tools")
|
||||
tracker.complete("precheck", "ok")
|
||||
tracker.add("ai-select", "Select coding agent integration")
|
||||
tracker.complete("ai-select", f"{selected_ai}")
|
||||
tracker.add("script-select", "Select script type")
|
||||
tracker.complete("script-select", selected_script)
|
||||
|
||||
tracker.add("integration", "Install integration")
|
||||
tracker.add("shared-infra", "Install shared infrastructure")
|
||||
|
||||
for key, label in [
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
git_default_notice = False
|
||||
|
||||
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||
try:
|
||||
from ..integrations.manifest import IntegrationManifest
|
||||
tracker.start("integration")
|
||||
manifest = IntegrationManifest(
|
||||
resolved_integration.key, project_path, version=get_speckit_version()
|
||||
)
|
||||
|
||||
integration_parsed_options: dict[str, Any] = {}
|
||||
if ai_commands_dir:
|
||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||
if ai_skills:
|
||||
integration_parsed_options["skills"] = True
|
||||
if integration_options:
|
||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||
if extra:
|
||||
integration_parsed_options.update(extra)
|
||||
|
||||
resolved_integration.setup(
|
||||
project_path, manifest,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
integration_settings = _with_integration_setting(
|
||||
{},
|
||||
resolved_integration.key,
|
||||
resolved_integration,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
)
|
||||
_write_integration_json(
|
||||
project_path,
|
||||
resolved_integration.key,
|
||||
[resolved_integration.key],
|
||||
integration_settings,
|
||||
)
|
||||
|
||||
tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
|
||||
|
||||
tracker.start("shared-infra")
|
||||
_install_shared_infra_or_exit(
|
||||
project_path,
|
||||
selected_script,
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options),
|
||||
)
|
||||
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
git_messages = []
|
||||
git_has_error = False
|
||||
if is_git_repo(project_path):
|
||||
git_messages.append("existing repo detected")
|
||||
elif should_init_git:
|
||||
success, error_msg = init_git_repo(project_path, quiet=True)
|
||||
if success:
|
||||
git_messages.append("initialized")
|
||||
else:
|
||||
git_has_error = True
|
||||
if error_msg:
|
||||
sanitized = error_msg.replace('\n', ' ').strip()
|
||||
git_messages.append(f"init failed: {sanitized[:120]}")
|
||||
else:
|
||||
git_messages.append("init failed")
|
||||
else:
|
||||
git_messages.append("git not available")
|
||||
try:
|
||||
from ..extensions import ExtensionManager
|
||||
bundled_path = _locate_bundled_extension("git")
|
||||
if bundled_path:
|
||||
manager = ExtensionManager(project_path)
|
||||
if manager.registry.is_installed("git"):
|
||||
git_messages.append("extension already installed")
|
||||
else:
|
||||
manager.install_from_directory(
|
||||
bundled_path, get_speckit_version()
|
||||
)
|
||||
git_default_notice = True
|
||||
git_messages.append("extension installed")
|
||||
else:
|
||||
git_has_error = True
|
||||
git_messages.append("bundled extension not found")
|
||||
except Exception as ext_err:
|
||||
git_has_error = True
|
||||
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
|
||||
git_messages.append(
|
||||
f"extension install failed: {sanitized_ext[:120]}"
|
||||
)
|
||||
summary = "; ".join(git_messages)
|
||||
if git_has_error:
|
||||
tracker.error("git", summary)
|
||||
else:
|
||||
tracker.complete("git", summary)
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
try:
|
||||
bundled_wf = _locate_bundled_workflow("speckit")
|
||||
if bundled_wf:
|
||||
from ..workflows.catalog import WorkflowRegistry
|
||||
from ..workflows.engine import WorkflowDefinition
|
||||
wf_registry = WorkflowRegistry(project_path)
|
||||
if wf_registry.is_installed("speckit"):
|
||||
tracker.complete("workflow", "already installed")
|
||||
else:
|
||||
import shutil as _shutil
|
||||
dest_wf = project_path / ".specify" / "workflows" / "speckit"
|
||||
dest_wf.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.copy2(
|
||||
bundled_wf / "workflow.yml",
|
||||
dest_wf / "workflow.yml",
|
||||
)
|
||||
definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
|
||||
wf_registry.add("speckit", {
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"source": "bundled",
|
||||
})
|
||||
tracker.complete("workflow", "speckit installed")
|
||||
else:
|
||||
tracker.skip("workflow", "bundled workflow not found")
|
||||
except Exception as wf_err:
|
||||
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
|
||||
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
init_opts = {
|
||||
"ai": selected_ai,
|
||||
"integration": resolved_integration.key,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"context_file": resolved_integration.context_file,
|
||||
"here": here,
|
||||
"script": selected_script,
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
if preset:
|
||||
try:
|
||||
from ..presets import PresetManager, PresetCatalog, PresetError
|
||||
preset_manager = PresetManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
local_path = Path(preset).resolve()
|
||||
if local_path.is_dir() and (local_path / "preset.yml").exists():
|
||||
preset_manager.install_from_directory(local_path, speckit_ver)
|
||||
else:
|
||||
bundled_path = _locate_bundled_preset(preset)
|
||||
if bundled_path:
|
||||
preset_manager.install_from_directory(bundled_path, speckit_ver)
|
||||
else:
|
||||
preset_catalog = PresetCatalog(project_path)
|
||||
pack_info = preset_catalog.get_pack_info(preset)
|
||||
if not pack_info:
|
||||
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
|
||||
elif pack_info.get("bundled") and not pack_info.get("download_url"):
|
||||
from ..extensions import REINSTALL_COMMAND
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
|
||||
f"but could not be found in the installed package."
|
||||
)
|
||||
console.print(
|
||||
"This usually means the spec-kit installation is incomplete or corrupted."
|
||||
)
|
||||
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
|
||||
else:
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = preset_catalog.download_pack(preset)
|
||||
preset_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except PresetError as preset_err:
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
"preset",
|
||||
preset,
|
||||
preset_err,
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
finally:
|
||||
if zip_path is not None:
|
||||
try:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as preset_err:
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
"preset",
|
||||
preset,
|
||||
preset_err,
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except (typer.Exit, SystemExit):
|
||||
raise
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
||||
if debug:
|
||||
_env_pairs = [
|
||||
("Python", sys.version.split()[0]),
|
||||
("Platform", sys.platform),
|
||||
("CWD", str(Path.cwd())),
|
||||
]
|
||||
_label_width = max(len(k) for k, _ in _env_pairs)
|
||||
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
|
||||
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
|
||||
if not here and project_path.exists() and not dir_existed_before:
|
||||
shutil.rmtree(project_path)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
pass
|
||||
|
||||
console.print(tracker.render())
|
||||
console.print("\n[bold green]Project ready.[/bold green]")
|
||||
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config:
|
||||
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
if ai_deprecation_warning:
|
||||
deprecation_notice = Panel(
|
||||
ai_deprecation_warning,
|
||||
title="[bold red]Deprecation Warning[/bold red]",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(deprecation_notice)
|
||||
|
||||
if git_default_notice:
|
||||
default_change_notice = Panel(
|
||||
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
|
||||
"Use [bold]specify extension add git[/bold] after init when needed.",
|
||||
title="[yellow]Notice: Git Default Changing[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(default_change_notice)
|
||||
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
||||
step_num = 2
|
||||
else:
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
||||
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
trae_skill_mode = selected_ai == "trae"
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||
devin_skill_mode = selected_ai == "devin"
|
||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
||||
|
||||
if codex_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
step_num += 1
|
||||
if claude_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
||||
step_num += 1
|
||||
if cursor_agent_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
|
||||
step_num += 1
|
||||
if devin_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode or agy_skill_mode or trae_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if claude_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit-{name}"
|
||||
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
|
||||
|
||||
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
||||
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
||||
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
||||
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
||||
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
||||
|
||||
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2))
|
||||
console.print()
|
||||
console.print(steps_panel)
|
||||
|
||||
enhancement_intro = (
|
||||
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
if native_skill_mode
|
||||
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
)
|
||||
enhancement_lines = [
|
||||
enhancement_intro,
|
||||
"",
|
||||
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
||||
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
||||
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
||||
]
|
||||
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1, 2))
|
||||
console.print()
|
||||
console.print(enhancements_panel)
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify integration * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify preset * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -1,2 +0,0 @@
|
||||
"""specify workflow * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -25,8 +25,6 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"checklist",
|
||||
@@ -109,8 +107,13 @@ def normalize_priority(value: Any, default: int = 10) -> int:
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry(BaseCatalogEntry):
|
||||
class CatalogEntry:
|
||||
"""Represents a single catalog entry in the catalog stack."""
|
||||
url: str
|
||||
name: str
|
||||
priority: int
|
||||
install_allowed: bool
|
||||
description: str = ""
|
||||
|
||||
|
||||
class ExtensionManifest:
|
||||
@@ -1187,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)
|
||||
|
||||
@@ -1663,16 +1666,12 @@ class CommandRegistrar:
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
|
||||
|
||||
class ExtensionCatalog(CatalogStackBase):
|
||||
class ExtensionCatalog:
|
||||
"""Manages extension catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
CONFIG_FILENAME = "extension-catalogs.yml"
|
||||
ENTRY_CLASS = CatalogEntry
|
||||
ERROR_TYPE = ValidationError
|
||||
VALIDATION_ERROR_TYPE = ValidationError
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize extension catalog manager.
|
||||
@@ -1686,6 +1685,27 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
ValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
||||
|
||||
@@ -1702,6 +1722,81 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to extension-catalogs.yml
|
||||
|
||||
Returns:
|
||||
Ordered list of CatalogEntry objects, or None if file doesn't exist.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any catalog entry has an invalid URL,
|
||||
the file cannot be parsed, a priority value is invalid,
|
||||
or the file exists but contains no valid catalog entries
|
||||
(fail-closed for security).
|
||||
"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as e:
|
||||
raise ValidationError(
|
||||
f"Failed to read catalog config {config_path}: {e}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
# File exists but has no catalogs key or empty list - fail closed
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||
)
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
||||
)
|
||||
entries: List[CatalogEntry] = []
|
||||
skipped_entries: List[int] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
skipped_entries.append(idx)
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
||||
else:
|
||||
install_allowed = bool(raw_install)
|
||||
entries.append(CatalogEntry(
|
||||
url=url,
|
||||
name=str(item.get("name", f"catalog-{idx + 1}")),
|
||||
priority=priority,
|
||||
install_allowed=install_allowed,
|
||||
description=str(item.get("description", "")),
|
||||
))
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
if not entries:
|
||||
# All entries were invalid (missing URLs) - fail closed for security
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
|
||||
f"(entries at indices {skipped_entries} were skipped). "
|
||||
f"Each catalog entry must have a 'url' field."
|
||||
)
|
||||
return entries
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
@@ -1731,44 +1826,24 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
file=sys.stderr,
|
||||
)
|
||||
self._non_default_catalog_warning_shown = True
|
||||
return [
|
||||
self._entry(
|
||||
url=catalog_url,
|
||||
name="custom",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Custom catalog via SPECKIT_CATALOG_URL",
|
||||
)
|
||||
]
|
||||
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
|
||||
|
||||
# 2. Project-level config overrides all defaults
|
||||
project_config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
|
||||
catalogs = self._load_catalog_config(project_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 3. User-level config
|
||||
user_config_path = Path.home() / ".specify" / self.CONFIG_FILENAME
|
||||
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
||||
catalogs = self._load_catalog_config(user_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 4. Built-in default stack
|
||||
return [
|
||||
self._entry(
|
||||
url=self.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Built-in catalog of installable extensions",
|
||||
),
|
||||
self._entry(
|
||||
url=self.COMMUNITY_CATALOG_URL,
|
||||
name="community",
|
||||
priority=2,
|
||||
install_allowed=False,
|
||||
description="Community-contributed extensions (discovery only)",
|
||||
),
|
||||
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
|
||||
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
|
||||
]
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
@@ -2406,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": [],
|
||||
@@ -2451,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 = {
|
||||
@@ -2600,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.
|
||||
@@ -2623,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
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -12,67 +11,6 @@ INTEGRATION_JSON = ".specify/integration.json"
|
||||
INTEGRATION_STATE_SCHEMA = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationReadError:
|
||||
"""Structured failure from :func:`try_read_integration_json`.
|
||||
|
||||
Callers map ``kind`` to whatever surface they need (loud CLI error,
|
||||
silent fallback, etc.) without re-implementing the parse/validation logic.
|
||||
"""
|
||||
|
||||
kind: str # "decode", "os", "not_object", "schema_too_new"
|
||||
detail: str = ""
|
||||
schema: int | None = None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This is the single low-level reader; both the CLI's loud
|
||||
``_read_integration_json`` and the workflow engine's silent
|
||||
``_load_project_integration`` consume it so the schema guard and parse
|
||||
logic cannot drift between them.
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
|
||||
# on some OSErrors (e.g. permission errors during stat), which would
|
||||
# silently treat an unreadable-but-present file as missing. Attempt the
|
||||
# read directly and distinguish FileNotFoundError (genuinely absent) from
|
||||
# other OSErrors (which become loud errors via the IntegrationReadError
|
||||
# path).
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None, None
|
||||
except IsADirectoryError as exc:
|
||||
return None, IntegrationReadError(
|
||||
kind="os",
|
||||
detail=f"{path} exists but is not a regular file: {exc}",
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
return None, IntegrationReadError(kind="decode", detail=str(exc))
|
||||
except OSError as exc:
|
||||
return None, IntegrationReadError(kind="os", detail=str(exc))
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, IntegrationReadError(kind="decode", detail=str(exc))
|
||||
if not isinstance(data, dict):
|
||||
return None, IntegrationReadError(kind="not_object", detail=type(data).__name__)
|
||||
schema = data.get("integration_state_schema")
|
||||
if (
|
||||
isinstance(schema, int)
|
||||
and not isinstance(schema, bool)
|
||||
and schema > INTEGRATION_STATE_SCHEMA
|
||||
):
|
||||
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
|
||||
return normalize_integration_state(data), None
|
||||
|
||||
|
||||
def clean_integration_key(key: Any) -> str | None:
|
||||
"""Return a stripped integration key, or None for empty/non-string values."""
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
|
||||
@@ -6,22 +6,7 @@ Commands are deprecated; ``--skills`` defaults to ``True``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so Codex maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
# Without this, Codex emits ``/speckit.git.commit`` (which does not
|
||||
# resolve) instead of ``/speckit-git-commit``.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
class CodexIntegration(SkillsIntegration):
|
||||
@@ -69,68 +54,3 @@ class CodexIntegration(SkillsIntegration):
|
||||
help="Install as agent skills (default for Codex)",
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject the dot-to-hyphen hook command note."""
|
||||
return self._inject_hook_command_note(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Codex skills, then inject the hook command note."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
for path in created:
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content = path.read_bytes().decode("utf-8")
|
||||
updated = self.post_process_skill_content(content)
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -24,16 +24,6 @@ from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
def _copilot_executable() -> str:
|
||||
"""Return the executable name for Copilot CLI on this platform.
|
||||
|
||||
On Windows, subprocess invocation is reliable with `copilot.cmd`.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return "copilot.cmd"
|
||||
return "copilot"
|
||||
|
||||
|
||||
def _allow_all() -> bool:
|
||||
"""Return True if the Copilot CLI should run with full permissions.
|
||||
|
||||
@@ -148,7 +138,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
# Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var
|
||||
# (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS
|
||||
# is also honoured as a fallback.
|
||||
args = [_copilot_executable(), "-p", prompt]
|
||||
args = ["copilot", "-p", prompt]
|
||||
if _allow_all():
|
||||
args.append("--yolo")
|
||||
if model:
|
||||
@@ -216,7 +206,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
agent_name = f"speckit.{stem}"
|
||||
prompt = args or ""
|
||||
|
||||
cli_args = [_copilot_executable(), "-p", prompt]
|
||||
cli_args = ["copilot", "-p", prompt]
|
||||
if not skills_mode:
|
||||
cli_args.extend(["--agent", agent_name])
|
||||
if _allow_all():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1048,9 +1048,9 @@ class PresetManager:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get(
|
||||
desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name.replace(".", "-"),
|
||||
f"Command: {short_name}",
|
||||
fm.get("description", f"Command: {short_name}"),
|
||||
)
|
||||
init_opts = load_init_options(self.project_root)
|
||||
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
|
||||
@@ -1314,9 +1314,9 @@ class PresetManager:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
frontmatter = dict(frontmatter)
|
||||
frontmatter["description"] = enhanced_desc
|
||||
@@ -1417,9 +1417,9 @@ class PresetManager:
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
@@ -1903,24 +1903,12 @@ class PresetCatalog:
|
||||
if not url:
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
raw_priority = item.get("priority", idx + 1)
|
||||
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
||||
# ``int(True)`` silently returns 1, which would let a YAML
|
||||
# ``priority: true`` slip through as a valid priority of 1. The
|
||||
# sibling integration-catalog reader in ``catalogs.py`` already
|
||||
# guards this; mirror the check here so the three catalog
|
||||
# validators stay consistent.
|
||||
if isinstance(raw_priority, bool):
|
||||
raise PresetValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise PresetValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
|
||||
@@ -19,10 +19,6 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..integration_state import (
|
||||
default_integration_key,
|
||||
try_read_integration_json,
|
||||
)
|
||||
from .base import RunStatus, StepContext, StepResult, StepStatus
|
||||
|
||||
|
||||
@@ -147,35 +143,6 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"Must be 'string', 'number', or 'boolean'."
|
||||
)
|
||||
|
||||
# Validate the default eagerly so authoring mistakes (e.g. a
|
||||
# default not in the declared enum, or a non-numeric default for
|
||||
# a number input) surface at install/validation time instead of
|
||||
# at workflow-execution time. ``"auto"`` for the integration
|
||||
# input is a runtime-resolved sentinel, so only the
|
||||
# enum-membership check is exempted for that exact case — the
|
||||
# declared type is still enforced (e.g. ``type: number`` paired
|
||||
# with ``default: "auto"`` is still rejected).
|
||||
if "default" in input_def:
|
||||
default_value = input_def["default"]
|
||||
is_auto_integration = (
|
||||
input_name == "integration" and default_value == "auto"
|
||||
)
|
||||
validation_input_def: dict[str, Any] = input_def
|
||||
if is_auto_integration and "enum" in input_def:
|
||||
validation_input_def = {
|
||||
key: value
|
||||
for key, value in input_def.items()
|
||||
if key != "enum"
|
||||
}
|
||||
try:
|
||||
WorkflowEngine._coerce_input(
|
||||
input_name, default_value, validation_input_def
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(
|
||||
f"Input {input_name!r} has invalid default: {exc}"
|
||||
)
|
||||
|
||||
# -- Steps ------------------------------------------------------------
|
||||
if not isinstance(definition.steps, list):
|
||||
errors.append("'steps' must be a list.")
|
||||
@@ -673,29 +640,22 @@ class WorkflowEngine:
|
||||
if not evaluate_condition(condition, context):
|
||||
break
|
||||
# Namespace nested step IDs per iteration
|
||||
# so logs and state keys are unique.
|
||||
# Execute one step at a time and alias each
|
||||
# result back to the unprefixed key so that
|
||||
# later steps in the same body and the loop
|
||||
# condition see the latest values.
|
||||
for ns_idx, ns in enumerate(result.next_steps):
|
||||
iter_steps = []
|
||||
for ns in result.next_steps:
|
||||
ns_copy = dict(ns)
|
||||
orig = ns_copy.get("id")
|
||||
base_id = orig or f"step-{ns_idx}"
|
||||
ns_copy["id"] = f"{step_id}:{base_id}:{_loop_iter + 1}"
|
||||
self._execute_steps(
|
||||
[ns_copy], context, state, registry,
|
||||
step_offset=-1,
|
||||
)
|
||||
if state.status in (
|
||||
RunStatus.PAUSED,
|
||||
RunStatus.FAILED,
|
||||
RunStatus.ABORTED,
|
||||
):
|
||||
return
|
||||
if orig and ns_copy["id"] in context.steps:
|
||||
context.steps[orig] = context.steps[ns_copy["id"]]
|
||||
state.step_results[orig] = context.steps[ns_copy["id"]]
|
||||
if "id" in ns_copy:
|
||||
ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}"
|
||||
iter_steps.append(ns_copy)
|
||||
self._execute_steps(
|
||||
iter_steps, context, state, registry,
|
||||
step_offset=-1,
|
||||
)
|
||||
if state.status in (
|
||||
RunStatus.PAUSED,
|
||||
RunStatus.FAILED,
|
||||
RunStatus.ABORTED,
|
||||
):
|
||||
return
|
||||
|
||||
# Fan-out: execute nested step template per item with unique IDs
|
||||
if step_type == "fan-out":
|
||||
@@ -751,73 +711,16 @@ class WorkflowEngine:
|
||||
if not isinstance(input_def, dict):
|
||||
continue
|
||||
if name in provided:
|
||||
# Resolve sentinels for explicitly-provided values too: a
|
||||
# caller passing ``{"integration": "auto"}`` (which the
|
||||
# workflow prompt advertises as a valid value) must be
|
||||
# treated identically to omitting the input and letting the
|
||||
# default flow through, so dispatch never sees the literal
|
||||
# sentinel.
|
||||
value = self._resolve_default(name, provided[name])
|
||||
resolved[name] = self._coerce_input(
|
||||
name, provided[name], input_def
|
||||
)
|
||||
elif "default" in input_def:
|
||||
value = self._resolve_default(name, input_def["default"])
|
||||
resolved[name] = input_def["default"]
|
||||
elif input_def.get("required", False):
|
||||
msg = f"Required input {name!r} not provided."
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
continue
|
||||
|
||||
# When the ``integration`` default could not be resolved against
|
||||
# project state and falls back to the literal ``"auto"``
|
||||
# sentinel, strip ``enum`` from the input definition before
|
||||
# coercion so a workflow that lists specific integrations in
|
||||
# ``enum`` does not crash at runtime on the sentinel value.
|
||||
# NOTE: only enum-membership is skipped; ``_coerce_input``
|
||||
# still enforces the declared ``type`` against the filtered
|
||||
# definition (``string`` rejects non-strings, ``number`` rejects
|
||||
# bools and uncoercible values, ``boolean`` rejects non-bools),
|
||||
# so ill-typed values still fail fast here.
|
||||
coerce_input_def = input_def
|
||||
if (
|
||||
name == "integration"
|
||||
and value == "auto"
|
||||
and "enum" in input_def
|
||||
):
|
||||
coerce_input_def = {
|
||||
key: val
|
||||
for key, val in input_def.items()
|
||||
if key != "enum"
|
||||
}
|
||||
resolved[name] = self._coerce_input(name, value, coerce_input_def)
|
||||
return resolved
|
||||
|
||||
def _resolve_default(self, name: str, default: Any) -> Any:
|
||||
"""Resolve special default sentinels against project state.
|
||||
|
||||
For the ``integration`` input, ``"auto"`` resolves to the integration
|
||||
recorded in ``.specify/integration.json`` so workflows dispatch to the
|
||||
AI the project was actually initialized with, instead of a hardcoded
|
||||
value baked into the workflow YAML.
|
||||
"""
|
||||
if name == "integration" and default == "auto":
|
||||
resolved = self._load_project_integration()
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return default
|
||||
|
||||
def _load_project_integration(self) -> str | None:
|
||||
"""Read the default integration key from ``.specify/integration.json``.
|
||||
|
||||
Delegates parsing and schema validation to
|
||||
:func:`try_read_integration_json` — the same low-level helper used by
|
||||
the CLI — so the engine cannot drift from CLI behavior on the parse
|
||||
path. Returns ``None`` when the file is missing, malformed, or
|
||||
written by a newer CLI; callers fall back to the literal default.
|
||||
"""
|
||||
state, error = try_read_integration_json(self.project_root)
|
||||
if state is None or error is not None:
|
||||
return None
|
||||
return default_integration_key(state)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_input(
|
||||
name: str, value: Any, input_def: dict[str, Any]
|
||||
@@ -827,13 +730,6 @@ class WorkflowEngine:
|
||||
enum_values = input_def.get("enum")
|
||||
|
||||
if input_type == "number":
|
||||
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
||||
# ``float(True)`` succeeds and would silently coerce a YAML
|
||||
# authoring mistake like ``type: number`` + ``default: true``
|
||||
# into ``1``. Fail fast instead.
|
||||
if isinstance(value, bool):
|
||||
msg = f"Input {name!r} expected a number, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
value = float(value)
|
||||
if value == int(value):
|
||||
@@ -850,17 +746,6 @@ class WorkflowEngine:
|
||||
else:
|
||||
msg = f"Input {name!r} expected a boolean, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
elif not isinstance(value, bool):
|
||||
msg = f"Input {name!r} expected a boolean, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
elif input_type == "string":
|
||||
# Without this, ``type: string`` accepts any Python value
|
||||
# (numbers, lists, dicts) because nothing else rejects it —
|
||||
# YAML ``default: 5`` would slip through. Require an actual
|
||||
# string so authoring mistakes fail at resolve time.
|
||||
if not isinstance(value, str):
|
||||
msg = f"Input {name!r} expected a string, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
|
||||
if enum_values is not None and value not in enum_values:
|
||||
msg = (
|
||||
|
||||
@@ -22,26 +22,6 @@ def _normalize_cli_output(output: str) -> str:
|
||||
return output.strip()
|
||||
|
||||
|
||||
class TestCliDiagnosticFormatting:
|
||||
def test_cli_error_detail_flattens_newlines(self):
|
||||
import specify_cli
|
||||
|
||||
assert specify_cli._cli_error_detail(RuntimeError("line one\nline two")) == "line one line two"
|
||||
|
||||
def test_cli_error_detail_handles_empty_message(self):
|
||||
import specify_cli
|
||||
|
||||
assert specify_cli._cli_error_detail(RuntimeError()) == "RuntimeError"
|
||||
|
||||
def test_cli_phase_label_includes_target(self):
|
||||
import specify_cli
|
||||
|
||||
assert (
|
||||
specify_cli._cli_phase_label("rollback", "integration", "codex")
|
||||
== "rollback integration 'codex'"
|
||||
)
|
||||
|
||||
|
||||
class TestInitIntegrationFlag:
|
||||
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
@@ -194,42 +174,6 @@ class TestInitIntegrationFlag:
|
||||
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
||||
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
|
||||
|
||||
def test_init_optional_preset_failure_reports_target_and_continues(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
def fail_install(self, path, version):
|
||||
raise OSError("preset install exploded\nwith context")
|
||||
|
||||
monkeypatch.setattr(PresetManager, "install_from_directory", fail_install)
|
||||
|
||||
project = tmp_path / "init-preset-warning"
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
str(project),
|
||||
"--integration",
|
||||
"copilot",
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--preset",
|
||||
"lean",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Failed to install preset 'lean'" in normalized
|
||||
assert "preset install exploded with context" in normalized
|
||||
assert "Continuing without the optional preset" in normalized
|
||||
assert "Project ready" in normalized
|
||||
|
||||
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -1111,143 +1055,6 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
def test_integration_install_failure_reports_phase_target_and_rollback(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
class BrokenIntegration(IntegrationBase):
|
||||
key = "broken-test"
|
||||
config = {
|
||||
"name": "Broken Test",
|
||||
"folder": ".broken/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".broken/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "BROKEN.md"
|
||||
|
||||
def setup(self, project_root, manifest, **kwargs):
|
||||
raise OSError("setup exploded\nwith context")
|
||||
|
||||
def teardown(self, project_root, manifest, force=False):
|
||||
raise OSError("rollback exploded")
|
||||
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration())
|
||||
|
||||
result = self._invoke(["integration", "install", "broken-test"], project)
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Failed to rollback integration 'broken-test'" in normalized
|
||||
assert "rollback exploded" in normalized
|
||||
assert "Failed to install integration 'broken-test'" in normalized
|
||||
assert "setup exploded with context" in normalized
|
||||
|
||||
def test_integration_upgrade_failure_reports_phase_and_target(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
class UpgradeBrokenIntegration(CopilotIntegration):
|
||||
key = "upgrade-broken"
|
||||
config = dict(CopilotIntegration.config)
|
||||
config["name"] = "Upgrade Broken"
|
||||
|
||||
def setup(self, project_root, manifest, **kwargs):
|
||||
raise OSError("upgrade exploded\nwith context")
|
||||
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setitem(
|
||||
INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration()
|
||||
)
|
||||
|
||||
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"integration": "upgrade-broken",
|
||||
"integrations": ["upgrade-broken"],
|
||||
"integration_settings": {"upgrade-broken": {"script": "sh"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(
|
||||
project / ".specify" / "integrations" / "upgrade-broken.manifest.json"
|
||||
).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"integration": "upgrade-broken",
|
||||
"version": "0.0.0",
|
||||
"installed_at": "2026-05-16T00:00:00+00:00",
|
||||
"files": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "upgrade", "upgrade-broken"], project)
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Failed to upgrade integration 'upgrade-broken'" in normalized
|
||||
assert "upgrade exploded with context" in normalized
|
||||
assert "previous integration files may still be in place" in normalized
|
||||
|
||||
def test_integration_switch_cleanup_warning_reports_phase_and_targets(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
project = self._make_project(tmp_path)
|
||||
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"integration": "copilot",
|
||||
"integrations": ["copilot"],
|
||||
"integration_settings": {"copilot": {"script": "sh"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project / ".specify" / "integrations" / "copilot.manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"integration": "copilot",
|
||||
"version": "0.0.0",
|
||||
"installed_at": "2026-05-16T00:00:00+00:00",
|
||||
"files": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fail_cleanup(self, integration_key):
|
||||
raise OSError("cleanup exploded")
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup)
|
||||
|
||||
result = self._invoke(["integration", "switch", "claude"], project)
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized
|
||||
assert "cleanup exploded" in normalized
|
||||
assert "Switched to integration" in normalized
|
||||
|
||||
# -- Project guard -----------------------------------------------------
|
||||
|
||||
def test_search_requires_specify_project(self, tmp_path):
|
||||
@@ -1412,30 +1219,6 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
assert "contains invalid JSON" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A non-UTF8 ``integration.json`` must surface a clear error and
|
||||
avoid falling through to the catalog lookup, mirroring the malformed-JSON
|
||||
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
|
||||
project = self._make_project(tmp_path)
|
||||
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
|
||||
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
|
||||
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise AssertionError("catalog search should not be called")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not valid UTF-8" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
|
||||
@@ -197,8 +197,8 @@ class TestClaudeIntegration:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("specify_cli.commands.init._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
|
||||
patch("specify_cli._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.select_with_arrows", return_value="claude"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -487,15 +487,13 @@ class TestClaudeDisableModelInvocation:
|
||||
assert "disable-model-invocation" not in fm
|
||||
assert "user-invocable" not in fm
|
||||
|
||||
def test_skills_default_post_process_is_identity(self, tmp_path):
|
||||
"""SkillsIntegration agents without an override leave content unchanged."""
|
||||
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
||||
# so it stands in for the base-class default behavior.
|
||||
agy = get_integration("agy")
|
||||
if agy is None:
|
||||
return # agy not registered in this build
|
||||
def test_non_claude_post_process_is_identity(self, tmp_path):
|
||||
"""Non-Claude integrations should not modify skill content."""
|
||||
codex = get_integration("codex")
|
||||
if codex is None:
|
||||
return # codex not registered in this build
|
||||
content = "---\nname: test\n---\nBody"
|
||||
assert agy.post_process_skill_content(content) == content
|
||||
assert codex.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestClaudeHookCommandNote:
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
"""Tests for CodexIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
@@ -28,89 +25,3 @@ class TestCodexAutoPromote:
|
||||
|
||||
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestCodexHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections.
|
||||
|
||||
Hook commands in ``extensions.yml`` use dotted ids like
|
||||
``speckit.git.commit`` but Codex skills are named with hyphens
|
||||
(``speckit-git-commit``). Without this note, Codex emits
|
||||
``/speckit.git.commit``, which does not resolve.
|
||||
"""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("codex")
|
||||
m = IntegrationManifest("codex", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = CodexIntegration._inject_hook_command_note(content)
|
||||
twice = CodexIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_hook_note_when_instruction_is_final_line_without_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the file
|
||||
ends without a trailing newline and the preceding line is not blank.
|
||||
"""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
# No blank line before the instruction and no trailing newline:
|
||||
# this is the case where the captured ``eol`` is empty and the
|
||||
# captured indent is also empty, so a missing line separator would
|
||||
# cause the note and instruction to collapse onto one line.
|
||||
content = (
|
||||
"---\nname: test\n---\n"
|
||||
"Body line\n"
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line_idx = next(
|
||||
i for i, l in enumerate(lines) if "replace dots" in l
|
||||
)
|
||||
instruction_line_idx = next(
|
||||
i for i, l in enumerate(lines)
|
||||
if l.lstrip().startswith("- For each executable hook")
|
||||
)
|
||||
assert note_line_idx < instruction_line_idx, (
|
||||
"Note must appear before the instruction"
|
||||
)
|
||||
assert "For each executable hook" not in lines[note_line_idx], (
|
||||
"Note and instruction must not be on the same line"
|
||||
)
|
||||
|
||||
@@ -1,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"))
|
||||
|
||||
@@ -163,30 +163,7 @@ class TestIntegrationInstall:
|
||||
assert "already installed" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "specify integration upgrade copilot" in normalized
|
||||
assert "already the default integration" in normalized
|
||||
assert "No files were changed" in normalized
|
||||
assert "specify integration uninstall copilot" not in normalized
|
||||
|
||||
def test_install_already_installed_non_default_guides_use(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "install", "codex"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "already installed" in normalized
|
||||
assert "specify integration use codex" in normalized
|
||||
assert "specify integration upgrade codex" in normalized
|
||||
assert "specify integration uninstall codex" not in normalized
|
||||
assert "specify integration uninstall copilot" in normalized
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -199,11 +176,7 @@ class TestIntegrationInstall:
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_safe_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -230,29 +203,6 @@ class TestIntegrationInstall:
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "claude"
|
||||
assert updated["ai"] == "claude"
|
||||
assert updated["context_file"] == "CLAUDE.md"
|
||||
|
||||
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
@@ -311,11 +261,7 @@ class TestIntegrationInstall:
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "multi-install safe" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -816,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
|
||||
@@ -891,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"
|
||||
|
||||
@@ -912,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, [
|
||||
@@ -1167,56 +1113,6 @@ class TestIntegrationUpgrade:
|
||||
assert "manifest" in result.output
|
||||
assert "unreadable" in result.output
|
||||
|
||||
def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
|
||||
def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
install = _run_in_project(project, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
])
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "gemini"
|
||||
assert updated["ai"] == "gemini"
|
||||
assert updated["context_file"] == "GEMINI.md"
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
@@ -1272,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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -832,7 +832,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
@@ -843,7 +843,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
@@ -852,7 +852,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
|
||||
@@ -7,13 +7,7 @@ Covers issue https://github.com/github/spec-kit/issues/550:
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app, check_tool
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
from specify_cli import check_tool
|
||||
|
||||
|
||||
class TestCheckToolClaude:
|
||||
@@ -28,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
|
||||
|
||||
@@ -44,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
|
||||
|
||||
@@ -55,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
|
||||
|
||||
@@ -66,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
|
||||
|
||||
@@ -82,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)
|
||||
|
||||
@@ -109,32 +93,4 @@ class TestCheckToolOther:
|
||||
return "/usr/bin/kiro" if name == "kiro" else None
|
||||
|
||||
with patch("shutil.which", side_effect=fake_which):
|
||||
assert check_tool("kiro-cli") is True
|
||||
|
||||
|
||||
class TestCheckTip:
|
||||
"""`specify check` should point users to the existing version check."""
|
||||
|
||||
def test_check_shows_self_check_tip(self):
|
||||
with patch("specify_cli.check_tool", return_value=True):
|
||||
result = runner.invoke(app, ["check"])
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Tip: Run 'specify self check' to verify you have the latest CLI version"
|
||||
in output
|
||||
)
|
||||
|
||||
def test_check_tip_does_not_fetch_latest_release(self):
|
||||
with (
|
||||
patch("specify_cli.check_tool", return_value=True),
|
||||
patch(
|
||||
"specify_cli._version._fetch_latest_release_tag",
|
||||
side_effect=AssertionError("latest release lookup should not run"),
|
||||
) as fetch_latest,
|
||||
):
|
||||
result = runner.invoke(app, ["check"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
fetch_latest.assert_not_called()
|
||||
assert check_tool("kiro-cli") is True
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for CLI version reporting."""
|
||||
"""Tests for the --version CLI flag."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
@@ -34,46 +33,3 @@ class TestVersionFlag:
|
||||
result = runner.invoke(app, ["--version", "init"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 0.7.2" in result.output
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the `specify version` subcommand."""
|
||||
|
||||
def test_version_features_text(self):
|
||||
"""specify version --features prints local capability flags."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Spec Kit CLI: 1.2.3" in result.output
|
||||
assert "Features:" in result.output
|
||||
assert "- controlled multi install integrations: yes" in result.output
|
||||
assert "- integration use command: yes" in result.output
|
||||
assert "- self check command: yes" in result.output
|
||||
|
||||
def test_version_features_json(self):
|
||||
"""specify version --features --json prints machine-readable capabilities."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {
|
||||
"version": "1.2.3",
|
||||
"features": {
|
||||
"controlled_multi_install_integrations": True,
|
||||
"integration_use_command": True,
|
||||
"multi_install_safe_registry_metadata": True,
|
||||
"integration_upgrade_command": True,
|
||||
"self_check_command": True,
|
||||
"workflow_catalog": True,
|
||||
"bundled_templates": True,
|
||||
},
|
||||
}
|
||||
|
||||
def test_version_json_requires_features(self):
|
||||
"""specify version --json is rejected until a JSON surface exists."""
|
||||
result = runner.invoke(app, ["version", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--json requires --features" in result.output
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Tests for the commands/ package structure."""
|
||||
import importlib
|
||||
|
||||
|
||||
def test_commands_package_importable():
|
||||
mod = importlib.import_module("specify_cli.commands")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_commands_init_importable():
|
||||
mod = importlib.import_module("specify_cli.commands.init")
|
||||
assert hasattr(mod, "register")
|
||||
assert callable(mod.register)
|
||||
|
||||
|
||||
def test_commands_stubs_importable():
|
||||
for name in ("integration", "preset", "extension", "workflow"):
|
||||
mod = importlib.import_module(f"specify_cli.commands.{name}")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_agent_config_importable():
|
||||
from specify_cli._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert isinstance(AI_ASSISTANT_ALIASES, dict)
|
||||
assert isinstance(AI_ASSISTANT_HELP, str)
|
||||
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_init_command_registered():
|
||||
from specify_cli import app
|
||||
callback_names = [
|
||||
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
|
||||
]
|
||||
assert "init" in callback_names
|
||||
@@ -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"] == {}
|
||||
@@ -1846,7 +1846,7 @@ Run {SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
|
||||
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
|
||||
@@ -2580,8 +2580,7 @@ class TestExtensionCatalog:
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile
|
||||
import io
|
||||
import zipfile, io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
@@ -2855,110 +2854,6 @@ class TestCatalogStack:
|
||||
assert len(entries) == 1
|
||||
assert entries[0].url == "http://localhost:8000/catalog.json"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_content", ["[]\n", "false\n", "0\n", "''\n", "- item\n"]
|
||||
)
|
||||
def test_load_catalog_config_rejects_non_mapping_roots(
|
||||
self, temp_dir, config_content
|
||||
):
|
||||
"""Malformed roots raise ValidationError, not fallback or AttributeError."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="expected a YAML mapping at the root"
|
||||
) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert str(config_path) in str(exc_info.value)
|
||||
|
||||
def test_load_catalog_config_rejects_boolean_priority(self, temp_dir):
|
||||
"""Boolean priorities are rejected instead of being coerced to 1 or 0."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "bad-priority",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="Invalid priority|expected integer"
|
||||
) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert str(config_path) in str(exc_info.value)
|
||||
|
||||
def test_load_catalog_config_defaults_blank_names(self, temp_dir):
|
||||
"""Blank and null names normalize by valid catalog order."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"name": "skipped", "url": " "},
|
||||
{"name": None, "url": "https://one.example.com/catalog.json"},
|
||||
{"name": " ", "url": "https://two.example.com/catalog.json"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
assert [entry.name for entry in catalog.get_active_catalogs()] == [
|
||||
"catalog-1",
|
||||
"catalog-2",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_detail"),
|
||||
[
|
||||
("relative/catalog.json", "HTTPS"),
|
||||
("https:///no-host", "valid URL with a host"),
|
||||
],
|
||||
)
|
||||
def test_load_catalog_config_invalid_url_includes_context(
|
||||
self, temp_dir, url, expected_detail
|
||||
):
|
||||
"""Invalid catalog URLs include the config path and entry index."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump({"catalogs": [{"name": "bad", "url": url}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
message = str(exc_info.value)
|
||||
assert "Invalid catalog URL" in message
|
||||
assert str(config_path) in message
|
||||
assert "index 0" in message
|
||||
assert expected_detail in message
|
||||
|
||||
# --- Merge conflict resolution ---
|
||||
|
||||
def test_merge_conflict_higher_priority_wins(self, temp_dir):
|
||||
|
||||
@@ -1830,31 +1830,6 @@ class TestPresetCatalogMultiCatalog:
|
||||
with pytest.raises(PresetValidationError, match="Invalid priority"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
def test_load_catalog_config_rejects_boolean_priority(self, project_dir):
|
||||
"""A YAML ``priority: true`` is a typo, not a request for priority 1.
|
||||
|
||||
``bool`` is a subclass of ``int`` in Python, so ``int(True)`` silently
|
||||
returns ``1``. Without an explicit guard a malformed config like
|
||||
``priority: yes`` would be accepted as a valid priority of 1 and
|
||||
silently change catalog ordering. The sibling integration-catalog
|
||||
reader rejects this case (see ``catalogs.py``); the preset catalog
|
||||
reader must stay consistent.
|
||||
"""
|
||||
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "bool-priority",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": True,
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
catalog = PresetCatalog(project_dir)
|
||||
with pytest.raises(PresetValidationError, match="Invalid priority|expected integer"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
def test_load_catalog_config_install_allowed_string(self, project_dir):
|
||||
"""Test that install_allowed accepts string values."""
|
||||
config_path = project_dir / ".specify" / "preset-catalogs.yml"
|
||||
@@ -2291,37 +2266,6 @@ class TestPresetSkills:
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
def _create_command_preset(self, temp_dir, preset_id, command_name, description, body):
|
||||
preset_dir = temp_dir / preset_id
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
command_file = f"{command_name}.md"
|
||||
(preset_dir / "commands" / command_file).write_text(
|
||||
f"---\ndescription: {description}\n---\n\n{body}\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": preset_id,
|
||||
"name": preset_id,
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": command_name,
|
||||
"file": f"commands/{command_file}",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
return preset_dir
|
||||
|
||||
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was used, a preset command override should update the skill."""
|
||||
# Simulate --ai-skills having been used: write init-options + create skill
|
||||
@@ -2346,120 +2290,6 @@ class TestPresetSkills:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_core_command_override_skill_uses_preset_command_description(self, project_dir, temp_dir):
|
||||
"""Preset skill overrides for core commands should keep preset frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
preset_dir = temp_dir / "taskstoissues-description"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.repro.taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: COMMAND-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"# Repro command body\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "taskstoissues-description",
|
||||
"name": "Taskstoissues Description",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.taskstoissues",
|
||||
"file": "commands/speckit.repro.taskstoissues.md",
|
||||
"description": "MANIFEST-DESCRIPTION",
|
||||
"replaces": "speckit.taskstoissues",
|
||||
"strategy": "replace",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: COMMAND-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: preset:taskstoissues-description" in content
|
||||
|
||||
def test_core_skill_restore_uses_core_command_description(self, project_dir, temp_dir):
|
||||
"""Core skill restore should keep core command frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
(core_cmds / "taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: CORE-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"core taskstoissues body\n"
|
||||
)
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"taskstoissues-restore",
|
||||
"speckit.taskstoissues",
|
||||
"PRESET-FRONTMATTER-DESCRIPTION",
|
||||
"preset taskstoissues body\n",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
manager.remove("taskstoissues-restore")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: CORE-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: templates/commands/taskstoissues.md" in content
|
||||
assert "core taskstoissues body" in content
|
||||
|
||||
def test_override_skill_reconcile_uses_override_command_description(self, project_dir, temp_dir):
|
||||
"""Override skill reconciliation should keep override frontmatter descriptions."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-taskstoissues")
|
||||
|
||||
overrides_dir = project_dir / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True)
|
||||
(overrides_dir / "speckit.taskstoissues.md").write_text(
|
||||
"---\n"
|
||||
"description: OVERRIDE-FRONTMATTER-DESCRIPTION\n"
|
||||
"---\n\n"
|
||||
"override taskstoissues body\n"
|
||||
)
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"taskstoissues-reconcile",
|
||||
"speckit.taskstoissues",
|
||||
"PRESET-FRONTMATTER-DESCRIPTION",
|
||||
"preset taskstoissues body\n",
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-taskstoissues" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
assert "description: OVERRIDE-FRONTMATTER-DESCRIPTION" in content
|
||||
assert "Convert tasks from tasks.md into GitHub issues." not in content
|
||||
assert "source: override:speckit.taskstoissues" in content
|
||||
assert "override taskstoissues body" in content
|
||||
|
||||
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
||||
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
||||
|
||||
@@ -115,36 +115,6 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell scripts and a BOM-prefixed template."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
templates_dir = tmp_path / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
# Write a BOM-prefixed template to ensure the WriteAllText fix is actually exercised.
|
||||
# If WriteAllText regresses, the output file will contain the BOM.
|
||||
bom = b"\xef\xbb\xbf"
|
||||
template_content = "# Feature Spec\n\nDescribe the feature here.\n"
|
||||
(templates_dir / "spec-template.md").write_bytes(bom + template_content.encode("utf-8"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_git_dir(tmp_path: Path) -> Path:
|
||||
"""Create a temp directory without git, but with scripts."""
|
||||
@@ -411,7 +381,6 @@ class TestGetFeaturePathsSinglePrefix:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||
@@ -681,45 +650,6 @@ class TestAllowExistingBranchPowerShell:
|
||||
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents
|
||||
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
@pytest.mark.skipif(
|
||||
os.name != "nt" or shutil.which("powershell.exe") is None,
|
||||
reason="Windows PowerShell not installed",
|
||||
)
|
||||
def test_ps_spec_file_written_without_bom(self, ps_git_repo: Path):
|
||||
"""spec.md generated from a BOM-prefixed template must not contain a UTF-8 BOM."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(CREATE_FEATURE_PS),
|
||||
"-ShortName",
|
||||
"bom-check",
|
||||
"BOM check feature",
|
||||
],
|
||||
cwd=ps_git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
spec_file = next((ps_git_repo / "specs").rglob("spec.md"), None)
|
||||
assert spec_file is not None, (
|
||||
f"spec.md was not created.\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
|
||||
raw = spec_file.read_bytes()
|
||||
assert not raw.startswith(b"\xef\xbb\xbf"), (
|
||||
f"spec.md must not start with a UTF-8 BOM — got first 3 bytes: {raw[:3]!r}"
|
||||
)
|
||||
# Verify template content was copied (not just an empty New-Item fallback)
|
||||
assert "Feature Spec" in raw.decode("utf-8"), (
|
||||
"spec.md does not contain template content — WriteAllText path was not exercised"
|
||||
)
|
||||
|
||||
|
||||
class TestGitExtensionParity:
|
||||
def test_bash_extension_surfaces_checkout_errors(self):
|
||||
@@ -974,6 +904,30 @@ def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ps_git_repo(tmp_path: Path) -> Path:
|
||||
"""Create a temp git repo with PowerShell scripts and .specify dir."""
|
||||
subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"],
|
||||
cwd=tmp_path,
|
||||
check=True,
|
||||
)
|
||||
ps_dir = tmp_path / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True)
|
||||
shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1")
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
shutil.copy(common_ps, ps_dir / "common.ps1")
|
||||
(tmp_path / ".specify" / "templates").mkdir(parents=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
|
||||
class TestPowerShellDryRun:
|
||||
def test_ps_dry_run_outputs_name(self, ps_git_repo: Path):
|
||||
@@ -1305,13 +1259,13 @@ class TestFeatureDirectoryResolution:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
|
||||
|
||||
# ── Description Quoting Tests (issue #2339) ──────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestDescriptionQuoting:
|
||||
"""Descriptions with quotes, apostrophes, and backslashes must not break the script.
|
||||
|
||||
Regression tests for https://github.com/github/spec-kit/issues/2339
|
||||
"""
|
||||
|
||||
@@ -1319,9 +1273,9 @@ class TestDescriptionQuoting:
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
'Fix the "login" bug',
|
||||
"Fix the \"login\" bug",
|
||||
"Handle path\\with\\backslashes",
|
||||
'It\'s a "complex" feature\\here',
|
||||
"It's a \"complex\" feature\\here",
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
@@ -1336,22 +1290,16 @@ class TestDescriptionQuoting:
|
||||
"description",
|
||||
[
|
||||
"Add user's profile page",
|
||||
'Fix the "login" bug',
|
||||
"Fix the \"login\" bug",
|
||||
"Handle path\\with\\backslashes",
|
||||
'It\'s a "complex" feature\\here',
|
||||
"It's a \"complex\" feature\\here",
|
||||
],
|
||||
ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
|
||||
)
|
||||
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
|
||||
"""Extension create-new-feature.sh succeeds with special characters in description."""
|
||||
script = (
|
||||
ext_git_repo
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "git"
|
||||
/ "scripts"
|
||||
/ "bash"
|
||||
/ "create-new-feature.sh"
|
||||
ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--dry-run", "--short-name", "feat", description],
|
||||
@@ -1373,4 +1321,3 @@ class TestDescriptionQuoting:
|
||||
"""Plain description without special characters continues to work."""
|
||||
result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -16,12 +16,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli._version import (
|
||||
_fetch_latest_release_tag,
|
||||
from specify_cli import (
|
||||
_get_installed_version,
|
||||
_fetch_latest_release_tag,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
app,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestNormalizeTag:
|
||||
|
||||
class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
@@ -162,7 +162,7 @@ class TestUserStory1:
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output
|
||||
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
@@ -174,7 +174,7 @@ class TestUserStory1:
|
||||
assert "git+https://" not in output
|
||||
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
@@ -185,7 +185,7 @@ class TestUserStory1:
|
||||
assert "Up to date" in output
|
||||
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
@@ -197,7 +197,7 @@ class TestUserStory1:
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
@@ -269,7 +269,7 @@ class TestUserStory2:
|
||||
def test_failure_prints_installed_plus_one_line_reason(
|
||||
self, expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -283,7 +283,7 @@ class TestUserStory2:
|
||||
|
||||
@pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES)
|
||||
def test_failure_exits_zero(self, _expected_reason, side_effect):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -293,7 +293,7 @@ class TestUserStory2:
|
||||
def test_failure_output_contains_no_traceback_no_url(
|
||||
self, _expected_reason, side_effect
|
||||
):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -390,7 +390,7 @@ class TestUserStory3:
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
@@ -403,7 +403,7 @@ class TestUserStory3:
|
||||
):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
|
||||
@@ -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)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Regression guard: version symbols must remain importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
GITHUB_API_LATEST,
|
||||
self_check,
|
||||
self_upgrade,
|
||||
)
|
||||
|
||||
|
||||
def test_version_symbols_importable():
|
||||
assert isinstance(GITHUB_API_LATEST, str)
|
||||
assert GITHUB_API_LATEST.startswith("https://")
|
||||
assert callable(self_check)
|
||||
assert callable(self_upgrade)
|
||||
|
||||
|
||||
def test_version_symbols_available_from_star_import():
|
||||
namespace = {}
|
||||
exec("from specify_cli import *", namespace)
|
||||
|
||||
for symbol in ("GITHUB_API_LATEST", "self_check", "self_upgrade"):
|
||||
assert symbol in namespace
|
||||
|
||||
|
||||
def test_version_module_symbols_directly_importable():
|
||||
from specify_cli._version import (
|
||||
GITHUB_API_LATEST,
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
_is_newer,
|
||||
_normalize_tag,
|
||||
self_app,
|
||||
self_check,
|
||||
self_upgrade,
|
||||
)
|
||||
assert callable(_get_installed_version)
|
||||
assert callable(_normalize_tag)
|
||||
assert callable(_is_newer)
|
||||
assert callable(_fetch_latest_release_tag)
|
||||
assert callable(self_check)
|
||||
assert callable(self_upgrade)
|
||||
assert self_app is not None
|
||||
@@ -13,7 +13,6 @@ Covers:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -374,8 +373,7 @@ class TestBuildExecArgs:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
impl = CopilotIntegration()
|
||||
args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514")
|
||||
expected_exec = "copilot.cmd" if os.name == "nt" else "copilot"
|
||||
assert args[0] == expected_exec
|
||||
assert args[0] == "copilot"
|
||||
assert "-p" in args
|
||||
assert "--yolo" in args
|
||||
assert "--model" in args
|
||||
@@ -465,7 +463,6 @@ class TestCommandStep:
|
||||
assert any("missing 'command'" in e for e in errors)
|
||||
|
||||
def test_step_override_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
@@ -477,8 +474,7 @@ class TestCommandStep:
|
||||
"integration": "gemini",
|
||||
"input": {},
|
||||
}
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["integration"] == "gemini"
|
||||
|
||||
def test_step_override_model(self):
|
||||
@@ -630,7 +626,6 @@ class TestPromptStep:
|
||||
assert result.output["dispatched"] is False
|
||||
|
||||
def test_execute_with_step_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
@@ -642,12 +637,10 @@ class TestPromptStep:
|
||||
"prompt": "Summarize the codebase",
|
||||
"integration": "gemini",
|
||||
}
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["integration"] == "gemini"
|
||||
|
||||
def test_execute_with_model(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
@@ -659,8 +652,7 @@ class TestPromptStep:
|
||||
"prompt": "hello",
|
||||
"model": "opus-4",
|
||||
}
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["model"] == "opus-4"
|
||||
|
||||
def test_dispatch_with_mock_cli(self, tmp_path):
|
||||
@@ -1503,656 +1495,6 @@ steps:
|
||||
with pytest.raises(ValueError, match="Required input"):
|
||||
engine.execute(definition, {})
|
||||
|
||||
def test_integration_auto_default_uses_project_integration(self, project_dir):
|
||||
"""`integration: auto` should resolve to .specify/integration.json's integration."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps({"integration": "opencode", "version": "0.7.4"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-default"
|
||||
name: "Auto Default"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "opencode"
|
||||
|
||||
def test_integration_auto_default_falls_back_when_no_integration_json(self, project_dir):
|
||||
"""`integration: auto` should keep the literal "auto" when project state is missing.
|
||||
|
||||
The engine itself must not invent an integration when
|
||||
``.specify/integration.json`` is absent; any later validation or
|
||||
command resolution will handle an unresolved ``"auto"`` value.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-fallback"
|
||||
name: "Auto Fallback"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_integration_explicit_input_overrides_auto(self, project_dir):
|
||||
"""An explicit --input integration=X must win over `auto` even when integration.json exists."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps({"integration": "opencode"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "explicit-wins"
|
||||
name: "Explicit Wins"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {"integration": "claude"})
|
||||
assert resolved["integration"] == "claude"
|
||||
|
||||
def test_integration_explicit_auto_resolves_like_default(self, project_dir):
|
||||
"""Passing ``integration=auto`` explicitly must resolve the sentinel,
|
||||
not pass it through as a literal — the workflow prompt advertises
|
||||
``auto`` as a valid value, so the dispatch path must never see it.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps({"integration": "opencode"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "explicit-auto"
|
||||
name: "Explicit Auto"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {"integration": "auto"})
|
||||
assert resolved["integration"] == "opencode"
|
||||
|
||||
def test_integration_auto_ignores_malformed_integration_json(self, project_dir):
|
||||
"""A malformed integration.json must not crash — fall back to the literal default."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text("{not json", encoding="utf-8")
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-malformed"
|
||||
name: "Auto Malformed"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_integration_auto_ignores_non_utf8_integration_json(self, project_dir):
|
||||
"""A non-UTF8 integration.json must not crash — fall back to the literal default."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 0xFF is invalid as the leading byte of a UTF-8 sequence, so
|
||||
# ``Path.read_text(encoding="utf-8")`` raises UnicodeDecodeError.
|
||||
(specify_dir / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-non-utf8"
|
||||
name: "Auto Non UTF-8"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_integration_auto_resolves_modern_normalized_state(self, project_dir):
|
||||
"""`integration: auto` must resolve modern state files that record
|
||||
``default_integration`` / ``installed_integrations`` and omit the
|
||||
legacy ``integration`` field."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": "0.8.3",
|
||||
"integration_state_schema": 1,
|
||||
"default_integration": "claude",
|
||||
"installed_integrations": ["claude", "copilot"],
|
||||
"integration_settings": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-modern"
|
||||
name: "Auto Modern"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "claude"
|
||||
|
||||
def test_integration_auto_rejects_future_state_schema(self, project_dir):
|
||||
"""`integration: auto` must not silently use a state file written by a newer
|
||||
CLI (``integration_state_schema`` greater than the current supported value);
|
||||
the resolver falls back to the literal default rather than guessing."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.integration_state import INTEGRATION_STATE_SCHEMA
|
||||
|
||||
specify_dir = project_dir / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": "99.0.0",
|
||||
"integration_state_schema": INTEGRATION_STATE_SCHEMA + 1,
|
||||
"default_integration": "claude",
|
||||
"installed_integrations": ["claude"],
|
||||
"integration_settings": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-future-schema"
|
||||
name: "Auto Future Schema"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["integration"] == "auto"
|
||||
|
||||
def test_default_value_is_validated_against_enum(self, project_dir):
|
||||
"""Defaults must run through the same coercion/enum check as provided inputs."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "default-enum"
|
||||
name: "Default Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
default: "not-in-enum"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="not in allowed values"):
|
||||
engine._resolve_inputs(definition, {})
|
||||
|
||||
def test_default_value_is_coerced_to_declared_type(self, project_dir):
|
||||
"""A numeric default declared as a string should still be coerced like a provided input."""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "default-coerce"
|
||||
name: "Default Coerce"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
retries:
|
||||
type: number
|
||||
default: "3"
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
resolved = engine._resolve_inputs(definition, {})
|
||||
assert resolved["retries"] == 3
|
||||
assert isinstance(resolved["retries"], int)
|
||||
|
||||
def test_validate_workflow_rejects_invalid_default(self):
|
||||
"""Authoring-time validation should reject defaults that violate enum."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bad-default"
|
||||
name: "Bad Default"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
default: "not-in-enum"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_exempts_integration_auto_sentinel(self):
|
||||
"""``integration: auto`` is a runtime-resolved sentinel and must not fail validation."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-ok"
|
||||
name: "Auto OK"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
enum: ["copilot", "claude", "gemini"]
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert not any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_still_checks_type_for_auto_sentinel(self):
|
||||
"""The ``auto`` exemption only skips enum-membership; declared type is still enforced."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-bad-type"
|
||||
name: "Auto Bad Type"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: number
|
||||
default: "auto"
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_rejects_bool_default_for_number_type(self):
|
||||
"""``type: number`` paired with a bool default must fail — bool is a
|
||||
subclass of int so ``float(True)`` would otherwise silently coerce
|
||||
``true`` to ``1``.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bool-as-number"
|
||||
name: "Bool As Number"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
count:
|
||||
type: number
|
||||
default: true
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_validate_workflow_rejects_non_string_default_for_string_type(self):
|
||||
"""``type: string`` must require an actual string — a numeric YAML
|
||||
default like ``5`` would otherwise slip through unvalidated.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "number-as-string"
|
||||
name: "Number As String"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
label:
|
||||
type: string
|
||||
default: 5
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid default" in e for e in errors), errors
|
||||
|
||||
def test_while_loop_condition_reads_latest_iteration(self, project_dir):
|
||||
"""Regression: while-loop condition must see updated step output
|
||||
from the most recent iteration, not stale iteration-0 data.
|
||||
|
||||
See https://github.com/github/spec-kit/issues/2592
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
# Shell step echoes a counter via a file.
|
||||
# Condition: exit_code != 0 means "keep looping" — but a non-zero
|
||||
# exit code would mark the step FAILED and abort the run, so we
|
||||
# use stdout-based comparison instead.
|
||||
#
|
||||
# Iteration 0: counter=1, echoes "1" → not "done" → loop continues
|
||||
# Iteration 1: counter=2, echoes "done" → condition false → stop
|
||||
# Without the fix, condition always reads iteration-0 stdout,
|
||||
# so the loop runs all max_iterations.
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
script_file = project_dir / "_tick.py"
|
||||
script_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print('done' if n >= 2 else str(n), end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "while-condition-update"
|
||||
name: "While Condition Update"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: while
|
||||
condition: "{{{{ 'done' not in steps.attempt.output.stdout }}}}"
|
||||
max_iterations: 5
|
||||
steps:
|
||||
- id: attempt
|
||||
type: shell
|
||||
run: '"{py}" "{script_file}"'
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
# The unprefixed key should reflect the latest iteration's result.
|
||||
assert state.step_results["attempt"]["output"]["stdout"] == "done"
|
||||
# Namespaced iteration-1 result should also exist.
|
||||
assert "retry-loop:attempt:1" in state.step_results
|
||||
# Counter should be 2 (iteration 0 + iteration 1), not 5.
|
||||
assert counter_file.read_text(encoding="utf-8").strip() == "2"
|
||||
|
||||
def test_do_while_loop_condition_reads_latest_iteration(self, project_dir):
|
||||
"""Regression: do-while loop condition must also see updated output.
|
||||
|
||||
See https://github.com/github/spec-kit/issues/2592
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
script_file = project_dir / "_tick.py"
|
||||
script_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print('done' if n >= 2 else str(n), end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "do-while-condition-update"
|
||||
name: "Do While Condition Update"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: do-while
|
||||
condition: "{{{{ 'done' not in steps.attempt.output.stdout }}}}"
|
||||
max_iterations: 5
|
||||
steps:
|
||||
- id: attempt
|
||||
type: shell
|
||||
run: '"{py}" "{script_file}"'
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert state.step_results["attempt"]["output"]["stdout"] == "done"
|
||||
assert counter_file.read_text(encoding="utf-8").strip() == "2"
|
||||
|
||||
def test_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
|
||||
"""While loop must still run to max_iterations when the condition
|
||||
never becomes false — copy-back must not break this path.
|
||||
|
||||
See https://github.com/github/spec-kit/issues/2592
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
script_file = project_dir / "_tick.py"
|
||||
script_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print('pending', end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "while-max-iterations"
|
||||
name: "While Max Iterations"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: while
|
||||
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
|
||||
max_iterations: 3
|
||||
steps:
|
||||
- id: tick
|
||||
type: shell
|
||||
run: '"{py}" "{script_file}"'
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
# All 3 iterations ran (iteration 0 + 2 loop iterations).
|
||||
assert counter_file.read_text(encoding="utf-8").strip() == "3"
|
||||
# Unprefixed key holds the last iteration's result.
|
||||
assert state.step_results["tick"]["output"]["stdout"] == "pending"
|
||||
# Namespaced keys for loop iterations exist.
|
||||
assert "retry-loop:tick:1" in state.step_results
|
||||
assert "retry-loop:tick:2" in state.step_results
|
||||
|
||||
def test_do_while_loop_runs_to_max_when_condition_stays_true(self, project_dir):
|
||||
"""Do-while loop must still run to max_iterations when the condition
|
||||
never becomes false.
|
||||
|
||||
See https://github.com/github/spec-kit/issues/2592
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
script_file = project_dir / "_tick.py"
|
||||
script_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print('pending', end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "do-while-max-iterations"
|
||||
name: "Do While Max Iterations"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: do-while
|
||||
condition: "{{{{ 'done' not in steps.tick.output.stdout }}}}"
|
||||
max_iterations: 3
|
||||
steps:
|
||||
- id: tick
|
||||
type: shell
|
||||
run: '"{py}" "{script_file}"'
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert counter_file.read_text(encoding="utf-8").strip() == "3"
|
||||
assert state.step_results["tick"]["output"]["stdout"] == "pending"
|
||||
|
||||
def test_while_loop_multi_step_body_inter_step_refs(self, project_dir):
|
||||
"""Multi-step loop body: step B must see step A's output from the
|
||||
current iteration, not a stale previous one.
|
||||
|
||||
See https://github.com/github/spec-kit/issues/2592
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
import sys
|
||||
|
||||
counter_file = project_dir / ".counter"
|
||||
counter_file.write_text("0", encoding="utf-8")
|
||||
py = sys.executable
|
||||
|
||||
# Step A: increments counter file, echoes the value.
|
||||
step_a_file = project_dir / "_step_a.py"
|
||||
step_a_file.write_text(
|
||||
f"import pathlib; p = pathlib.Path(r'{counter_file}')\n"
|
||||
"n = int(p.read_text()) + 1; p.write_text(str(n))\n"
|
||||
"print(str(n), end='')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Step B uses {{ steps.step-a.output.stdout }} expression
|
||||
# substitution in its run command so the engine resolves the
|
||||
# aliased unprefixed key — this is the real inter-step test.
|
||||
yaml_str = f"""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "while-multi-step"
|
||||
name: "While Multi Step"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: retry-loop
|
||||
type: while
|
||||
condition: "{{{{ 'done' not in steps.step-a.output.stdout }}}}"
|
||||
max_iterations: 3
|
||||
steps:
|
||||
- id: step-a
|
||||
type: shell
|
||||
run: '"{py}" "{step_a_file}"'
|
||||
- id: step-b
|
||||
type: shell
|
||||
run: "echo b-saw-{{{{ steps.step-a.output.stdout }}}}"
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
state = engine.execute(definition)
|
||||
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
# Both unprefixed keys reflect the latest iteration's results.
|
||||
assert state.step_results["step-a"]["output"]["stdout"] == "3"
|
||||
# Step B saw step A's output via expression substitution.
|
||||
assert "b-saw-3" in state.step_results["step-b"]["output"]["stdout"]
|
||||
# Namespaced keys exist for loop iterations.
|
||||
assert "retry-loop:step-a:1" in state.step_results
|
||||
assert "retry-loop:step-b:1" in state.step_results
|
||||
assert "retry-loop:step-a:2" in state.step_results
|
||||
assert "retry-loop:step-b:2" in state.step_results
|
||||
|
||||
|
||||
# ===== State Persistence Tests =====
|
||||
|
||||
|
||||
@@ -7,23 +7,9 @@ workflow:
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
# 0.8.5 is the first release with engine-side resolution of the
|
||||
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||
# as a literal integration key and fail at dispatch.
|
||||
speckit_version: ">=0.8.5"
|
||||
speckit_version: ">=0.7.2"
|
||||
integrations:
|
||||
# The four commands below (specify, plan, tasks, implement) are core
|
||||
# spec-kit commands provided by every integration. The list here is an
|
||||
# advisory, non-exhaustive compatibility hint following the documented
|
||||
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||
# against any integration the project was initialized with, including
|
||||
# ones not listed below, as long as that integration provides the four
|
||||
# core commands referenced in ``steps``.
|
||||
any:
|
||||
- "claude"
|
||||
- "copilot"
|
||||
- "gemini"
|
||||
- "opencode"
|
||||
any: ["copilot", "claude", "gemini"]
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
@@ -32,8 +18,8 @@ inputs:
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||
default: "copilot"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
|
||||
Reference in New Issue
Block a user