mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2314680fc | ||
|
|
616eba6a57 | ||
|
|
1bf4a6eb35 | ||
|
|
0dee2faf11 | ||
|
|
7fda89decb | ||
|
|
0964f113b7 | ||
|
|
b4b83be51b | ||
|
|
3d50f85875 | ||
|
|
0b9bd90021 | ||
|
|
bae355a234 | ||
|
|
9735145289 | ||
|
|
68a031c768 | ||
|
|
a59381ae30 | ||
|
|
975498e11d | ||
|
|
51e6a140e2 | ||
|
|
81e9ecd4d9 | ||
|
|
409ec59704 | ||
|
|
b36c34f171 | ||
|
|
8bd20a2f5f | ||
|
|
4c610a20dc | ||
|
|
27700387b6 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace
|
||||
14
.github/aw/actions-lock.json
vendored
Normal file
14
.github/aw/actions-lock.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"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,11 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- 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
|
||||
|
||||
1579
.github/workflows/add-community-extension.lock.yml
generated
vendored
Normal file
1579
.github/workflows/add-community-extension.lock.yml
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
288
.github/workflows/add-community-extension.md
vendored
Normal file
288
.github/workflows/add-community-extension.md
vendored
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
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
generated
vendored
Normal file
1579
.github/workflows/add-community-preset.lock.yml
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
282
.github/workflows/add-community-preset.md
vendored
Normal file
282
.github/workflows/add-community-preset.md
vendored
Normal file
@@ -0,0 +1,282 @@
|
||||
---
|
||||
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`
|
||||
22
.github/workflows/lint.yml
vendored
22
.github/workflows/lint.yml
vendored
@@ -13,6 +13,28 @@ 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
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,51 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
84
README.md
84
README.md
@@ -400,23 +400,24 @@ 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
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ └── spec.md
|
||||
└── templates
|
||||
├── plan-template.md
|
||||
├── spec-template.md
|
||||
└── tasks-template.md
|
||||
.
|
||||
├── .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
|
||||
```
|
||||
|
||||
### **STEP 3:** Functional specification clarification (required before planning)
|
||||
@@ -463,30 +464,31 @@ The output of this step will include a number of implementation detail documents
|
||||
```text
|
||||
.
|
||||
├── CLAUDE.md
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ └── bash
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── setup-tasks.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
|
||||
├── .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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -23,12 +23,12 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| Agent Governance | Project-local agent governance memory and context projection | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| 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 project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| 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) |
|
||||
@@ -51,6 +51,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
@@ -105,11 +106,13 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -69,6 +69,8 @@ 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
|
||||
|
||||
@@ -388,6 +388,14 @@ 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-15T00:00:00Z",
|
||||
"updated_at": "2026-05-21T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -71,10 +71,10 @@
|
||||
"agent-governance": {
|
||||
"name": "Agent Governance",
|
||||
"id": "agent-governance",
|
||||
"description": "Project-local agent governance memory and context projection.",
|
||||
"description": "Generate agent-platform repository governance files from Spec Kit metadata.",
|
||||
"author": "bigben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.0.0.zip",
|
||||
"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",
|
||||
@@ -84,8 +84,8 @@
|
||||
"speckit_version": ">=0.8.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "python3",
|
||||
"required": false
|
||||
"name": "uv",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -103,7 +103,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-14T00:00:00Z"
|
||||
"updated_at": "2026-05-21T00:00:00Z"
|
||||
},
|
||||
"agent-orchestrator": {
|
||||
"name": "Intelligent Agent Orchestrator",
|
||||
@@ -177,10 +177,10 @@
|
||||
"arch": {
|
||||
"name": "Architecture Workflow",
|
||||
"id": "arch",
|
||||
"description": "Generate project-level 4+1 architecture view artifacts and synthesis",
|
||||
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.0.0.zip",
|
||||
"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",
|
||||
@@ -190,7 +190,7 @@
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -203,7 +203,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-14T00:00:00Z"
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
@@ -1914,6 +1914,37 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"preview": {
|
||||
"name": "Interactive HTML Preview",
|
||||
"id": "preview",
|
||||
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-preview/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"preview",
|
||||
"prototype",
|
||||
"html",
|
||||
"ux"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
"id": "product-forge",
|
||||
@@ -2581,6 +2612,55 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"speckit-superpowers-bridge": {
|
||||
"name": "Superpowers Implementation Bridge",
|
||||
"id": "speckit-superpowers-bridge",
|
||||
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
||||
"author": "lihan3238",
|
||||
"version": "0.5.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.5.0/speckit-superpowers-bridge-v0.5.0.zip",
|
||||
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
||||
"changelog": "https://github.com/lihan3238/speckit-superpowers-bridge/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10",
|
||||
"tools": [
|
||||
{
|
||||
"name": "powershell",
|
||||
"version": ">=5.1",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "bash",
|
||||
"version": ">=4.0",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "jq",
|
||||
"version": ">=1.6",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"bridge",
|
||||
"superpowers",
|
||||
"cross-agent",
|
||||
"tdd",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
@@ -2649,21 +2729,21 @@
|
||||
"squad": {
|
||||
"name": "Squad Bridge",
|
||||
"id": "squad",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
|
||||
"description": "Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks.",
|
||||
"author": "jwill824",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.3.0.zip",
|
||||
"repository": "https://github.com/jwill824/spec-kit-squad",
|
||||
"homepage": "https://github.com/jwill824/spec-kit-squad",
|
||||
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
|
||||
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"speckit_version": ">=0.8.11",
|
||||
"tools": [
|
||||
{
|
||||
"name": "@bradygaster/squad-cli",
|
||||
"version": ">=0.1.0",
|
||||
"version": ">=0.9.4",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
@@ -2683,7 +2763,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-29T00:00:00Z",
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"staff-review": {
|
||||
"name": "Staff Review Extension",
|
||||
@@ -2885,6 +2965,37 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.11.dev0"
|
||||
version = "0.8.13"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -55,7 +55,7 @@ from .integration_state import (
|
||||
installed_integration_keys as _installed_integration_keys,
|
||||
integration_setting as _integration_setting,
|
||||
integration_settings as _integration_settings,
|
||||
normalize_integration_state as _normalize_integration_state,
|
||||
try_read_integration_json as _try_read_integration_json,
|
||||
write_integration_json as _write_integration_json_file,
|
||||
)
|
||||
from .shared_infra import (
|
||||
@@ -427,6 +427,35 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||
return project_path / ".agents" / "skills"
|
||||
|
||||
|
||||
def _cli_error_detail(exc: BaseException) -> str:
|
||||
"""Return a compact one-line exception detail for CLI output."""
|
||||
detail = str(exc).replace("\n", " ").strip()
|
||||
return detail or exc.__class__.__name__
|
||||
|
||||
|
||||
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
|
||||
"""Format a stable operation label for user-visible diagnostics."""
|
||||
label = f"{phase} {target_kind}".strip()
|
||||
if target:
|
||||
label = f"{label} '{target}'"
|
||||
return label
|
||||
|
||||
|
||||
def _print_cli_warning(
|
||||
phase: str,
|
||||
target_kind: str,
|
||||
target: str | None,
|
||||
exc: BaseException,
|
||||
*,
|
||||
continuing: str | None = None,
|
||||
) -> None:
|
||||
"""Print a warning that names the failed CLI phase and target."""
|
||||
label = _cli_phase_label(phase, target_kind, target)
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to {label}: {_cli_error_detail(exc)}")
|
||||
if continuing:
|
||||
console.print(f"[dim]{continuing}[/dim]")
|
||||
|
||||
|
||||
# Constants kept for backward compatibility with presets and extensions.
|
||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||
SKILL_DESCRIPTIONS = {
|
||||
@@ -859,9 +888,8 @@ def init(
|
||||
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]}"
|
||||
f"extension install failed during optional git setup: {_cli_error_detail(ext_err)[:120]}"
|
||||
)
|
||||
summary = "; ".join(git_messages)
|
||||
if git_has_error:
|
||||
@@ -899,8 +927,10 @@ def init(
|
||||
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]}")
|
||||
tracker.error(
|
||||
"workflow",
|
||||
f"install bundled workflow 'speckit' failed: {_cli_error_detail(wf_err)[:120]}",
|
||||
)
|
||||
|
||||
# Fix permissions after all installs (scripts + extensions)
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
@@ -962,7 +992,13 @@ def init(
|
||||
zip_path = preset_catalog.download_pack(preset)
|
||||
preset_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except PresetError as preset_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
"preset",
|
||||
preset,
|
||||
preset_err,
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
finally:
|
||||
if zip_path is not None:
|
||||
# Clean up downloaded ZIP to avoid cache accumulation
|
||||
@@ -972,7 +1008,14 @@ def init(
|
||||
# Best-effort cleanup; failure to delete is non-fatal
|
||||
pass
|
||||
except Exception as preset_err:
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
|
||||
# Optional preset install must not abort project initialization.
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
"preset",
|
||||
preset,
|
||||
preset_err,
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except (typer.Exit, SystemExit):
|
||||
@@ -1157,6 +1200,8 @@ def check():
|
||||
if not any(agent_results.values()):
|
||||
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")
|
||||
|
||||
console.print("[dim]Tip: Run 'specify self check' to verify you have the latest CLI version[/dim]")
|
||||
|
||||
|
||||
def _feature_capabilities() -> dict[str, bool]:
|
||||
"""Return stable local CLI capability flags for humans and agents."""
|
||||
@@ -1283,35 +1328,37 @@ integration_app.add_typer(integration_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def _read_integration_json(project_root: Path) -> dict[str, Any]:
|
||||
"""Load ``.specify/integration.json``. Returns normalized state when present."""
|
||||
"""Load ``.specify/integration.json``. Returns normalized state when present.
|
||||
|
||||
Delegates the parse / schema-guard logic to the shared
|
||||
:func:`_try_read_integration_json` helper so the CLI and workflow engine
|
||||
cannot drift on validation rules. Each error variant is translated into
|
||||
the existing loud-fail UX (console message + ``typer.Exit(1)``).
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
console.print(f"[red]Error:[/red] {path} contains invalid JSON.")
|
||||
state, error = _try_read_integration_json(project_root)
|
||||
if error is None:
|
||||
return state or {}
|
||||
if error.kind == "decode":
|
||||
console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.")
|
||||
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||
console.print(f"[dim]Details:[/dim] {exc}")
|
||||
raise typer.Exit(1)
|
||||
except OSError as exc:
|
||||
console.print(f"[dim]Details:[/dim] {error.detail}")
|
||||
elif error.kind == "os":
|
||||
console.print(f"[red]Error:[/red] Could not read {path}.")
|
||||
console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
|
||||
console.print(f"[dim]Details:[/dim] {exc}")
|
||||
raise typer.Exit(1)
|
||||
if not isinstance(data, dict):
|
||||
console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.")
|
||||
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||
raise typer.Exit(1)
|
||||
schema = data.get("integration_state_schema")
|
||||
if isinstance(schema, int) and not isinstance(schema, bool) and schema > INTEGRATION_STATE_SCHEMA:
|
||||
console.print(f"[dim]Details:[/dim] {error.detail}")
|
||||
elif error.kind == "not_object":
|
||||
console.print(
|
||||
f"[red]Error:[/red] {path} uses integration state schema {schema}, "
|
||||
f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}."
|
||||
)
|
||||
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||
elif error.kind == "schema_too_new":
|
||||
console.print(
|
||||
f"[red]Error:[/red] {path} uses integration state schema {error.schema}, "
|
||||
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
|
||||
)
|
||||
console.print("Please upgrade Spec Kit before modifying integrations.")
|
||||
raise typer.Exit(1)
|
||||
return _normalize_integration_state(data)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _write_integration_json(
|
||||
@@ -1691,20 +1738,29 @@ def integration_install(
|
||||
if new_default == integration.key:
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
# Attempt rollback of any files written by setup
|
||||
try:
|
||||
integration.teardown(project_root, manifest, force=True)
|
||||
except Exception as rollback_err:
|
||||
# Suppress so the original setup error remains the primary failure
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}")
|
||||
_print_cli_warning(
|
||||
"rollback",
|
||||
"integration",
|
||||
key,
|
||||
rollback_err,
|
||||
continuing="The original install failure is still the primary error.",
|
||||
)
|
||||
if installed_keys:
|
||||
_write_integration_json(
|
||||
project_root, default_key, installed_keys, _integration_settings(current)
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
console.print(f"[red]Error:[/red] Failed to install integration: {e}")
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: "
|
||||
f"{_cli_error_detail(exc)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
name = (integration.config or {}).get("name", key)
|
||||
@@ -2068,9 +2124,12 @@ def integration_switch(
|
||||
ext_mgr = ExtensionManager(project_root)
|
||||
ext_mgr.unregister_agent_artifacts(installed_key)
|
||||
except Exception as ext_err:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Could not clean up extension artifacts "
|
||||
f"(commands, skills, registry entries) for '{installed_key}': {ext_err}"
|
||||
_print_cli_warning(
|
||||
"clean up extension artifacts for",
|
||||
"integration",
|
||||
installed_key,
|
||||
ext_err,
|
||||
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
|
||||
)
|
||||
|
||||
# Clear metadata so a failed Phase 2 doesn't leave stale references
|
||||
@@ -2165,18 +2224,27 @@ def integration_switch(
|
||||
ext_mgr = ExtensionManager(project_root)
|
||||
ext_mgr.register_enabled_extensions_for_agent(target)
|
||||
except Exception as ext_err:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Could not register extension commands, skills, "
|
||||
f"or related artifacts for '{target}': {ext_err}"
|
||||
_print_cli_warning(
|
||||
"register extension artifacts for",
|
||||
"integration",
|
||||
target,
|
||||
ext_err,
|
||||
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
# Attempt rollback of any files written by setup
|
||||
try:
|
||||
target_integration.teardown(project_root, manifest, force=True)
|
||||
except Exception as rollback_err:
|
||||
# Suppress so the original setup error remains the primary failure
|
||||
console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}")
|
||||
_print_cli_warning(
|
||||
"rollback",
|
||||
"integration",
|
||||
target,
|
||||
rollback_err,
|
||||
continuing="The original switch failure is still the primary error.",
|
||||
)
|
||||
if installed_keys:
|
||||
fallback_key = installed_keys[0]
|
||||
fallback_integration = get_integration(fallback_key)
|
||||
@@ -2205,7 +2273,10 @@ def integration_switch(
|
||||
)
|
||||
else:
|
||||
_remove_integration_json(project_root)
|
||||
console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}")
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} "
|
||||
f"during switch: {_cli_error_detail(exc)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
name = (target_integration.config or {}).get("name", target)
|
||||
@@ -2340,7 +2411,8 @@ def integration_upgrade(
|
||||
except Exception as exc:
|
||||
# Don't teardown — setup overwrites in-place, so teardown would
|
||||
# delete files that were working before the upgrade. Just report.
|
||||
console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}")
|
||||
console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.")
|
||||
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
||||
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"checklist",
|
||||
@@ -107,13 +109,8 @@ def normalize_priority(value: Any, default: int = 10) -> int:
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry:
|
||||
class CatalogEntry(BaseCatalogEntry):
|
||||
"""Represents a single catalog entry in the catalog stack."""
|
||||
url: str
|
||||
name: str
|
||||
priority: int
|
||||
install_allowed: bool
|
||||
description: str = ""
|
||||
|
||||
|
||||
class ExtensionManifest:
|
||||
@@ -1666,12 +1663,16 @@ class CommandRegistrar:
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
|
||||
|
||||
class ExtensionCatalog:
|
||||
class ExtensionCatalog(CatalogStackBase):
|
||||
"""Manages extension catalog fetching, caching, and searching."""
|
||||
|
||||
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
|
||||
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
|
||||
CACHE_DURATION = 3600 # 1 hour in seconds
|
||||
CONFIG_FILENAME = "extension-catalogs.yml"
|
||||
ENTRY_CLASS = CatalogEntry
|
||||
ERROR_TYPE = ValidationError
|
||||
VALIDATION_ERROR_TYPE = ValidationError
|
||||
|
||||
def __init__(self, project_root: Path):
|
||||
"""Initialize extension catalog manager.
|
||||
@@ -1685,27 +1686,6 @@ class ExtensionCatalog:
|
||||
self.cache_file = self.cache_dir / "catalog.json"
|
||||
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
|
||||
|
||||
def _validate_catalog_url(self, url: str) -> None:
|
||||
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
|
||||
|
||||
Args:
|
||||
url: URL to validate
|
||||
|
||||
Raises:
|
||||
ValidationError: If URL is invalid or uses non-HTTPS scheme
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ValidationError(
|
||||
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.netloc:
|
||||
raise ValidationError("Catalog URL must be a valid URL with a host.")
|
||||
|
||||
def _make_request(self, url: str):
|
||||
"""Build a urllib Request, adding auth headers when a provider matches.
|
||||
|
||||
@@ -1722,81 +1702,6 @@ class ExtensionCatalog:
|
||||
from specify_cli.authentication.http import open_url
|
||||
return open_url(url, timeout)
|
||||
|
||||
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
|
||||
"""Load catalog stack configuration from a YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to extension-catalogs.yml
|
||||
|
||||
Returns:
|
||||
Ordered list of CatalogEntry objects, or None if file doesn't exist.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any catalog entry has an invalid URL,
|
||||
the file cannot be parsed, a priority value is invalid,
|
||||
or the file exists but contains no valid catalog entries
|
||||
(fail-closed for security).
|
||||
"""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError) as e:
|
||||
raise ValidationError(
|
||||
f"Failed to read catalog config {config_path}: {e}"
|
||||
)
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
# File exists but has no catalogs key or empty list - fail closed
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
|
||||
f"Remove the file to use built-in defaults, or add valid catalog entries."
|
||||
)
|
||||
if not isinstance(catalogs_data, list):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
|
||||
)
|
||||
entries: List[CatalogEntry] = []
|
||||
skipped_entries: List[int] = []
|
||||
for idx, item in enumerate(catalogs_data):
|
||||
if not isinstance(item, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
|
||||
)
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
skipped_entries.append(idx)
|
||||
continue
|
||||
self._validate_catalog_url(url)
|
||||
try:
|
||||
priority = int(item.get("priority", idx + 1))
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
|
||||
else:
|
||||
install_allowed = bool(raw_install)
|
||||
entries.append(CatalogEntry(
|
||||
url=url,
|
||||
name=str(item.get("name", f"catalog-{idx + 1}")),
|
||||
priority=priority,
|
||||
install_allowed=install_allowed,
|
||||
description=str(item.get("description", "")),
|
||||
))
|
||||
entries.sort(key=lambda e: e.priority)
|
||||
if not entries:
|
||||
# All entries were invalid (missing URLs) - fail closed for security
|
||||
raise ValidationError(
|
||||
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
|
||||
f"(entries at indices {skipped_entries} were skipped). "
|
||||
f"Each catalog entry must have a 'url' field."
|
||||
)
|
||||
return entries
|
||||
|
||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||
"""Get the ordered list of active catalogs.
|
||||
|
||||
@@ -1826,24 +1731,44 @@ class ExtensionCatalog:
|
||||
file=sys.stderr,
|
||||
)
|
||||
self._non_default_catalog_warning_shown = True
|
||||
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
|
||||
return [
|
||||
self._entry(
|
||||
url=catalog_url,
|
||||
name="custom",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Custom catalog via SPECKIT_CATALOG_URL",
|
||||
)
|
||||
]
|
||||
|
||||
# 2. Project-level config overrides all defaults
|
||||
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
|
||||
project_config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
|
||||
catalogs = self._load_catalog_config(project_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 3. User-level config
|
||||
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
|
||||
user_config_path = Path.home() / ".specify" / self.CONFIG_FILENAME
|
||||
catalogs = self._load_catalog_config(user_config_path)
|
||||
if catalogs is not None:
|
||||
return catalogs
|
||||
|
||||
# 4. Built-in default stack
|
||||
return [
|
||||
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
|
||||
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
|
||||
self._entry(
|
||||
url=self.DEFAULT_CATALOG_URL,
|
||||
name="default",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Built-in catalog of installable extensions",
|
||||
),
|
||||
self._entry(
|
||||
url=self.COMMUNITY_CATALOG_URL,
|
||||
name="community",
|
||||
priority=2,
|
||||
install_allowed=False,
|
||||
description="Community-contributed extensions (discovery only)",
|
||||
),
|
||||
]
|
||||
|
||||
def get_catalog_url(self) -> str:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -11,6 +12,67 @@ INTEGRATION_JSON = ".specify/integration.json"
|
||||
INTEGRATION_STATE_SCHEMA = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationReadError:
|
||||
"""Structured failure from :func:`try_read_integration_json`.
|
||||
|
||||
Callers map ``kind`` to whatever surface they need (loud CLI error,
|
||||
silent fallback, etc.) without re-implementing the parse/validation logic.
|
||||
"""
|
||||
|
||||
kind: str # "decode", "os", "not_object", "schema_too_new"
|
||||
detail: str = ""
|
||||
schema: int | None = None
|
||||
|
||||
|
||||
def try_read_integration_json(
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
|
||||
"""Parse ``.specify/integration.json`` without raising.
|
||||
|
||||
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
|
||||
file does not exist, or ``(None, error)`` for any parse / validation
|
||||
failure. This is the single low-level reader; both the CLI's loud
|
||||
``_read_integration_json`` and the workflow engine's silent
|
||||
``_load_project_integration`` consume it so the schema guard and parse
|
||||
logic cannot drift between them.
|
||||
"""
|
||||
path = project_root / INTEGRATION_JSON
|
||||
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
|
||||
# on some OSErrors (e.g. permission errors during stat), which would
|
||||
# silently treat an unreadable-but-present file as missing. Attempt the
|
||||
# read directly and distinguish FileNotFoundError (genuinely absent) from
|
||||
# other OSErrors (which become loud errors via the IntegrationReadError
|
||||
# path).
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None, None
|
||||
except IsADirectoryError as exc:
|
||||
return None, IntegrationReadError(
|
||||
kind="os",
|
||||
detail=f"{path} exists but is not a regular file: {exc}",
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
return None, IntegrationReadError(kind="decode", detail=str(exc))
|
||||
except OSError as exc:
|
||||
return None, IntegrationReadError(kind="os", detail=str(exc))
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return None, IntegrationReadError(kind="decode", detail=str(exc))
|
||||
if not isinstance(data, dict):
|
||||
return None, IntegrationReadError(kind="not_object", detail=type(data).__name__)
|
||||
schema = data.get("integration_state_schema")
|
||||
if (
|
||||
isinstance(schema, int)
|
||||
and not isinstance(schema, bool)
|
||||
and schema > INTEGRATION_STATE_SCHEMA
|
||||
):
|
||||
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
|
||||
return normalize_integration_state(data), None
|
||||
|
||||
|
||||
def clean_integration_key(key: Any) -> str | None:
|
||||
"""Return a stripped integration key, or None for empty/non-string values."""
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
|
||||
@@ -6,7 +6,22 @@ Commands are deprecated; ``--skills`` defaults to ``True``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so Codex maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
# Without this, Codex emits ``/speckit.git.commit`` (which does not
|
||||
# resolve) instead of ``/speckit-git-commit``.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
class CodexIntegration(SkillsIntegration):
|
||||
@@ -54,3 +69,68 @@ class CodexIntegration(SkillsIntegration):
|
||||
help="Install as agent skills (default for Codex)",
|
||||
),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject the dot-to-hyphen hook command note."""
|
||||
return self._inject_hook_command_note(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Codex skills, then inject the hook command note."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
for path in created:
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content = path.read_bytes().decode("utf-8")
|
||||
updated = self.post_process_skill_content(content)
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1903,12 +1903,24 @@ 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(item.get("priority", idx + 1))
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError):
|
||||
raise PresetValidationError(
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
f"expected integer, got {item.get('priority')!r}"
|
||||
f"expected integer, got {raw_priority!r}"
|
||||
)
|
||||
raw_install = item.get("install_allowed", False)
|
||||
if isinstance(raw_install, str):
|
||||
|
||||
@@ -19,6 +19,10 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..integration_state import (
|
||||
default_integration_key,
|
||||
try_read_integration_json,
|
||||
)
|
||||
from .base import RunStatus, StepContext, StepResult, StepStatus
|
||||
|
||||
|
||||
@@ -143,6 +147,35 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"Must be 'string', 'number', or 'boolean'."
|
||||
)
|
||||
|
||||
# Validate the default eagerly so authoring mistakes (e.g. a
|
||||
# default not in the declared enum, or a non-numeric default for
|
||||
# a number input) surface at install/validation time instead of
|
||||
# at workflow-execution time. ``"auto"`` for the integration
|
||||
# input is a runtime-resolved sentinel, so only the
|
||||
# enum-membership check is exempted for that exact case — the
|
||||
# declared type is still enforced (e.g. ``type: number`` paired
|
||||
# with ``default: "auto"`` is still rejected).
|
||||
if "default" in input_def:
|
||||
default_value = input_def["default"]
|
||||
is_auto_integration = (
|
||||
input_name == "integration" and default_value == "auto"
|
||||
)
|
||||
validation_input_def: dict[str, Any] = input_def
|
||||
if is_auto_integration and "enum" in input_def:
|
||||
validation_input_def = {
|
||||
key: value
|
||||
for key, value in input_def.items()
|
||||
if key != "enum"
|
||||
}
|
||||
try:
|
||||
WorkflowEngine._coerce_input(
|
||||
input_name, default_value, validation_input_def
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append(
|
||||
f"Input {input_name!r} has invalid default: {exc}"
|
||||
)
|
||||
|
||||
# -- Steps ------------------------------------------------------------
|
||||
if not isinstance(definition.steps, list):
|
||||
errors.append("'steps' must be a list.")
|
||||
@@ -640,22 +673,29 @@ class WorkflowEngine:
|
||||
if not evaluate_condition(condition, context):
|
||||
break
|
||||
# Namespace nested step IDs per iteration
|
||||
iter_steps = []
|
||||
for ns in result.next_steps:
|
||||
# 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):
|
||||
ns_copy = dict(ns)
|
||||
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
|
||||
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"]]
|
||||
|
||||
# Fan-out: execute nested step template per item with unique IDs
|
||||
if step_type == "fan-out":
|
||||
@@ -711,16 +751,73 @@ class WorkflowEngine:
|
||||
if not isinstance(input_def, dict):
|
||||
continue
|
||||
if name in provided:
|
||||
resolved[name] = self._coerce_input(
|
||||
name, provided[name], input_def
|
||||
)
|
||||
# Resolve sentinels for explicitly-provided values too: a
|
||||
# caller passing ``{"integration": "auto"}`` (which the
|
||||
# workflow prompt advertises as a valid value) must be
|
||||
# treated identically to omitting the input and letting the
|
||||
# default flow through, so dispatch never sees the literal
|
||||
# sentinel.
|
||||
value = self._resolve_default(name, provided[name])
|
||||
elif "default" in input_def:
|
||||
resolved[name] = input_def["default"]
|
||||
value = self._resolve_default(name, input_def["default"])
|
||||
elif input_def.get("required", False):
|
||||
msg = f"Required input {name!r} not provided."
|
||||
raise ValueError(msg)
|
||||
else:
|
||||
continue
|
||||
|
||||
# When the ``integration`` default could not be resolved against
|
||||
# project state and falls back to the literal ``"auto"``
|
||||
# sentinel, strip ``enum`` from the input definition before
|
||||
# coercion so a workflow that lists specific integrations in
|
||||
# ``enum`` does not crash at runtime on the sentinel value.
|
||||
# NOTE: only enum-membership is skipped; ``_coerce_input``
|
||||
# still enforces the declared ``type`` against the filtered
|
||||
# definition (``string`` rejects non-strings, ``number`` rejects
|
||||
# bools and uncoercible values, ``boolean`` rejects non-bools),
|
||||
# so ill-typed values still fail fast here.
|
||||
coerce_input_def = input_def
|
||||
if (
|
||||
name == "integration"
|
||||
and value == "auto"
|
||||
and "enum" in input_def
|
||||
):
|
||||
coerce_input_def = {
|
||||
key: val
|
||||
for key, val in input_def.items()
|
||||
if key != "enum"
|
||||
}
|
||||
resolved[name] = self._coerce_input(name, value, coerce_input_def)
|
||||
return resolved
|
||||
|
||||
def _resolve_default(self, name: str, default: Any) -> Any:
|
||||
"""Resolve special default sentinels against project state.
|
||||
|
||||
For the ``integration`` input, ``"auto"`` resolves to the integration
|
||||
recorded in ``.specify/integration.json`` so workflows dispatch to the
|
||||
AI the project was actually initialized with, instead of a hardcoded
|
||||
value baked into the workflow YAML.
|
||||
"""
|
||||
if name == "integration" and default == "auto":
|
||||
resolved = self._load_project_integration()
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return default
|
||||
|
||||
def _load_project_integration(self) -> str | None:
|
||||
"""Read the default integration key from ``.specify/integration.json``.
|
||||
|
||||
Delegates parsing and schema validation to
|
||||
:func:`try_read_integration_json` — the same low-level helper used by
|
||||
the CLI — so the engine cannot drift from CLI behavior on the parse
|
||||
path. Returns ``None`` when the file is missing, malformed, or
|
||||
written by a newer CLI; callers fall back to the literal default.
|
||||
"""
|
||||
state, error = try_read_integration_json(self.project_root)
|
||||
if state is None or error is not None:
|
||||
return None
|
||||
return default_integration_key(state)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_input(
|
||||
name: str, value: Any, input_def: dict[str, Any]
|
||||
@@ -730,6 +827,13 @@ class WorkflowEngine:
|
||||
enum_values = input_def.get("enum")
|
||||
|
||||
if input_type == "number":
|
||||
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
||||
# ``float(True)`` succeeds and would silently coerce a YAML
|
||||
# authoring mistake like ``type: number`` + ``default: true``
|
||||
# into ``1``. Fail fast instead.
|
||||
if isinstance(value, bool):
|
||||
msg = f"Input {name!r} expected a number, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
value = float(value)
|
||||
if value == int(value):
|
||||
@@ -746,6 +850,17 @@ class WorkflowEngine:
|
||||
else:
|
||||
msg = f"Input {name!r} expected a boolean, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
elif not isinstance(value, bool):
|
||||
msg = f"Input {name!r} expected a boolean, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
elif input_type == "string":
|
||||
# Without this, ``type: string`` accepts any Python value
|
||||
# (numbers, lists, dicts) because nothing else rejects it —
|
||||
# YAML ``default: 5`` would slip through. Require an actual
|
||||
# string so authoring mistakes fail at resolve time.
|
||||
if not isinstance(value, str):
|
||||
msg = f"Input {name!r} expected a string, got {value!r}."
|
||||
raise ValueError(msg)
|
||||
|
||||
if enum_values is not None and value not in enum_values:
|
||||
msg = (
|
||||
|
||||
@@ -22,6 +22,26 @@ 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
|
||||
@@ -174,6 +194,42 @@ 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
|
||||
@@ -1055,6 +1111,143 @@ 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):
|
||||
@@ -1219,6 +1412,30 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
assert "contains invalid JSON" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A non-UTF8 ``integration.json`` must surface a clear error and
|
||||
avoid falling through to the catalog lookup, mirroring the malformed-JSON
|
||||
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
|
||||
project = self._make_project(tmp_path)
|
||||
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
|
||||
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
|
||||
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise AssertionError("catalog search should not be called")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not valid UTF-8" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
|
||||
@@ -487,13 +487,15 @@ class TestClaudeDisableModelInvocation:
|
||||
assert "disable-model-invocation" not in fm
|
||||
assert "user-invocable" not in fm
|
||||
|
||||
def test_non_claude_post_process_is_identity(self, tmp_path):
|
||||
"""Non-Claude integrations should not modify skill content."""
|
||||
codex = get_integration("codex")
|
||||
if codex is None:
|
||||
return # codex not registered in this build
|
||||
def test_skills_default_post_process_is_identity(self, tmp_path):
|
||||
"""SkillsIntegration agents without an override leave content unchanged."""
|
||||
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
||||
# so it stands in for the base-class default behavior.
|
||||
agy = get_integration("agy")
|
||||
if agy is None:
|
||||
return # agy not registered in this build
|
||||
content = "---\nname: test\n---\nBody"
|
||||
assert codex.post_process_skill_content(content) == content
|
||||
assert agy.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestClaudeHookCommandNote:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for CodexIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
@@ -25,3 +28,89 @@ class TestCodexAutoPromote:
|
||||
|
||||
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestCodexHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections.
|
||||
|
||||
Hook commands in ``extensions.yml`` use dotted ids like
|
||||
``speckit.git.commit`` but Codex skills are named with hyphens
|
||||
(``speckit-git-commit``). Without this note, Codex emits
|
||||
``/speckit.git.commit``, which does not resolve.
|
||||
"""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("codex")
|
||||
m = IntegrationManifest("codex", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = CodexIntegration._inject_hook_command_note(content)
|
||||
twice = CodexIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_hook_note_when_instruction_is_final_line_without_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the file
|
||||
ends without a trailing newline and the preceding line is not blank.
|
||||
"""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
# No blank line before the instruction and no trailing newline:
|
||||
# this is the case where the captured ``eol`` is empty and the
|
||||
# captured indent is also empty, so a missing line separator would
|
||||
# cause the note and instruction to collapse onto one line.
|
||||
content = (
|
||||
"---\nname: test\n---\n"
|
||||
"Body line\n"
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line_idx = next(
|
||||
i for i, l in enumerate(lines) if "replace dots" in l
|
||||
)
|
||||
instruction_line_idx = next(
|
||||
i for i, l in enumerate(lines)
|
||||
if l.lstrip().startswith("- For each executable hook")
|
||||
)
|
||||
assert note_line_idx < instruction_line_idx, (
|
||||
"Note must appear before the instruction"
|
||||
)
|
||||
assert "For each executable hook" not in lines[note_line_idx], (
|
||||
"Note and instruction must not be on the same line"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,13 @@ Covers issue https://github.com/github/spec-kit/issues/550:
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from specify_cli import check_tool
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app, check_tool
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestCheckToolClaude:
|
||||
@@ -103,4 +109,32 @@ 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
|
||||
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()
|
||||
|
||||
@@ -1846,7 +1846,7 @@ Run {SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
|
||||
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
|
||||
@@ -2580,7 +2580,8 @@ class TestExtensionCatalog:
|
||||
def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch):
|
||||
"""download_extension passes Authorization header when a provider is configured."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import zipfile, io
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
@@ -2854,6 +2855,110 @@ class TestCatalogStack:
|
||||
assert len(entries) == 1
|
||||
assert entries[0].url == "http://localhost:8000/catalog.json"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_content", ["[]\n", "false\n", "0\n", "''\n", "- item\n"]
|
||||
)
|
||||
def test_load_catalog_config_rejects_non_mapping_roots(
|
||||
self, temp_dir, config_content
|
||||
):
|
||||
"""Malformed roots raise ValidationError, not fallback or AttributeError."""
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="expected a YAML mapping at the root"
|
||||
) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert str(config_path) in str(exc_info.value)
|
||||
|
||||
def test_load_catalog_config_rejects_boolean_priority(self, temp_dir):
|
||||
"""Boolean priorities are rejected instead of being coerced to 1 or 0."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"name": "bad-priority",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="Invalid priority|expected integer"
|
||||
) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
assert str(config_path) in str(exc_info.value)
|
||||
|
||||
def test_load_catalog_config_defaults_blank_names(self, temp_dir):
|
||||
"""Blank and null names normalize by valid catalog order."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"name": "skipped", "url": " "},
|
||||
{"name": None, "url": "https://one.example.com/catalog.json"},
|
||||
{"name": " ", "url": "https://two.example.com/catalog.json"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
assert [entry.name for entry in catalog.get_active_catalogs()] == [
|
||||
"catalog-1",
|
||||
"catalog-2",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_detail"),
|
||||
[
|
||||
("relative/catalog.json", "HTTPS"),
|
||||
("https:///no-host", "valid URL with a host"),
|
||||
],
|
||||
)
|
||||
def test_load_catalog_config_invalid_url_includes_context(
|
||||
self, temp_dir, url, expected_detail
|
||||
):
|
||||
"""Invalid catalog URLs include the config path and entry index."""
|
||||
import yaml as yaml_module
|
||||
|
||||
project_dir = self._make_project(temp_dir)
|
||||
config_path = project_dir / ".specify" / "extension-catalogs.yml"
|
||||
config_path.write_text(
|
||||
yaml_module.dump({"catalogs": [{"name": "bad", "url": url}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
catalog.get_active_catalogs()
|
||||
message = str(exc_info.value)
|
||||
assert "Invalid catalog URL" in message
|
||||
assert str(config_path) in message
|
||||
assert "index 0" in message
|
||||
assert expected_detail in message
|
||||
|
||||
# --- Merge conflict resolution ---
|
||||
|
||||
def test_merge_conflict_higher_priority_wins(self, temp_dir):
|
||||
|
||||
@@ -1830,6 +1830,31 @@ 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"
|
||||
|
||||
@@ -463,6 +463,7 @@ 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
|
||||
|
||||
@@ -474,7 +475,8 @@ class TestCommandStep:
|
||||
"integration": "gemini",
|
||||
"input": {},
|
||||
}
|
||||
result = step.execute(config, ctx)
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["integration"] == "gemini"
|
||||
|
||||
def test_step_override_model(self):
|
||||
@@ -626,6 +628,7 @@ 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
|
||||
|
||||
@@ -637,10 +640,12 @@ class TestPromptStep:
|
||||
"prompt": "Summarize the codebase",
|
||||
"integration": "gemini",
|
||||
}
|
||||
result = step.execute(config, ctx)
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
|
||||
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
|
||||
|
||||
@@ -652,7 +657,8 @@ class TestPromptStep:
|
||||
"prompt": "hello",
|
||||
"model": "opus-4",
|
||||
}
|
||||
result = step.execute(config, ctx)
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None):
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["model"] == "opus-4"
|
||||
|
||||
def test_dispatch_with_mock_cli(self, tmp_path):
|
||||
@@ -1495,6 +1501,656 @@ 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,9 +7,23 @@ workflow:
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.7.2"
|
||||
# 0.8.5 is the first release with engine-side resolution of the
|
||||
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||
# as a literal integration key and fail at dispatch.
|
||||
speckit_version: ">=0.8.5"
|
||||
integrations:
|
||||
any: ["copilot", "claude", "gemini"]
|
||||
# The four commands below (specify, plan, tasks, implement) are core
|
||||
# spec-kit commands provided by every integration. The list here is an
|
||||
# advisory, non-exhaustive compatibility hint following the documented
|
||||
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||
# against any integration the project was initialized with, including
|
||||
# ones not listed below, as long as that integration provides the four
|
||||
# core commands referenced in ``steps``.
|
||||
any:
|
||||
- "claude"
|
||||
- "copilot"
|
||||
- "gemini"
|
||||
- "opencode"
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
@@ -18,8 +32,8 @@ inputs:
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "copilot"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
||||
default: "auto"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
|
||||
Reference in New Issue
Block a user