mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
81 Commits
v0.8.10
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14eb384381 | ||
|
|
089feca75f | ||
|
|
3617cd9c02 | ||
|
|
50da3a0f77 | ||
|
|
cd8a39f50e | ||
|
|
e53cb2c143 | ||
|
|
cc3d828227 | ||
|
|
b4e5a1c3be | ||
|
|
11bd31935f | ||
|
|
a130b7e8d1 | ||
|
|
5372dcbdea | ||
|
|
b48b22379e | ||
|
|
3f096ffcfc | ||
|
|
f50839a928 | ||
|
|
ae96f97035 | ||
|
|
ad62357015 | ||
|
|
57a518a583 | ||
|
|
db81a719a4 | ||
|
|
6d25d869b3 | ||
|
|
9307093d8a | ||
|
|
5a678c552e | ||
|
|
5a50b75adb | ||
|
|
0a8f31ef18 | ||
|
|
cec63d34e3 | ||
|
|
b58a121771 | ||
|
|
c6afe4cde1 | ||
|
|
66884db85b | ||
|
|
9af5411b4e | ||
|
|
3227b9660e | ||
|
|
d116ce2b0a | ||
|
|
eb11dd2d64 | ||
|
|
9816f902ca | ||
|
|
3cb7027fab | ||
|
|
7556fc7fe7 | ||
|
|
98b8bb6eb7 | ||
|
|
7a7843b68b | ||
|
|
7e9d470144 | ||
|
|
e54653efcc | ||
|
|
c7e0cacaff | ||
|
|
0f9beabca7 | ||
|
|
69b9348776 | ||
|
|
c47f334629 | ||
|
|
0ae451f697 | ||
|
|
7f33dca87c | ||
|
|
e2ad589433 | ||
|
|
dca81b90de | ||
|
|
a08af08415 | ||
|
|
2dc79a7e06 | ||
|
|
3b024f9357 | ||
|
|
d6a6dcf59a | ||
|
|
e42ce8b759 | ||
|
|
616eba6a57 | ||
|
|
1bf4a6eb35 | ||
|
|
0dee2faf11 | ||
|
|
7fda89decb | ||
|
|
0964f113b7 | ||
|
|
b4b83be51b | ||
|
|
3d50f85875 | ||
|
|
0b9bd90021 | ||
|
|
bae355a234 | ||
|
|
9735145289 | ||
|
|
68a031c768 | ||
|
|
a59381ae30 | ||
|
|
975498e11d | ||
|
|
51e6a140e2 | ||
|
|
81e9ecd4d9 | ||
|
|
409ec59704 | ||
|
|
b36c34f171 | ||
|
|
8bd20a2f5f | ||
|
|
4c610a20dc | ||
|
|
27700387b6 | ||
|
|
d947fda96f | ||
|
|
13c167e107 | ||
|
|
f684305e51 | ||
|
|
b774282058 | ||
|
|
6322a4d429 | ||
|
|
be382804c7 | ||
|
|
c87081a50a | ||
|
|
e6afba9429 | ||
|
|
c1a1653aca | ||
|
|
0e5b59fcaa |
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
|
||||
|
||||
1577
.github/workflows/add-community-extension.lock.yml
generated
vendored
Normal file
1577
.github/workflows/add-community-extension.lock.yml
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
285
.github/workflows/add-community-extension.md
vendored
Normal file
285
.github/workflows/add-community-extension.md
vendored
Normal file
@@ -0,0 +1,285 @@
|
||||
---
|
||||
description: "Process community extension submission issues — validate, add to catalog, and open a PR for maintainer review"
|
||||
emoji: "🧩"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [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:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
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 only triggers when the `extension-submission` label is added to an
|
||||
issue. Before processing, verify that the issue title starts with `[Extension]:`.
|
||||
If it does not, stop without commenting.
|
||||
|
||||
## 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`
|
||||
1577
.github/workflows/add-community-preset.lock.yml
generated
vendored
Normal file
1577
.github/workflows/add-community-preset.lock.yml
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
279
.github/workflows/add-community-preset.md
vendored
Normal file
279
.github/workflows/add-community-preset.md
vendored
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
description: "Process community preset submission issues — validate, add to catalog, and open a PR for maintainer review"
|
||||
emoji: "🎨"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [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:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
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 only triggers when the `preset-submission` label is added to an
|
||||
issue. Before processing, verify that the issue title starts with `[Preset]:`.
|
||||
If it does not, stop without commenting.
|
||||
|
||||
## Step 1 — Read and Parse the Issue
|
||||
|
||||
Read issue #${{ github.event.issue.number }}.
|
||||
|
||||
Extract the following fields from the structured issue body (GitHub issue form
|
||||
fields):
|
||||
|
||||
| Field | Issue Form ID | Required |
|
||||
|-------|--------------|----------|
|
||||
| Preset ID | `preset-id` | Yes |
|
||||
| Preset Name | `preset-name` | Yes |
|
||||
| Version | `version` | Yes |
|
||||
| Description | `description` | Yes |
|
||||
| Author | `author` | Yes |
|
||||
| Repository URL | `repository` | Yes |
|
||||
| Download URL | `download-url` | Yes |
|
||||
| License | `license` | Yes |
|
||||
| Required Spec Kit Version | `speckit-version` | Yes |
|
||||
| Required Extensions | `required-extensions` | No |
|
||||
| Templates Provided | `templates-provided` | Yes |
|
||||
| Commands Provided | `commands-provided` | Yes |
|
||||
| Number of Scripts | `scripts-count` | No (default 0) |
|
||||
| Tags | `tags` | Yes |
|
||||
|
||||
The issue body uses GitHub's issue form format. Each field appears under a
|
||||
heading matching the field label (e.g., `### Preset ID` followed by the
|
||||
value). Parse accordingly.
|
||||
|
||||
## Step 2 — Validate the Submission
|
||||
|
||||
Run **all** of the following validation checks. Collect all results before
|
||||
deciding pass/fail:
|
||||
|
||||
### 2a. Preset ID format
|
||||
- Must match regex: `^[a-z][a-z0-9-]*$`
|
||||
- Must be lowercase with hyphens only
|
||||
|
||||
### 2b. Version format
|
||||
- Must follow semver: `X.Y.Z` (digits only, no `v` prefix)
|
||||
|
||||
### 2c. Repository validation
|
||||
- Fetch the repository URL — confirm it exists and is publicly accessible
|
||||
- Confirm the repository contains a `preset.yml` file
|
||||
- Confirm the repository contains a `README.md` file
|
||||
- Confirm the repository contains a `LICENSE` file
|
||||
|
||||
### 2d. Release and download URL validation
|
||||
- The download URL should follow the pattern
|
||||
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
|
||||
or
|
||||
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
|
||||
- Verify a GitHub release exists matching the submitted version
|
||||
|
||||
### 2e. Submission checklists
|
||||
- Confirm that all required checkboxes in the Testing Checklist and Submission
|
||||
Requirements sections are checked (`[x]`)
|
||||
|
||||
### Validation outcome
|
||||
|
||||
If **any** validation fails:
|
||||
1. Add a comment on the issue listing each failed check with a clear explanation
|
||||
of what's wrong and how to fix it
|
||||
2. Add the `validation-failed` label
|
||||
3. **Stop — do not proceed further**
|
||||
|
||||
If all validations pass:
|
||||
1. Add the `validation-passed` label
|
||||
2. Continue to Step 3
|
||||
|
||||
## Step 3 — Determine Add vs Update
|
||||
|
||||
Search `presets/catalog.community.json` for the preset ID.
|
||||
|
||||
- **Not found** → this is a **new addition**
|
||||
- **Found** → this is an **update** — replace the existing entry in-place;
|
||||
preserve `created_at` from the existing entry
|
||||
|
||||
## Step 4 — Update `presets/catalog.community.json`
|
||||
|
||||
Edit `presets/catalog.community.json` to add or update the preset entry.
|
||||
|
||||
### For a new preset
|
||||
|
||||
Insert the entry in **alphabetical order by preset ID** within the
|
||||
`"presets"` object. Use this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"<id>": {
|
||||
"name": "<name>",
|
||||
"id": "<id>",
|
||||
"version": "<version>",
|
||||
"description": "<description>",
|
||||
"author": "<author>",
|
||||
"repository": "<repository>",
|
||||
"download_url": "<download_url>",
|
||||
"homepage": "<homepage or repository>",
|
||||
"documentation": "<documentation or repository README>",
|
||||
"license": "<license>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>"
|
||||
},
|
||||
"provides": {
|
||||
"templates": <N>,
|
||||
"commands": <N>
|
||||
},
|
||||
"tags": ["<tag1>", "<tag2>"],
|
||||
"created_at": "<today>T00:00:00Z",
|
||||
"updated_at": "<today>T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the preset has required extensions, add an `"extensions"` array inside
|
||||
`"requires"`:
|
||||
|
||||
```json
|
||||
"requires": {
|
||||
"speckit_version": "<speckit_version>",
|
||||
"extensions": ["<extension-id>"]
|
||||
}
|
||||
```
|
||||
|
||||
If the preset provides scripts, add `"scripts": <N>` inside `"provides"`.
|
||||
|
||||
### For an update
|
||||
|
||||
Replace only the changed fields (typically `version`, `download_url`,
|
||||
`description`, `provides`, `requires`, `tags`, `updated_at`). **Preserve**
|
||||
`created_at` from the existing entry.
|
||||
|
||||
### Counting templates and commands
|
||||
|
||||
Parse the "Templates Provided" and "Commands Provided" issue fields:
|
||||
- Count the number of list items (lines starting with `-`)
|
||||
- If the field says "None", the count is 0
|
||||
|
||||
### After editing
|
||||
|
||||
Update the **top-level `"updated_at"` timestamp** in the catalog to today's date
|
||||
in ISO 8601 format.
|
||||
|
||||
Validate the JSON by running:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('presets/catalog.community.json')); print('Valid JSON')"
|
||||
```
|
||||
|
||||
If validation fails, fix the JSON and re-validate before continuing.
|
||||
|
||||
## Step 5 — Update `docs/community/presets.md`
|
||||
|
||||
Edit `docs/community/presets.md` to add or update a row in the Community
|
||||
Presets table.
|
||||
|
||||
### For a new preset
|
||||
|
||||
Insert a new row in **alphabetical order by preset name**:
|
||||
|
||||
```
|
||||
| <Name> | <Description> | <N> templates, <N> commands | <Requires> | [<repo-name>](<repository-url>) |
|
||||
```
|
||||
|
||||
For the Requires column:
|
||||
- Use `—` if no extensions are required
|
||||
- List required extension names if any (e.g., `AIDE extension`)
|
||||
|
||||
If the preset provides scripts, include them: `<N> templates, <N> commands, <N> scripts`
|
||||
|
||||
### For an update
|
||||
|
||||
Find the existing row and update any changed fields in-place.
|
||||
|
||||
## Step 6 — Create Pull Request
|
||||
|
||||
Create a pull request with the changes. Use this branch naming convention:
|
||||
|
||||
- **New preset:** `add-<preset-id>-preset`
|
||||
- **Update:** `update-<preset-id>-preset`
|
||||
|
||||
### Commit message
|
||||
|
||||
For a new preset:
|
||||
```
|
||||
Add <Name> preset to community catalog
|
||||
|
||||
Add <id> preset submitted by @<issue-author> to:
|
||||
- presets/catalog.community.json (alphabetical order)
|
||||
- docs/community/presets.md community presets table
|
||||
|
||||
Closes #<issue-number>
|
||||
```
|
||||
|
||||
For an update:
|
||||
```
|
||||
Update <Name> preset to v<version>
|
||||
|
||||
Update <id> preset submitted by @<issue-author>:
|
||||
- presets/catalog.community.json (version, download_url, etc.)
|
||||
- docs/community/presets.md community presets table
|
||||
|
||||
Closes #<issue-number>
|
||||
```
|
||||
|
||||
### PR description
|
||||
|
||||
Include:
|
||||
- A summary of what changed
|
||||
- Validation results (all checks passed)
|
||||
- `Closes #${{ github.event.issue.number }}`
|
||||
- `cc @<issue-author>` — mention the submitter
|
||||
|
||||
## Important Rules
|
||||
|
||||
- **Alphabetical order matters** — entries must be sorted by ID in the JSON and
|
||||
by name in the docs table
|
||||
- **Always validate JSON** after editing — a trailing comma or missing brace
|
||||
will break the catalog
|
||||
- **Use `Closes` not `Fixes`** — `Closes #N` is the correct keyword for
|
||||
submission issues
|
||||
- **Preserve `created_at` on updates** — keep the original value; only update
|
||||
`updated_at`
|
||||
- **Do not modify any other files** — only `presets/catalog.community.json`
|
||||
and `docs/community/presets.md`
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
|
||||
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
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
|
||||
with:
|
||||
# Days of inactivity before an issue or PR becomes stale
|
||||
days-before-stale: 150
|
||||
|
||||
48
AGENTS.md
48
AGENTS.md
@@ -177,7 +177,24 @@ def _register_builtins() -> None:
|
||||
|
||||
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
|
||||
|
||||
Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code.
|
||||
The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
|
||||
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
|
||||
|
||||
Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||
|
||||
Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.
|
||||
|
||||
### 5. Test it
|
||||
|
||||
@@ -379,10 +396,37 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
|
||||
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
|
||||
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there
|
||||
|
||||
## Branch Naming Convention
|
||||
|
||||
Branches follow one of two patterns depending on whether an issue exists:
|
||||
|
||||
```
|
||||
<type>/<number>-<short-slug> # when an issue is created first
|
||||
<type>/<short-slug> # when no issue exists (PR-only changes)
|
||||
```
|
||||
|
||||
When an issue exists, include its number immediately after the prefix — this is what makes branches traceable. For small or self-contained changes that go straight to a PR without a tracking issue, omit the number.
|
||||
|
||||
| Prefix | When to use | Example |
|
||||
|---|---|---|
|
||||
| `feat/` | New features | `feat/2342-workflow-cli-alignment` |
|
||||
| `fix/` | Bug fixes | `fix/2653-paths-only-validation` |
|
||||
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention`, `docs/update-landing-stats` |
|
||||
| `community/` | Community catalog additions | `community/2492-add-mde-extension` |
|
||||
| `chore/` | Maintenance, tooling, CI | `chore/2366-editorconfig` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Include the issue number when one exists — this is what makes branches traceable
|
||||
2. Use kebab-case for the slug
|
||||
3. Keep the slug short — enough to identify the work without looking up the issue
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
|
||||
2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated.
|
||||
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
|
||||
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
|
||||
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
|
||||
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.
|
||||
|
||||
130
CHANGELOG.md
130
CHANGELOG.md
@@ -2,6 +2,136 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.18] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Add support for SPECKIT_WORKFLOW_RUN_ID override (#2742)
|
||||
- feat: support SPECKIT_INTEGRATION_<KEY>_EXECUTABLE env var (#2743)
|
||||
- chore(deps): bump github/gh-aw-actions from 0.74.8 to 0.77.0 (#2754)
|
||||
- chore(deps): bump github/codeql-action from 4.35.5 to 4.36.0 (#2753)
|
||||
- fix: disable no-op issue reporting for catalog submission workflows (#2748)
|
||||
- Add confirmation prompt for URL-based extension installs (#2745)
|
||||
- fix: restrict community submission workflows to labeled event only (#2741)
|
||||
- feat(integrations): support SPECIFY_<KEY>_EXTRA_ARGS env var for agent subprocess flags (#2596)
|
||||
- chore: release 0.8.17, begin 0.8.18.dev0 development (#2737)
|
||||
|
||||
## [0.8.17] - 2026-05-28
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: consolidate Community sections in README (#2736)
|
||||
- Fix shared script command hints for integration separators (#2627)
|
||||
- docs: update security-governance preset to v0.4.0 (#2703)
|
||||
- feat(agy): enhance Google Antigravity CLI integration (#2689)
|
||||
- Fix --dev extension agent symlinks (#2554)
|
||||
- Share skills hook note post-processing (#2679)
|
||||
- feat: add Hermes Agent integration (with review fixes) (#2651)
|
||||
- Update Superpowers Implementation Bridge to v0.7.0 (#2732)
|
||||
- chore: release 0.8.16, begin 0.8.17.dev0 development (#2729)
|
||||
|
||||
## [0.8.16] - 2026-05-27
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: update landing page stats and branch naming convention (#2727)
|
||||
- feat(workflows): expose {{ context.run_id }} template variable (#2664)
|
||||
- fix: resolve __SPECKIT_COMMAND_*__ refs in preset skill rendering (#2717) (#2718)
|
||||
- Add Workflow Preset to community catalog (#2725)
|
||||
- fix: paths-only skips branch validation, setup-plan preserves existing plan (#2672)
|
||||
- docs: fix broken pipx homepage URLs to point to pipx.pypa.io (#2670)
|
||||
- Update Architecture Guard extension to v1.8.9 (#2723)
|
||||
- Re-validate spec quality checklist after clarify updates spec (#2715)
|
||||
- chore: release 0.8.15, begin 0.8.16.dev0 development (#2722)
|
||||
|
||||
## [0.8.15] - 2026-05-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Fiction Book Writing preset to v1.8.1 (#2714)
|
||||
- chore: update memorylint and superb to 1.4.0 (#2690)
|
||||
- fix: promote post-execution hook dispatch to H2 with directive language (#2713)
|
||||
- Add Token Budget extension to community catalog (#2712)
|
||||
- fix: create skills directory on demand during extension/preset install (#2711)
|
||||
- fix: PS 5.1 compat — replace non-ASCII chars in shipped PowerShell scripts (#2709)
|
||||
- docs: update security-governance preset to v0.3.0 (#2676)
|
||||
- Update README.md (#2675)
|
||||
- chore: release 0.8.14, begin 0.8.15.dev0 development (#2706)
|
||||
|
||||
## [0.8.14] - 2026-05-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Add util for windows sub-process (#2598)
|
||||
- refactor: create commands/ package and move init handler (PR-4/8) (#2615)
|
||||
- Add Product Spec Extension to community catalog (#2705)
|
||||
- fix init-options speckit version refresh (#2647)
|
||||
- chore(deps): bump github/gh-aw-actions from 0.74.8 to 0.74.9 (#2658)
|
||||
- docs: add branch naming convention to AGENTS.md and CONTRIBUTING.md (#2678)
|
||||
- chore(deps): bump actions/stale from 10.2.0 to 10.3.0 (#2657)
|
||||
- chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#2656)
|
||||
- chore: release 0.8.13, begin 0.8.14.dev0 development (#2669)
|
||||
|
||||
## [0.8.13] - 2026-05-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: while/do-while loop condition reads stale iteration-0 step output (#2662)
|
||||
- docs: fix directory hierarchy in README examples (#2639)
|
||||
- fix(catalogs): reject boolean priority in extension and preset catalog readers (#2589)
|
||||
- Update Agent Governance extension to v1.2.0 (#2659)
|
||||
- Add agentic workflows for community catalog submissions (#2655)
|
||||
- feat: add self-check tip to check output (#2574)
|
||||
- fix(cli): clarify exception diagnostics (#2602)
|
||||
- ci: add diff whitespace check (#2572)
|
||||
- chore: release 0.8.12, begin 0.8.13.dev0 development (#2648)
|
||||
|
||||
## [0.8.12] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(codex): inject dot-to-hyphen hook command note in Codex skills (#2503)
|
||||
- Update Squad Bridge extension to v1.3.0 (#2645)
|
||||
- Update Superpowers Implementation Bridge extension to v0.5.0 (#2644)
|
||||
- Add Team Assign extension to community catalog (#2642)
|
||||
- refactor: migrate extension catalog stack parsing to shared base (#2576)
|
||||
- Update Architecture Workflow extension to v1.1.0 (#2588)
|
||||
- fix(workflow): support integration: auto to follow project's initialized AI (#2421)
|
||||
- Add Superpowers Implementation Bridge extension to community catalog (#2586)
|
||||
- Add Interactive HTML Preview extension to community catalog (#2585)
|
||||
- chore: release 0.8.11, begin 0.8.12.dev0 development (#2584)
|
||||
- Update Agent Governance extension to v1.1.0 (#2583)
|
||||
|
||||
## [0.8.11] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
|
||||
- refactor: extract _version.py from __init__.py (PR-3/8) (#2550)
|
||||
- Add Time Machine extension to community catalog (#2580)
|
||||
- fix(powershell): ensure UTF-8 templates are written without BOM (#2280)
|
||||
- docs: document high-assurance spec workflow (#2518)
|
||||
- docs: fix script name in directory tree examples (#2555)
|
||||
- Fix preset skill description precedence (#2538)
|
||||
- fix(integration): clarify multi-install guidance (#2549)
|
||||
- feat: add version feature reporting (#2548)
|
||||
- Add Architecture Workflow extension to community catalog (#2565)
|
||||
- chore: release 0.8.10, begin 0.8.11.dev0 development (#2562)
|
||||
|
||||
## [0.8.10] - 2026-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: streamline install section and add community overview (#2561)
|
||||
- Move community extensions table from README to docs site (#2560)
|
||||
- Add Agent Governance extension to community catalog (#2559)
|
||||
- Add Reqnroll BDD extension to community catalog (#2545)
|
||||
- fix(cli): harden extension registration and discovery workflows (#2499)
|
||||
- refactor: extract _assets.py and _utils.py from __init__.py (PR-2/8) (#2543)
|
||||
- fix(opencode): use commands/ directory (plural) to match OpenCode docs (#2453)
|
||||
- refactor: extract _console.py from __init__.py (PR-1/8) (#2474)
|
||||
- Fix constitution reference in README (#2491)
|
||||
- chore: release 0.8.9, begin 0.8.10.dev0 development (#2532)
|
||||
|
||||
## [0.8.9] - 2026-05-12
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -38,7 +38,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler
|
||||
1. Fork and clone the repository
|
||||
1. Configure and install the dependencies: `uv sync --extra test`
|
||||
1. Make sure the CLI works on your machine: `uv run specify --help`
|
||||
1. Create a new branch: `git checkout -b my-branch-name`
|
||||
1. Create a new branch: `git checkout -b <type>/<number>-<short-slug>` (see [Branch naming](#branch-naming) below)
|
||||
1. Make your change, add tests, and make sure everything still works
|
||||
1. Test the CLI functionality with a sample project if relevant
|
||||
1. Push to your fork and submit a pull request
|
||||
@@ -55,6 +55,20 @@ Here are a few things you can do that will increase the likelihood of your pull
|
||||
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
|
||||
|
||||
### Branch naming
|
||||
|
||||
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
|
||||
|
||||
| Prefix | When to use | Example |
|
||||
|---|---|---|
|
||||
| `feat/` | New features | `feat/2342-workflow-cli-alignment` |
|
||||
| `fix/` | Bug fixes | `fix/2653-paths-only-validation` |
|
||||
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention` |
|
||||
| `community/` | Community catalog additions | `community/2492-add-mde-extension` |
|
||||
| `chore/` | Maintenance, tooling, CI | `chore/2366-editorconfig` |
|
||||
|
||||
Including the issue or PR number makes branches traceable — especially useful since the project uses squash merges and `git branch --merged` won't detect merged branches. If you start with a PR (no issue), use the PR number once it's assigned.
|
||||
|
||||
## Development workflow
|
||||
|
||||
When working on spec-kit:
|
||||
|
||||
125
README.md
125
README.md
@@ -22,10 +22,7 @@
|
||||
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
|
||||
- [⚡ Get Started](#-get-started)
|
||||
- [📽️ Video Overview](#️-video-overview)
|
||||
- [🧩 Community Extensions](#-community-extensions)
|
||||
- [🎨 Community Presets](#-community-presets)
|
||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
||||
- [🛠️ Community Friends](#️-community-friends)
|
||||
- [🌍 Community](#-community)
|
||||
- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations)
|
||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
||||
@@ -35,7 +32,7 @@
|
||||
- [🔧 Prerequisites](#-prerequisites)
|
||||
- [📖 Learn More](#-learn-more)
|
||||
- [📋 Detailed Process](#-detailed-process)
|
||||
- [ Support](#-support)
|
||||
- [💬 Support](#-support)
|
||||
- [🙏 Acknowledgements](#-acknowledgements)
|
||||
- [📄 License](#-license)
|
||||
|
||||
@@ -112,31 +109,19 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
|
||||
## 🧩 Community Extensions
|
||||
## 🌍 Community
|
||||
|
||||
Community-contributed extensions add new commands, hooks, and capabilities to Spec Kit. See the full list on the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page.
|
||||
Explore community-contributed resources on the [Spec Kit docs site](https://github.github.io/spec-kit/):
|
||||
|
||||
- [Extensions](https://github.github.io/spec-kit/community/extensions.html) — commands, hooks, and capabilities
|
||||
- [Presets](https://github.github.io/spec-kit/community/presets.html) — template and terminology overrides
|
||||
- [Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) — end-to-end SDD scenarios
|
||||
- [Friends](https://github.github.io/spec-kit/community/friends.html) — projects that extend or build on Spec Kit
|
||||
|
||||
> [!NOTE]
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
|
||||
> Community contributions are independently created and maintained by their respective authors. Review source code before installation and use at your own discretion.
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
|
||||
## 🎨 Community Presets
|
||||
|
||||
Community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page.
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets are third-party contributions and are not maintained by the Spec Kit team. Review them carefully before use, and see the docs page above for the full disclaimer.
|
||||
|
||||
To submit your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
|
||||
## 🚶 Community Walkthroughs
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page.
|
||||
|
||||
## 🛠️ Community Friends
|
||||
|
||||
Community projects that extend, visualize, or build on Spec Kit. See the full list on the [Community Friends](https://github.github.io/spec-kit/community/friends.html) page.
|
||||
Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
|
||||
## 🤖 Supported AI Coding Agent Integrations
|
||||
|
||||
@@ -206,7 +191,7 @@ specify extension add <extension-name>
|
||||
|
||||
For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.
|
||||
|
||||
See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available.
|
||||
See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](https://github.github.io/spec-kit/community/extensions.html) for what's available.
|
||||
|
||||
### Presets — Customize Existing Workflows
|
||||
|
||||
@@ -281,7 +266,7 @@ Our research and experimentation focus on:
|
||||
|
||||
- **Linux/macOS/Windows**
|
||||
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
@@ -400,22 +385,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
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ └── spec.md
|
||||
└── templates
|
||||
├── plan-template.md
|
||||
├── spec-template.md
|
||||
└── tasks-template.md
|
||||
.
|
||||
├── .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)
|
||||
@@ -462,29 +449,31 @@ The output of this step will include a number of implementation detail documents
|
||||
```text
|
||||
.
|
||||
├── CLAUDE.md
|
||||
├── memory
|
||||
│ └── constitution.md
|
||||
├── scripts
|
||||
│ ├── check-prerequisites.sh
|
||||
│ ├── common.sh
|
||||
│ ├── create-new-feature.sh
|
||||
│ ├── setup-plan.sh
|
||||
│ └── update-claude-md.sh
|
||||
├── specs
|
||||
│ └── 001-create-taskify
|
||||
│ ├── contracts
|
||||
│ │ ├── api-spec.json
|
||||
│ │ └── signalr-spec.md
|
||||
│ ├── data-model.md
|
||||
│ ├── plan.md
|
||||
│ ├── quickstart.md
|
||||
│ ├── research.md
|
||||
│ └── spec.md
|
||||
└── templates
|
||||
├── CLAUDE-template.md
|
||||
├── plan-template.md
|
||||
├── spec-template.md
|
||||
└── tasks-template.md
|
||||
├── .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).
|
||||
@@ -577,7 +566,7 @@ Once the implementation is complete, test the application and resolve any runtim
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
## 💬 Support
|
||||
|
||||
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.
|
||||
|
||||
|
||||
@@ -23,11 +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 Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
@@ -50,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) |
|
||||
@@ -76,6 +78,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
@@ -104,12 +107,16 @@ 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 Budget | Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage | `process` | Read+Write | [spec-kit-token-budget](https://github.com/tinesoft/spec-kit-token-budget) |
|
||||
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
|
||||
@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
@@ -23,9 +23,10 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds secure development governance: memory-safe-language preference, language-specific secure-coding profiles, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/AI-SBOM, VEX/SLSA, OpenSSF Scorecard, G7/BSI AI-SBOM target evidence, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 23 templates, 7 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).
|
||||
|
||||
@@ -43,7 +43,7 @@ Run `specify init` with your agent of choice and Spec Kit sets up the right comm
|
||||
|
||||
### Make it your own
|
||||
|
||||
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
|
||||
<span class="pillar-stat">105 community extensions</span> (60+ authors), <span class="pillar-stat">22 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
|
||||
|
||||
Including entirely different SDD processes:
|
||||
|
||||
@@ -82,7 +82,7 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">96K+</span>
|
||||
<span class="stat-number">106K+</span>
|
||||
<span class="stat-label">GitHub stars</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
@@ -94,11 +94,11 @@ Community extensions like CI Guard and Architecture Guard add compliance gates a
|
||||
<span class="stat-label">Integrations</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">91</span>
|
||||
<span class="stat-number">105</span>
|
||||
<span class="stat-label">Extensions</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">18</span>
|
||||
<span class="stat-number">22</span>
|
||||
<span class="stat-label">Presets</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
@@ -150,3 +150,5 @@ specify init my-project --integration copilot
|
||||
Ready to start? Follow the [Quick Start Guide](quickstart.md).
|
||||
|
||||
</div>
|
||||
|
||||
<p class="text-end small text-body-secondary">Last updated: May 27, 2026</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installing with pipx
|
||||
|
||||
[pipx](https://pypa.github.io/pipx/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
|
||||
[pipx](https://pipx.pypa.io/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
## Install Specify CLI
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
|
||||
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
|
||||
- [Python 3.11+](https://www.python.org/downloads/)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
@@ -94,7 +94,10 @@ After initialization, you should see the following commands available in your co
|
||||
- `/speckit.plan` - Generate implementation plans
|
||||
- `/speckit.tasks` - Break down into actionable tasks
|
||||
|
||||
The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.
|
||||
Scripts are installed into a variant subdirectory matching the chosen script type:
|
||||
|
||||
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
|
||||
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -5,11 +5,19 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
|
||||
> [!NOTE]
|
||||
> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`.
|
||||
|
||||
## The 6-Step Process
|
||||
## Recommended Workflow
|
||||
|
||||
> [!TIP]
|
||||
> **Context Awareness**: Spec Kit commands automatically detect the active feature based on your current Git branch (e.g., `001-feature-name`). To switch between different specifications, simply switch Git branches.
|
||||
|
||||
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
|
||||
|
||||
```text
|
||||
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
|
||||
```
|
||||
|
||||
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
|
||||
|
||||
### Step 1: Install Specify
|
||||
|
||||
**In your terminal**, run the `specify` CLI command to initialize your project:
|
||||
@@ -24,10 +32,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init .
|
||||
|
||||
> [!NOTE]
|
||||
> You can also install the CLI persistently with `pipx`:
|
||||
>
|
||||
> ```bash
|
||||
> pipx install git+https://github.com/github/spec-kit.git
|
||||
> ```
|
||||
>
|
||||
> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example:
|
||||
>
|
||||
> ```bash
|
||||
> specify init <PROJECT_NAME>
|
||||
> specify init .
|
||||
@@ -56,7 +67,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
|
||||
```
|
||||
|
||||
### Step 4: Refine the Spec
|
||||
### Step 4: Refine and Validate the Spec
|
||||
|
||||
**In the chat**, use the `/speckit.clarify` slash command to identify and resolve ambiguities in your specification. You can provide specific focus areas as arguments.
|
||||
|
||||
@@ -64,6 +75,12 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.clarify Focus on security and performance requirements.
|
||||
```
|
||||
|
||||
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
|
||||
|
||||
```bash
|
||||
/speckit.checklist
|
||||
```
|
||||
|
||||
### Step 5: Create a Technical Implementation Plan
|
||||
|
||||
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
|
||||
@@ -72,7 +89,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
|
||||
```
|
||||
|
||||
### Step 6: Break Down and Implement
|
||||
### Step 6: Break Down, Analyze, and Implement
|
||||
|
||||
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
|
||||
|
||||
@@ -80,13 +97,13 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
|
||||
/speckit.tasks
|
||||
```
|
||||
|
||||
Optionally, validate the plan with `/speckit.analyze`:
|
||||
Validate cross-artifact consistency with `/speckit.analyze` before implementation:
|
||||
|
||||
```markdown
|
||||
/speckit.analyze
|
||||
```
|
||||
|
||||
Then, use the `/speckit.implement` slash command to execute the plan.
|
||||
Use the `/speckit.implement` slash command to execute the plan.
|
||||
|
||||
```markdown
|
||||
/speckit.implement
|
||||
@@ -159,7 +176,7 @@ Generate an actionable task list using the `/speckit.tasks` command:
|
||||
|
||||
### Step 7: Validate and Implement
|
||||
|
||||
Have your coding agent audit the implementation plan using `/speckit.analyze`:
|
||||
Have your coding agent audit the spec, plan, and tasks with `/speckit.analyze` before implementation:
|
||||
|
||||
```bash
|
||||
/speckit.analyze
|
||||
@@ -179,7 +196,7 @@ Finally, implement the solution:
|
||||
- **Be explicit** about what you're building and why
|
||||
- **Don't focus on tech stack** during specification phase
|
||||
- **Iterate and refine** your specifications before implementation
|
||||
- **Validate** the plan before coding begins
|
||||
- **Validate** requirements and plans before coding begins
|
||||
- **Let the coding agent handle** the implementation details
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -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
|
||||
@@ -77,6 +79,16 @@ specify version
|
||||
|
||||
Displays the Spec Kit CLI version, Python version, platform, and architecture.
|
||||
|
||||
To inspect local CLI capabilities without checking the network:
|
||||
|
||||
```bash
|
||||
specify version --features
|
||||
specify version --features --json
|
||||
```
|
||||
|
||||
The JSON form is intended for scripts and coding agents that need to choose a
|
||||
workflow based on the installed CLI's supported features.
|
||||
|
||||
A quick version check is also available via:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,7 +76,7 @@ specify extension add <extension-name> --from https://github.com/org/spec-kit-ex
|
||||
|
||||
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
||||
|
||||
See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions.
|
||||
See the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page for the full list of available community-contributed extensions.
|
||||
|
||||
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).
|
||||
|
||||
|
||||
57
extensions/agent-context/README.md
Normal file
57
extensions/agent-context/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Coding Agent Context Extension
|
||||
|
||||
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
|
||||
|
||||
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
|
||||
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
# or target the specific interpreter Spec Kit uses:
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Disable
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||
15
extensions/agent-context/agent-context-config.yml
Normal file
15
extensions/agent-context/agent-context-config.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Coding Agent Context Extension Configuration
|
||||
# These values are populated automatically by `specify init` and
|
||||
# `specify integration use` / `specify integration install`.
|
||||
|
||||
# Path (relative to the project root) to the coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
context_file: ""
|
||||
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||
---
|
||||
|
||||
# Update Coding Agent Context
|
||||
|
||||
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
|
||||
|
||||
## Behavior
|
||||
|
||||
The script reads the agent-context extension config at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
|
||||
|
||||
- `context_file` — the path of the coding agent context file to manage.
|
||||
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||
|
||||
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
|
||||
|
||||
## Execution
|
||||
|
||||
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||
34
extensions/agent-context/extension.yml
Normal file
34
extensions/agent-context/extension.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: agent-context
|
||||
name: "Coding Agent Context"
|
||||
version: "1.0.0"
|
||||
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.2.0"
|
||||
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.agent-context.update
|
||||
file: commands/speckit.agent-context.update.md
|
||||
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||
|
||||
hooks:
|
||||
after_specify:
|
||||
command: speckit.agent-context.update
|
||||
optional: true
|
||||
description: "Refresh agent context after specification"
|
||||
after_plan:
|
||||
command: speckit.agent-context.update
|
||||
optional: true
|
||||
description: "Refresh agent context after planning"
|
||||
|
||||
tags:
|
||||
- "agent"
|
||||
- "context"
|
||||
- "core"
|
||||
200
extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
200
extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-agent-context.sh
|
||||
#
|
||||
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||
#
|
||||
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||
# agent-context extension config:
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.sh [plan_path]
|
||||
#
|
||||
# When `plan_path` is omitted, the script picks the most recently modified
|
||||
# `specs/*/plan.md` if any exist, otherwise emits the section without a
|
||||
# concrete plan path.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
|
||||
DEFAULT_START="<!-- SPECKIT START -->"
|
||||
DEFAULT_END="<!-- SPECKIT END -->"
|
||||
|
||||
if [[ ! -f "$EXT_CONFIG" ]]; then
|
||||
echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Locate a suitable Python interpreter (python3, then python).
|
||||
_python=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_python="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
|
||||
_python="python"
|
||||
fi
|
||||
|
||||
if [[ -z "$_python" ]]; then
|
||||
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse extension config once; emit three newline-separated fields:
|
||||
# context_file, context_markers.start, context_markers.end
|
||||
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
"agent-context: PyYAML is required to parse extension config but is not available "
|
||||
"in the current Python environment.\n"
|
||||
" To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
|
||||
" Context file will not be updated until PyYAML is importable.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
def get_str(obj, *keys):
|
||||
node = obj
|
||||
for k in keys:
|
||||
if isinstance(node, dict) and k in node:
|
||||
node = node[k]
|
||||
else:
|
||||
return ""
|
||||
return node if isinstance(node, str) else ""
|
||||
print(get_str(data, "context_file"))
|
||||
print(get_str(data, "context_markers", "start"))
|
||||
print(get_str(data, "context_markers", "end"))
|
||||
PY
|
||||
)"; then
|
||||
echo "agent-context: skipping update (see above for details)." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_opts_lines=()
|
||||
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
||||
_opts_lines+=("$_line")
|
||||
done < <(printf '%s\n' "$_raw_opts")
|
||||
if (( ${#_opts_lines[@]} < 3 )); then
|
||||
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
CONTEXT_FILE="${_opts_lines[0]}"
|
||||
MARKER_START="${_opts_lines[1]}"
|
||||
MARKER_END="${_opts_lines[2]}"
|
||||
|
||||
if [[ -z "$CONTEXT_FILE" ]]; then
|
||||
echo "agent-context: context_file not set in extension config; nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reject absolute paths, backslash separators, and '..' path segments in context_file
|
||||
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
|
||||
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CONTEXT_FILE" == *\\* ]]; then
|
||||
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
|
||||
for _seg in "${_cf_parts[@]}"; do
|
||||
if [[ "$_seg" == ".." ]]; then
|
||||
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
unset _cf_parts _seg
|
||||
|
||||
[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
|
||||
[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
|
||||
|
||||
PLAN_PATH="${1:-}"
|
||||
if [[ -z "$PLAN_PATH" ]]; then
|
||||
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
|
||||
# Use find + sort by modification time to avoid ls/head fragility with
|
||||
# spaces in paths or SIGPIPE from pipefail.
|
||||
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
|
||||
import sys, os
|
||||
from pathlib import Path
|
||||
specs = Path(sys.argv[1]) / "specs"
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
print(plans[0] if plans else "")
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$_plan_abs" ]]; then
|
||||
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
|
||||
mkdir -p "$(dirname "$CTX_PATH")"
|
||||
|
||||
# Build the managed section
|
||||
TMP_SECTION="$(mktemp)"
|
||||
trap 'rm -f "$TMP_SECTION"' EXIT
|
||||
{
|
||||
echo "$MARKER_START"
|
||||
echo "For additional context about technologies to be used, project structure,"
|
||||
echo "shell commands, and other important information, read the current plan"
|
||||
if [[ -n "$PLAN_PATH" ]]; then
|
||||
echo "at $PLAN_PATH"
|
||||
fi
|
||||
echo "$MARKER_END"
|
||||
} > "$TMP_SECTION"
|
||||
|
||||
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
|
||||
import sys, os
|
||||
ctx_path, start, end, section_path = sys.argv[1:5]
|
||||
with open(section_path, "r", encoding="utf-8") as fh:
|
||||
section = fh.read().rstrip("\n") + "\n"
|
||||
|
||||
if os.path.exists(ctx_path):
|
||||
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
|
||||
content = fh.read()
|
||||
s = content.find(start)
|
||||
e = content.find(end, s if s != -1 else 0)
|
||||
if s != -1 and e != -1 and e > s:
|
||||
end_of_marker = e + len(end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = content[:s] + section + content[end_of_marker:]
|
||||
elif s != -1:
|
||||
new_content = content[:s] + section
|
||||
elif e != -1:
|
||||
end_of_marker = e + len(end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = section + content[end_of_marker:]
|
||||
else:
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_content = (content + "\n" + section) if content else section
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
with open(ctx_path, "wb") as fh:
|
||||
fh.write(new_content.encode("utf-8"))
|
||||
PY
|
||||
|
||||
echo "agent-context: updated $CONTEXT_FILE"
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# update-agent-context.ps1
|
||||
#
|
||||
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||
#
|
||||
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||
# agent-context extension config:
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.ps1 [plan_path]
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanPath
|
||||
)
|
||||
|
||||
function Get-ConfigValue {
|
||||
param(
|
||||
[AllowNull()][object]$Object,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $null
|
||||
}
|
||||
if ($Object -is [System.Collections.IDictionary]) {
|
||||
return $Object[$Key]
|
||||
}
|
||||
$prop = $Object.PSObject.Properties[$Key]
|
||||
if ($prop) {
|
||||
return $prop.Value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-ConfigObject {
|
||||
param(
|
||||
[AllowNull()][object]$Object
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $false
|
||||
}
|
||||
if ($Object -is [System.Collections.IDictionary]) {
|
||||
return $true
|
||||
}
|
||||
if ($Object -is [System.Management.Automation.PSCustomObject]) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$DefaultStart = '<!-- SPECKIT START -->'
|
||||
$DefaultEnd = '<!-- SPECKIT END -->'
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ExtConfig)) {
|
||||
Write-Warning "agent-context: $ExtConfig not found; nothing to do."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$Options = $null
|
||||
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
|
||||
} catch {
|
||||
# fall through to Python fallback
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Options) {
|
||||
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
|
||||
$pythonCmd = $null
|
||||
foreach ($candidate in @('python3', 'python')) {
|
||||
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
|
||||
# Verify it is Python 3
|
||||
$verOut = & $candidate --version 2>&1
|
||||
if ($verOut -match 'Python 3') {
|
||||
$pythonCmd = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($pythonCmd) {
|
||||
try {
|
||||
$jsonOut = & $pythonCmd -c @'
|
||||
import json
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
"agent-context: PyYAML is required to parse extension config; cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
print(json.dumps(data))
|
||||
'@ $ExtConfig
|
||||
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
|
||||
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
} catch {
|
||||
$Options = $null
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Options) {
|
||||
Write-Warning "agent-context: unable to parse $ExtConfig; skipping update."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-ConfigObject -Object $Options)) {
|
||||
Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
|
||||
if (-not $ContextFile) {
|
||||
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Reject absolute paths and '..' path segments in context_file
|
||||
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
|
||||
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
|
||||
exit 1
|
||||
}
|
||||
$cfSegments = $ContextFile -split '[/\\]'
|
||||
if ($cfSegments -contains '..') {
|
||||
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$MarkerStart = $DefaultStart
|
||||
$MarkerEnd = $DefaultEnd
|
||||
$cm = Get-ConfigValue -Object $Options -Key 'context_markers'
|
||||
if ($cm) {
|
||||
$cmStart = Get-ConfigValue -Object $cm -Key 'start'
|
||||
if ($cmStart -is [string] -and $cmStart) {
|
||||
$MarkerStart = $cmStart
|
||||
}
|
||||
$cmEnd = Get-ConfigValue -Object $cm -Key 'end'
|
||||
if ($cmEnd -is [string] -and $cmEnd) {
|
||||
$MarkerEnd = $cmEnd
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PlanPath) {
|
||||
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
|
||||
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
|
||||
# $ErrorActionPreference = 'Stop' don't abort the script.
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
|
||||
}
|
||||
} catch {
|
||||
# Non-fatal: continue without a plan path.
|
||||
}
|
||||
}
|
||||
|
||||
$CtxPath = Join-Path $ProjectRoot $ContextFile
|
||||
$CtxDir = Split-Path -Parent $CtxPath
|
||||
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
|
||||
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$lines = @($MarkerStart,
|
||||
'For additional context about technologies to be used, project structure,',
|
||||
'shell commands, and other important information, read the current plan')
|
||||
if ($PlanPath) {
|
||||
$lines += "at $PlanPath"
|
||||
}
|
||||
$lines += $MarkerEnd
|
||||
$Section = ($lines -join "`n") + "`n"
|
||||
|
||||
if (Test-Path -LiteralPath $CtxPath) {
|
||||
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
|
||||
# Strip UTF-8 BOM if present
|
||||
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
|
||||
} else {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
|
||||
}
|
||||
|
||||
$s = $content.IndexOf($MarkerStart)
|
||||
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
|
||||
|
||||
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
|
||||
$endOfMarker = $e + $MarkerEnd.Length
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
|
||||
} elseif ($s -ge 0) {
|
||||
$newContent = $content.Substring(0, $s) + $Section
|
||||
} elseif ($e -ge 0) {
|
||||
$endOfMarker = $e + $MarkerEnd.Length
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||
$newContent = $Section + $content.Substring($endOfMarker)
|
||||
} else {
|
||||
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
|
||||
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
|
||||
}
|
||||
} else {
|
||||
$newContent = $Section
|
||||
}
|
||||
|
||||
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
Write-Host "agent-context: updated $ContextFile"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-30T00: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",
|
||||
@@ -174,6 +174,37 @@
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"updated_at": "2026-05-07T00:00:00Z"
|
||||
},
|
||||
"arch": {
|
||||
"name": "Architecture Workflow",
|
||||
"id": "arch",
|
||||
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-arch/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"4plus1",
|
||||
"workflow",
|
||||
"design"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -209,10 +240,10 @@
|
||||
"architecture-guard": {
|
||||
"name": "Architecture Guard",
|
||||
"id": "architecture-guard",
|
||||
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
|
||||
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.8.4",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.4.zip",
|
||||
"version": "1.8.9",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.9.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
|
||||
@@ -227,17 +258,18 @@
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"drift-detection",
|
||||
"spec-kit",
|
||||
"review",
|
||||
"refactor",
|
||||
"monolithic",
|
||||
"microservices"
|
||||
"workflow",
|
||||
"governance",
|
||||
"guardrails"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-05-11T14:58:00Z"
|
||||
"updated_at": "2026-05-27T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -1615,8 +1647,8 @@
|
||||
"id": "memorylint",
|
||||
"description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.",
|
||||
"author": "RbBtSn0w",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.0/memorylint.zip",
|
||||
"version": "1.4.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.4.0/memorylint.zip",
|
||||
"repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
|
||||
"homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint",
|
||||
"documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md",
|
||||
@@ -1626,8 +1658,8 @@
|
||||
"speckit_version": ">=0.5.1"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
"commands": 2,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
@@ -1640,7 +1672,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-16T13:10:26Z"
|
||||
"updated_at": "2026-05-24T01:06:49Z"
|
||||
},
|
||||
"multi-model-review": {
|
||||
"name": "Multi-Model Review",
|
||||
@@ -1883,6 +1915,69 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"preview": {
|
||||
"name": "Interactive HTML Preview",
|
||||
"id": "preview",
|
||||
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
|
||||
"author": "bigsmartben",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
|
||||
"changelog": "https://github.com/bigsmartben/spec-kit-preview/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"preview",
|
||||
"prototype",
|
||||
"html",
|
||||
"ux"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"product": {
|
||||
"name": "Product Spec Extension",
|
||||
"id": "product",
|
||||
"description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.",
|
||||
"author": "spec-kit-product contributors",
|
||||
"version": "0.1.3",
|
||||
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.1.3/product-0.1.3.zip",
|
||||
"repository": "https://github.com/d0whc3r/spec-kit-product",
|
||||
"homepage": "https://github.com/d0whc3r/spec-kit-product",
|
||||
"documentation": "https://github.com/d0whc3r/spec-kit-product/blob/main/README.md",
|
||||
"changelog": "https://github.com/d0whc3r/spec-kit-product/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 6
|
||||
},
|
||||
"tags": [
|
||||
"product",
|
||||
"spec",
|
||||
"prd",
|
||||
"design",
|
||||
"documentation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-26T00:00:00Z",
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
},
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
"id": "product-forge",
|
||||
@@ -2123,8 +2218,8 @@
|
||||
"id": "reqnroll-bdd",
|
||||
"description": "Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit.",
|
||||
"author": "LoogaCY Studio",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"homepage": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"documentation": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd#readme",
|
||||
@@ -2154,7 +2249,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-13T00:00:00Z",
|
||||
"updated_at": "2026-05-13T00:00:00Z"
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro Extension",
|
||||
@@ -2550,6 +2645,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.7.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.7.0/speckit-superpowers-bridge-v0.7.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-28T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
"id": "speckit-utils",
|
||||
@@ -2618,21 +2762,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
|
||||
}
|
||||
]
|
||||
@@ -2652,7 +2796,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",
|
||||
@@ -2751,8 +2895,8 @@
|
||||
"id": "superb",
|
||||
"description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.",
|
||||
"author": "rbbtsn0w",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.0/superpowers-bridge.zip",
|
||||
"version": "1.4.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.4.0/superpowers-bridge.zip",
|
||||
"repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
|
||||
"homepage": "https://github.com/RbBtSn0w/spec-kit-extensions",
|
||||
"documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md",
|
||||
@@ -2770,7 +2914,7 @@
|
||||
},
|
||||
"provides": {
|
||||
"commands": 8,
|
||||
"hooks": 4
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"methodology",
|
||||
@@ -2787,7 +2931,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-04-16T14:08:23Z"
|
||||
"updated_at": "2026-05-24T01:07:34Z"
|
||||
},
|
||||
"superpowers-bridge": {
|
||||
"name": "Superpowers Bridge",
|
||||
@@ -2854,6 +2998,74 @@
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"team-assign": {
|
||||
"name": "Team Assign",
|
||||
"id": "team-assign",
|
||||
"description": "Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard",
|
||||
"author": "tarunkumarbhati",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tarunkumarbhati/spec-kit-team-assign/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
|
||||
"homepage": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
|
||||
"documentation": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/README.md",
|
||||
"changelog": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"team",
|
||||
"assignment",
|
||||
"process",
|
||||
"planning",
|
||||
"subtasks"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-20T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"time-machine": {
|
||||
"name": "Time Machine",
|
||||
"id": "time-machine",
|
||||
"description": "Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature",
|
||||
"author": "te3yo",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/teeyo/spec-kit-time-machine/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"homepage": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"documentation": "https://github.com/teeyo/spec-kit-time-machine",
|
||||
"changelog": "https://github.com/teeyo/spec-kit-time-machine/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"brownfield",
|
||||
"automation",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-05-15T00:00:00Z"
|
||||
},
|
||||
"tinyspec": {
|
||||
"name": "TinySpec",
|
||||
"id": "tinyspec",
|
||||
@@ -2949,6 +3161,48 @@
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
"updated_at": "2026-05-01T00:00:00Z"
|
||||
},
|
||||
"token-budget": {
|
||||
"name": "Token Budget",
|
||||
"id": "token-budget",
|
||||
"description": "Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage.",
|
||||
"author": "Tine Kondo",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/tinesoft/spec-kit-token-budget/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/tinesoft/spec-kit-token-budget",
|
||||
"homepage": "https://github.com/tinesoft/spec-kit-token-budget",
|
||||
"documentation": "https://github.com/tinesoft/spec-kit-token-budget/blob/main/README.md",
|
||||
"changelog": "https://github.com/tinesoft/spec-kit-token-budget/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "python3",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "rtk",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 6
|
||||
},
|
||||
"tags": [
|
||||
"tokens",
|
||||
"budget",
|
||||
"context",
|
||||
"efficiency",
|
||||
"cost-optimization"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-26T00:00:00Z",
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
},
|
||||
"v-model": {
|
||||
"name": "V-Model Extension Pack",
|
||||
"id": "v-model",
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"name": "Coding Agent Context",
|
||||
"id": "agent-context",
|
||||
"version": "1.0.0",
|
||||
"description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"bundled": true,
|
||||
"tags": [
|
||||
"agent",
|
||||
"context",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"git": {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
|
||||
@@ -35,7 +35,7 @@ Replace the script to add project-specific Git initialization steps:
|
||||
## Output
|
||||
|
||||
On success:
|
||||
- `✓ Git repository initialized`
|
||||
- `[OK] Git repository initialized`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ if (Test-Path $configFile) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# No config file — auto-commit disabled by default
|
||||
# No config file -- auto-commit disabled by default
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git-specific common functions for the git extension.
|
||||
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
||||
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
|
||||
# branch validation and detection logic.
|
||||
|
||||
function Test-HasGit {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: initialize-repo.ps1
|
||||
# Initialize a Git repository with an initial commit.
|
||||
# Customizable — replace this script to add .gitignore templates,
|
||||
# Customizable -- replace this script to add .gitignore templates,
|
||||
# default branch config, git-flow, LFS, signing, etc.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
@@ -66,4 +66,4 @@ try {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Git repository initialized"
|
||||
Write-Host "[OK] Git repository initialized"
|
||||
|
||||
@@ -272,6 +272,15 @@
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli"]
|
||||
},
|
||||
"hermes": {
|
||||
"id": "hermes",
|
||||
"name": "Hermes Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "Hermes Agent skills-based integration by Nous Research",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-05T10:00:00Z",
|
||||
"updated_at": "2026-05-27T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -222,11 +222,11 @@
|
||||
"fiction-book-writing": {
|
||||
"name": "Fiction Book Writing",
|
||||
"id": "fiction-book-writing",
|
||||
"version": "1.7.0",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
||||
"version": "1.8.1",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.zip",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip",
|
||||
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
|
||||
"license": "MIT",
|
||||
@@ -234,8 +234,8 @@
|
||||
"speckit_version": ">=0.5.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 22,
|
||||
"commands": 27,
|
||||
"templates": 25,
|
||||
"commands": 33,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
@@ -254,7 +254,7 @@
|
||||
"language-support"
|
||||
],
|
||||
"created_at": "2026-04-09T08:00:00Z",
|
||||
"updated_at": "2026-04-27T08:00:00Z"
|
||||
"updated_at": "2026-05-24T08:00:00Z"
|
||||
},
|
||||
"game-narrative-writing": {
|
||||
"name": "Game Narrative Writing",
|
||||
@@ -472,11 +472,11 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA awareness.",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, ASVS verification, SBOM/AI-SBOM supply-chain transparency, and EU Cyber Resilience Act awareness.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -491,11 +491,28 @@
|
||||
"security",
|
||||
"governance",
|
||||
"msl",
|
||||
"ssdf",
|
||||
"asvs",
|
||||
"supply-chain"
|
||||
"supply-chain",
|
||||
"sbom",
|
||||
"ai-sbom",
|
||||
"vex",
|
||||
"slsa",
|
||||
"cwe-top-25",
|
||||
"secure-coding",
|
||||
"rust",
|
||||
"go",
|
||||
"swift",
|
||||
"java",
|
||||
"kotlin",
|
||||
"python",
|
||||
"typescript",
|
||||
"g7",
|
||||
"bsi",
|
||||
"cra"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
},
|
||||
"spec2cloud": {
|
||||
"name": "Spec2Cloud",
|
||||
@@ -572,6 +589,34 @@
|
||||
"clarify",
|
||||
"interactive"
|
||||
]
|
||||
},
|
||||
"workflow-preset": {
|
||||
"name": "Workflow Preset",
|
||||
"id": "workflow-preset",
|
||||
"version": "1.2.0",
|
||||
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
|
||||
"author": "bigsmartben",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/archive/refs/tags/v1.2.0.zip",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.10.dev0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 23,
|
||||
"commands": 7
|
||||
},
|
||||
"tags": [
|
||||
"behavior",
|
||||
"bdd",
|
||||
"planning",
|
||||
"implementation",
|
||||
"handoff"
|
||||
],
|
||||
"created_at": "2026-05-27T00:00:00Z",
|
||||
"updated_at": "2026-05-27T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.10.dev0"
|
||||
version = "0.8.19.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -40,6 +40,7 @@ packages = ["src/specify_cli"]
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
|
||||
# Bundled workflows (auto-installed during `specify init`)
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
|
||||
@@ -78,13 +78,12 @@ done
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
# Get feature paths
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
|
||||
# If paths-only mode, output paths and exit (no validation)
|
||||
if $PATHS_ONLY; then
|
||||
if $JSON_MODE; then
|
||||
# Minimal JSON paths payload (no validation performed)
|
||||
@@ -112,23 +111,26 @@ if $PATHS_ONLY; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate branch name
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.tasks first to create the task list." >&2
|
||||
echo "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ read_feature_json_feature_directory() {
|
||||
}
|
||||
|
||||
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||
# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks).
|
||||
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||
feature_json_matches_feature_dir() {
|
||||
local repo_root="$1"
|
||||
@@ -262,7 +262,7 @@ get_feature_paths() {
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||
local feature_dir
|
||||
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||
@@ -642,4 +642,3 @@ except Exception:
|
||||
printf '%s' "$content"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -40,15 +40,31 @@ fi
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
# Copy plan template if plan doesn't already exist
|
||||
if [[ -f "$IMPL_PLAN" ]]; then
|
||||
if $JSON_MODE; then
|
||||
echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2
|
||||
else
|
||||
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
|
||||
fi
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
if $JSON_MODE; then
|
||||
echo "Copied plan template to $IMPL_PLAN" >&2
|
||||
else
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
fi
|
||||
else
|
||||
if $JSON_MODE; then
|
||||
echo "Warning: Plan template not found" >&2
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
fi
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output results
|
||||
|
||||
@@ -35,13 +35,13 @@ fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -56,14 +56,10 @@ EXAMPLES:
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# If paths-only mode, output paths and exit (support combined -Json -PathsOnly)
|
||||
# If paths-only mode, output paths and exit (no validation)
|
||||
if ($PathsOnly) {
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
@@ -85,23 +81,28 @@ if ($PathsOnly) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate branch name
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run /speckit.specify first to create the feature structure."
|
||||
Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run /speckit.plan first to create the implementation plan."
|
||||
Write-Output "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
||||
Write-Output "Run /speckit.tasks first to create the task list."
|
||||
Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ function Test-FeatureBranch {
|
||||
}
|
||||
|
||||
# True when .specify/feature.json pins an existing feature directory that matches the
|
||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks).
|
||||
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
|
||||
function Test-FeatureJsonMatchesFeatureDir {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
@@ -288,7 +288,7 @@ function Get-FeaturePathsEnv {
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__)
|
||||
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
@@ -336,10 +336,10 @@ function Get-FeaturePathsEnv {
|
||||
function Test-FileExists {
|
||||
param([string]$Path, [string]$Description)
|
||||
if (Test-Path -Path $Path -PathType Leaf) {
|
||||
Write-Output " ✓ $Description"
|
||||
Write-Output " [OK] $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " ✗ $Description"
|
||||
Write-Output " [FAIL] $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -347,10 +347,10 @@ function Test-FileExists {
|
||||
function Test-DirHasFiles {
|
||||
param([string]$Path, [string]$Description)
|
||||
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
|
||||
Write-Output " ✓ $Description"
|
||||
Write-Output " [OK] $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " ✗ $Description"
|
||||
Write-Output " [FAIL] $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
@@ -591,7 +591,7 @@ except Exception:
|
||||
|
||||
if ($layerPaths.Count -eq 0) { return $null }
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# If the top (highest-priority) layer is replace, it wins entirely --
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if ($layerStrategies[0] -eq 'replace') {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
@@ -640,4 +640,4 @@ except Exception:
|
||||
}
|
||||
|
||||
return $content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ if (-not $DryRun) {
|
||||
if ($AllowExistingBranch) {
|
||||
# If we're already on the branch, continue without another checkout.
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch — nothing to do
|
||||
# Already on the target branch -- nothing to do
|
||||
} else {
|
||||
# Otherwise switch to the existing branch instead of failing.
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
@@ -350,7 +350,10 @@ if (-not $DryRun) {
|
||||
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
if ($template -and (Test-Path $template)) {
|
||||
Copy-Item $template $specFile -Force
|
||||
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
} else {
|
||||
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
||||
}
|
||||
|
||||
@@ -33,15 +33,25 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
# Copy plan template if it exists, otherwise note it or create empty file
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
Copy-Item $template $paths.IMPL_PLAN -Force
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
# Copy plan template if plan doesn't already exist
|
||||
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
|
||||
} else {
|
||||
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
|
||||
}
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
} else {
|
||||
Write-Warning "Plan template not found"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Output results
|
||||
|
||||
@@ -28,13 +28,13 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
[Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.")
|
||||
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||
[Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.")
|
||||
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
45
src/specify_cli/_agent_config.py
Normal file
45
src/specify_cli/_agent_config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Agent configuration constants derived from the integration registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _build_agent_config() -> dict[str, dict[str, Any]]:
|
||||
from .integrations import INTEGRATION_REGISTRY
|
||||
config: dict[str, dict[str, Any]] = {}
|
||||
for key, integration in INTEGRATION_REGISTRY.items():
|
||||
if integration.config:
|
||||
config[key] = dict(integration.config)
|
||||
return config
|
||||
|
||||
|
||||
AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
AI_ASSISTANT_ALIASES: dict[str, str] = {
|
||||
"kiro": "kiro-cli",
|
||||
}
|
||||
|
||||
|
||||
def _build_ai_assistant_help() -> str:
|
||||
non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
|
||||
base_help = (
|
||||
f"AI assistant to use: {', '.join(non_generic_agents)}, "
|
||||
"or generic (requires --ai-commands-dir)."
|
||||
)
|
||||
if not AI_ASSISTANT_ALIASES:
|
||||
return base_help
|
||||
alias_phrases = []
|
||||
for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
|
||||
alias_phrases.append(f"'{alias}' as an alias for '{target}'")
|
||||
if len(alias_phrases) == 1:
|
||||
aliases_text = alias_phrases[0]
|
||||
else:
|
||||
aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1]
|
||||
return base_help + " Use " + aliases_text + "."
|
||||
|
||||
|
||||
AI_ASSISTANT_HELP: str = _build_ai_assistant_help()
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
173
src/specify_cli/_version.py
Normal file
173
src/specify_cli/_version.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Version checking and self-update commands for specify_cli.
|
||||
|
||||
Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
||||
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
||||
all version-related logic lives in one place.
|
||||
|
||||
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
||||
at module level, keeping this layer thin and circular-import-safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from ._console import console
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
|
||||
|
||||
def _get_installed_version() -> str:
|
||||
"""Return the installed specify-cli distribution version or 'unknown'.
|
||||
|
||||
Uses importlib.metadata so the value reflects what was actually installed
|
||||
by pip/uv/pipx — not a value read from pyproject.toml. This is
|
||||
intentional for `specify self check`, which should reason about the
|
||||
installed distribution rather than a source-tree fallback. Callers must
|
||||
treat the sentinel string 'unknown' as an indeterminate value (see FR-020).
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
metadata_errors = [importlib.metadata.PackageNotFoundError]
|
||||
invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None)
|
||||
if invalid_metadata_error is not None:
|
||||
metadata_errors.append(invalid_metadata_error)
|
||||
|
||||
try:
|
||||
return importlib.metadata.version("specify-cli")
|
||||
except tuple(metadata_errors):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _normalize_tag(tag: str) -> str:
|
||||
"""Strip exactly one leading 'v' from a release tag.
|
||||
|
||||
Returns the rest of the string unchanged. This handles the common
|
||||
'vX.Y.Z' tag convention in this repo; it MUST NOT strip more
|
||||
aggressively (e.g., two leading 'v's keeps one).
|
||||
"""
|
||||
return tag[1:] if tag.startswith("v") else tag
|
||||
|
||||
|
||||
def _is_newer(latest: str, current: str) -> bool:
|
||||
"""Return True iff `latest` is strictly greater than `current` under PEP 440.
|
||||
|
||||
Returns False whenever either side is 'unknown' or fails to parse; this
|
||||
keeps the comparison indeterminate (rather than crashing or falsely
|
||||
recommending a downgrade) on edge inputs.
|
||||
"""
|
||||
if latest == "unknown" or current == "unknown":
|
||||
return False
|
||||
try:
|
||||
return Version(latest) > Version(current)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
|
||||
|
||||
On success: (tag_name, None).
|
||||
On a documented network/HTTP failure (added in T029/T030): (None, category).
|
||||
On anything else — including a malformed response body — the exception
|
||||
propagates; there is no catch-all (research D-006).
|
||||
"""
|
||||
from .authentication.http import open_url
|
||||
|
||||
try:
|
||||
with open_url(
|
||||
GITHUB_API_LATEST,
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
return tag, None
|
||||
except urllib.error.HTTPError as e:
|
||||
# Order matters: HTTPError is a subclass of URLError.
|
||||
if e.code == 403:
|
||||
return None, (
|
||||
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
)
|
||||
return None, f"HTTP {e.code}"
|
||||
except (urllib.error.URLError, OSError):
|
||||
return None, "offline or timeout"
|
||||
|
||||
|
||||
# ===== Self Commands =====
|
||||
|
||||
self_app = typer.Typer(
|
||||
name="self",
|
||||
help="Manage the specify CLI itself (read-only check and reserved upgrade command).",
|
||||
add_completion=False,
|
||||
)
|
||||
|
||||
|
||||
@self_app.command("check")
|
||||
def self_check() -> None:
|
||||
"""Check whether a newer specify-cli release is available. Read-only.
|
||||
|
||||
This command only checks for updates; it does not modify your installation.
|
||||
The reserved (and currently non-destructive) `specify self upgrade` command
|
||||
is the name that a future release will use for actual self-upgrade — its
|
||||
behavior is not implemented in this release and is intentionally out of
|
||||
scope here. See `specify self upgrade --help` for its current status.
|
||||
"""
|
||||
installed = _get_installed_version()
|
||||
tag, failure_reason = _fetch_latest_release_tag()
|
||||
|
||||
if tag is None:
|
||||
# Graceful-failure path (FR-008). `failure_reason` is one of the
|
||||
# enumerated strings produced by _fetch_latest_release_tag() — it
|
||||
# never contains a URL, headers, response body, or traceback.
|
||||
assert failure_reason is not None
|
||||
console.print(f"Installed: {installed}")
|
||||
console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}")
|
||||
return
|
||||
|
||||
latest_normalized = _normalize_tag(tag)
|
||||
|
||||
if installed == "unknown":
|
||||
# FR-020: surface the latest release and the recovery action even
|
||||
# when the local distribution metadata is unavailable.
|
||||
console.print("Current version could not be determined.")
|
||||
console.print(f"Latest release: {latest_normalized}")
|
||||
console.print("\nTo reinstall:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
if _is_newer(latest_normalized, installed):
|
||||
console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}")
|
||||
console.print("\nTo upgrade:")
|
||||
console.print(" uv tool install specify-cli --force \\")
|
||||
console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}")
|
||||
return
|
||||
|
||||
# Installed is parseable AND is >= latest → "up to date" (FR-006).
|
||||
# Also reached when the tag is unparseable (InvalidVersion) → _is_newer
|
||||
# returns False, and the up-to-date branch is the safer default per
|
||||
# FR-004 / test T016.
|
||||
console.print(f"[green]Up to date:[/green] {installed}")
|
||||
|
||||
|
||||
@self_app.command("upgrade")
|
||||
def self_upgrade() -> None:
|
||||
"""Reserved command surface for self-upgrade; not implemented in this release.
|
||||
|
||||
This command is a documented non-destructive stub in this release: it
|
||||
performs no outbound network request, no install-method detection, and
|
||||
invokes no installer. It prints a three-line guidance message and exits 0.
|
||||
Actual self-upgrade is planned as follow-up work.
|
||||
|
||||
Use `specify self check` today to see whether a newer release is available
|
||||
and to get a copy-pasteable reinstall command.
|
||||
"""
|
||||
console.print("specify self upgrade is not implemented yet.")
|
||||
console.print("Run 'specify self check' to see whether a newer release is available.")
|
||||
console.print("Actual self-upgrade is planned as follow-up work.")
|
||||
@@ -374,8 +374,15 @@ class CommandRegistrar:
|
||||
|
||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||
|
||||
# Resolve __CONTEXT_FILE__ from init-options
|
||||
context_file = init_opts.get("context_file") or ""
|
||||
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
|
||||
# Fall back to init-options.json for projects that haven't migrated.
|
||||
# Local import: _load_agent_context_config lives in __init__.py which
|
||||
# imports agents.py, so a top-level import would be circular.
|
||||
from . import _load_agent_context_config
|
||||
ac_cfg = _load_agent_context_config(project_root)
|
||||
context_file = ac_cfg.get("context_file") or ""
|
||||
if not context_file:
|
||||
context_file = init_opts.get("context_file") or ""
|
||||
body = body.replace("__CONTEXT_FILE__", context_file)
|
||||
|
||||
return CommandRegistrar.rewrite_project_relative_paths(body)
|
||||
@@ -439,6 +446,7 @@ class CommandRegistrar:
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
_resolved_dir: Path = None,
|
||||
link_outputs: bool = False,
|
||||
) -> List[str]:
|
||||
"""Register commands for a specific agent.
|
||||
|
||||
@@ -453,6 +461,9 @@ class CommandRegistrar:
|
||||
only — avoids a second ``_resolve_agent_dir`` call and
|
||||
duplicate deprecation warnings when invoked from
|
||||
``register_commands_for_all_agents``).
|
||||
link_outputs: If True, write rendered output to a source-local
|
||||
dev cache and symlink the agent command file to it. Falls back
|
||||
to a normal file write when symlinks are unavailable.
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
@@ -559,7 +570,15 @@ class CommandRegistrar:
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
self._ensure_inside(dest_file, commands_dir)
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest_file.write_text(output, encoding="utf-8")
|
||||
self._write_registered_output(
|
||||
dest_file,
|
||||
output,
|
||||
source_dir,
|
||||
agent_name,
|
||||
output_name,
|
||||
agent_config["extension"],
|
||||
link_outputs,
|
||||
)
|
||||
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, cmd_name)
|
||||
@@ -625,13 +644,56 @@ class CommandRegistrar:
|
||||
)
|
||||
self._ensure_inside(alias_file, commands_dir)
|
||||
alias_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
self._write_registered_output(
|
||||
alias_file,
|
||||
alias_output,
|
||||
source_dir,
|
||||
agent_name,
|
||||
alias_output_name,
|
||||
agent_config["extension"],
|
||||
link_outputs,
|
||||
)
|
||||
if agent_name == "copilot":
|
||||
self.write_copilot_prompt(project_root, alias)
|
||||
registered.append(alias)
|
||||
|
||||
return registered
|
||||
|
||||
@staticmethod
|
||||
def _write_registered_output(
|
||||
dest_file: Path,
|
||||
content: str,
|
||||
source_dir: Path,
|
||||
agent_name: str,
|
||||
output_name: str,
|
||||
extension: str,
|
||||
link_outputs: bool,
|
||||
) -> None:
|
||||
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
|
||||
if not link_outputs:
|
||||
dest_file.write_text(content, encoding="utf-8")
|
||||
return
|
||||
|
||||
rel_output = Path(f"{output_name}{extension}")
|
||||
cache_root = source_dir / ".specify-dev" / "agent-commands" / agent_name
|
||||
cache_file = cache_root / rel_output
|
||||
CommandRegistrar._ensure_inside(cache_file, cache_root)
|
||||
|
||||
try:
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(content, encoding="utf-8")
|
||||
if dest_file.exists() or dest_file.is_symlink():
|
||||
dest_file.unlink()
|
||||
target = os.path.relpath(cache_file, dest_file.parent)
|
||||
os.symlink(target, dest_file)
|
||||
except (OSError, ValueError):
|
||||
# Windows often requires Developer Mode or admin privileges for
|
||||
# symlinks, and relpath can fail across drives. Keep dev installs
|
||||
# functional by falling back to a copy.
|
||||
if dest_file.is_symlink():
|
||||
dest_file.unlink()
|
||||
dest_file.write_text(content, encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
|
||||
"""Generate a companion .prompt.md file for a Copilot agent command.
|
||||
@@ -654,15 +716,28 @@ class CommandRegistrar:
|
||||
) -> Path:
|
||||
"""Return the agent command directory, falling back to legacy_dir.
|
||||
|
||||
When the canonical directory (``agent_config["dir"]``) does not
|
||||
exist but a ``legacy_dir`` is configured and present on disk,
|
||||
returns the legacy path and emits a deprecation warning advising
|
||||
the user to upgrade.
|
||||
Supports project-relative paths (e.g. ``.claude/skills/``),
|
||||
home-relative paths (e.g. ``~/.hermes/skills``), and absolute
|
||||
paths — the ``agent_config["dir"]`` value is resolved verbatim
|
||||
when absolute or starting with ``~/``, or joined with
|
||||
``project_root`` when relative.
|
||||
|
||||
When the canonical directory does not exist but a ``legacy_dir``
|
||||
is configured and present on disk, returns the legacy path and
|
||||
emits a deprecation warning advising the user to upgrade.
|
||||
|
||||
Integrations that do not declare ``legacy_dir`` get the canonical
|
||||
path unconditionally — no fallback, no warning.
|
||||
"""
|
||||
agent_dir = project_root / agent_config["dir"]
|
||||
dir_str = agent_config["dir"]
|
||||
if dir_str.startswith("~"):
|
||||
# Use Path.home() + remainder instead of expanduser() so tests
|
||||
# that monkeypatch Path.home() can properly isolate the home dir.
|
||||
# expanduser() uses OS env/user lookup and ignores monkeypatches.
|
||||
agent_dir = Path.home() / dir_str[1:].lstrip("/")
|
||||
else:
|
||||
p = Path(dir_str)
|
||||
agent_dir = p if p.is_absolute() else project_root / p
|
||||
if not agent_dir.exists():
|
||||
legacy = agent_config.get("legacy_dir")
|
||||
if legacy:
|
||||
@@ -687,6 +762,7 @@ class CommandRegistrar:
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
link_outputs: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
@@ -696,6 +772,8 @@ class CommandRegistrar:
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -704,6 +782,15 @@ class CommandRegistrar:
|
||||
|
||||
self._ensure_configs()
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
# Check detect_dir first (project-local marker) if configured,
|
||||
# falling back to the resolved dir for output. This prevents
|
||||
# global dirs (e.g. ~/.hermes/skills) from causing false
|
||||
# detection in every project.
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
if detect_dir_str:
|
||||
detect_path = project_root / detect_dir_str
|
||||
if not detect_path.exists():
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
@@ -718,6 +805,7 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
link_outputs=link_outputs,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -733,6 +821,7 @@ class CommandRegistrar:
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
link_outputs: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all non-skill agents in the project.
|
||||
|
||||
@@ -746,6 +835,8 @@ class CommandRegistrar:
|
||||
source_dir: Directory containing command source files
|
||||
project_root: Path to project root
|
||||
context_note: Custom context comment for markdown output
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -755,6 +846,11 @@ class CommandRegistrar:
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
if detect_dir_str:
|
||||
detect_path = project_root / detect_dir_str
|
||||
if not detect_path.exists():
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
@@ -768,6 +864,7 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
link_outputs=link_outputs,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -816,7 +913,7 @@ class CommandRegistrar:
|
||||
cmd_file = (
|
||||
target_dir / f"{output_name}{agent_config['extension']}"
|
||||
)
|
||||
if cmd_file.exists():
|
||||
if cmd_file.exists() or cmd_file.is_symlink():
|
||||
cmd_file.unlink()
|
||||
# For SKILL.md agents each command lives in its own
|
||||
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
|
||||
|
||||
7
src/specify_cli/commands/__init__.py
Normal file
7
src/specify_cli/commands/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""CLI command groups extracted from the main application.
|
||||
|
||||
Implemented command modules expose a ``register(app)`` function. Placeholder
|
||||
modules are import-only anchors for command groups that still live in the main
|
||||
application module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
2
src/specify_cli/commands/extension.py
Normal file
2
src/specify_cli/commands/extension.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify extension * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
783
src/specify_cli/commands/init.py
Normal file
783
src/specify_cli/commands/init.py
Normal file
@@ -0,0 +1,783 @@
|
||||
"""specify init command."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
from .._assets import (
|
||||
_locate_bundled_extension,
|
||||
_locate_bundled_preset,
|
||||
_locate_bundled_workflow,
|
||||
get_speckit_version,
|
||||
)
|
||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||
|
||||
def _build_integration_equivalent(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
parts = [f"--integration {integration_key}"]
|
||||
if integration_key == "generic" and ai_commands_dir:
|
||||
parts.append(
|
||||
f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_ai_deprecation_warning(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
replacement = _build_integration_equivalent(
|
||||
integration_key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
return (
|
||||
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
|
||||
f"Use [bold]{replacement}[/bold] instead."
|
||||
)
|
||||
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
|
||||
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
"""Copy constitution template to memory if it doesn't exist."""
|
||||
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
|
||||
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
|
||||
|
||||
if memory_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.skip("constitution", "existing file preserved")
|
||||
return
|
||||
|
||||
if not template_constitution.exists():
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", "template not found")
|
||||
return
|
||||
|
||||
try:
|
||||
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(template_constitution, memory_constitution)
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.complete("constitution", "copied from template")
|
||||
else:
|
||||
console.print("[cyan]Initialized constitution from template[/cyan]")
|
||||
except Exception as e:
|
||||
if tracker:
|
||||
tracker.add("constitution", "Constitution setup")
|
||||
tracker.error("constitution", str(e))
|
||||
else:
|
||||
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
|
||||
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
|
||||
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project.
|
||||
|
||||
Project files are scaffolded from assets bundled inside the specify-cli
|
||||
package, so initialization does not need network access and templates
|
||||
match the installed CLI version.
|
||||
|
||||
This command will:
|
||||
1. Check that required tools are installed (git is optional)
|
||||
2. Let you choose your coding agent integration, or default to Copilot
|
||||
in non-interactive sessions
|
||||
3. Install bundled Spec Kit templates, scripts, workflow, and shared
|
||||
project infrastructure
|
||||
4. Initialize a fresh git repository (if not --no-git and no existing repo)
|
||||
5. Set up coding agent integration commands and optional presets
|
||||
|
||||
Examples:
|
||||
specify init my-project
|
||||
specify init my-project --integration claude
|
||||
specify init my-project --integration copilot --no-git
|
||||
specify init --ignore-agent-tools my-project
|
||||
specify init . --integration claude # Initialize in current directory
|
||||
specify init . # Initialize in current directory (interactive integration selection)
|
||||
specify init --here --integration claude # Alternative syntax for current directory
|
||||
specify init --here --integration codex --integration-options="--skills"
|
||||
specify init --here --integration codebuddy
|
||||
specify init --here --integration vibe # Initialize with Mistral Vibe support
|
||||
specify init --here
|
||||
specify init --here --force # Skip confirmation when current directory not empty
|
||||
specify init my-project --integration claude # Claude installs skills by default
|
||||
specify init --here --integration gemini
|
||||
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
|
||||
specify init my-project --integration claude --preset healthcare-compliance # With preset
|
||||
"""
|
||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||
from .. import (
|
||||
_install_shared_infra_or_exit,
|
||||
_parse_integration_options,
|
||||
_print_cli_warning,
|
||||
_update_agent_context_config_file,
|
||||
_write_integration_json,
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
)
|
||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||
|
||||
show_banner()
|
||||
ai_deprecation_warning: str | None = None
|
||||
|
||||
if ai_assistant and ai_assistant.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
|
||||
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_commands_dir and ai_commands_dir.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
|
||||
|
||||
if integration and ai_assistant:
|
||||
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from ..integrations import INTEGRATION_REGISTRY, get_integration
|
||||
if integration:
|
||||
resolved_integration = get_integration(integration)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
ai_assistant = integration
|
||||
elif ai_assistant:
|
||||
resolved_integration = get_integration(ai_assistant)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}")
|
||||
raise typer.Exit(1)
|
||||
ai_deprecation_warning = _build_ai_deprecation_warning(
|
||||
resolved_integration.key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
|
||||
if ai_assistant or integration:
|
||||
if ai_skills:
|
||||
from ..integrations.base import SkillsIntegration as _SkillsCheck
|
||||
if isinstance(resolved_integration, _SkillsCheck):
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills is not needed; "
|
||||
"skills are the default for this integration.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills has no effect with "
|
||||
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
|
||||
)
|
||||
if ai_commands_dir and resolved_integration.key != "generic":
|
||||
console.print(
|
||||
"[dim]Note: --ai-commands-dir is deprecated; "
|
||||
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
|
||||
)
|
||||
|
||||
if no_git:
|
||||
console.print(
|
||||
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
|
||||
"[yellow]The git extension will no longer be enabled by default "
|
||||
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
|
||||
)
|
||||
|
||||
if project_name == ".":
|
||||
here = True
|
||||
project_name = None
|
||||
|
||||
if here and project_name:
|
||||
console.print("[red]Error:[/red] Cannot specify both project name and --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not here and not project_name:
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_skills and not ai_assistant:
|
||||
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||
raise typer.Exit(1)
|
||||
|
||||
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
dir_existed_before = False
|
||||
if here:
|
||||
project_name = Path.cwd().name
|
||||
project_path = Path.cwd()
|
||||
dir_existed_before = True
|
||||
|
||||
existing_items = list(project_path.iterdir())
|
||||
if existing_items:
|
||||
console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)")
|
||||
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
||||
if force:
|
||||
console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]")
|
||||
else:
|
||||
response = typer.confirm("Do you want to continue?")
|
||||
if not response:
|
||||
console.print("[yellow]Operation cancelled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
project_path = Path(project_name).resolve()
|
||||
dir_existed_before = project_path.exists()
|
||||
if project_path.exists():
|
||||
if not project_path.is_dir():
|
||||
console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.")
|
||||
raise typer.Exit(1)
|
||||
existing_items = list(project_path.iterdir())
|
||||
if force:
|
||||
if existing_items:
|
||||
console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)")
|
||||
console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]")
|
||||
console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]")
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
"Please choose a different project name or remove the existing directory.\n"
|
||||
"Use [bold]--force[/bold] to merge into the existing directory.",
|
||||
title="[red]Directory Conflict[/red]",
|
||||
border_style="red",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(error_panel)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
if ai_assistant not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
elif not _stdin_is_interactive():
|
||||
console.print(
|
||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
"Use --integration to choose a different agent.[/dim]"
|
||||
)
|
||||
selected_ai = DEFAULT_INIT_INTEGRATION
|
||||
else:
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your coding agent integration:",
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
)
|
||||
|
||||
if not ai_assistant:
|
||||
resolved_integration = get_integration(selected_ai)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if selected_ai == "generic" and not integration_options:
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
|
||||
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
||||
raise typer.Exit(1)
|
||||
|
||||
current_dir = Path.cwd()
|
||||
|
||||
setup_lines = [
|
||||
"[cyan]Specify Project Setup[/cyan]",
|
||||
"",
|
||||
f"{'Project':<15} [green]{project_path.name}[/green]",
|
||||
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
|
||||
]
|
||||
|
||||
if not here:
|
||||
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
||||
|
||||
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
|
||||
|
||||
should_init_git = False
|
||||
if not no_git:
|
||||
should_init_git = check_tool("git")
|
||||
if not should_init_git:
|
||||
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
|
||||
|
||||
if not ignore_agent_tools:
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config and agent_config["requires_cli"]:
|
||||
install_url = agent_config["install_url"]
|
||||
if not check_tool(selected_ai):
|
||||
error_panel = Panel(
|
||||
f"[cyan]{selected_ai}[/cyan] not found\n"
|
||||
f"Install from: [cyan]{install_url}[/cyan]\n"
|
||||
f"{agent_config['name']} is required to continue with this project type.\n\n"
|
||||
"Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check",
|
||||
title="[red]Agent Detection Error[/red]",
|
||||
border_style="red",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(error_panel)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if script_type:
|
||||
if script_type not in SCRIPT_TYPE_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_script = script_type
|
||||
else:
|
||||
default_script = "ps" if os.name == "nt" else "sh"
|
||||
|
||||
if _stdin_is_interactive():
|
||||
selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script)
|
||||
else:
|
||||
selected_script = default_script
|
||||
|
||||
console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}")
|
||||
console.print(f"[cyan]Selected script type:[/cyan] {selected_script}")
|
||||
|
||||
tracker = StepTracker("Initialize Specify Project")
|
||||
|
||||
tracker.add("precheck", "Check required tools")
|
||||
tracker.complete("precheck", "ok")
|
||||
tracker.add("ai-select", "Select coding agent integration")
|
||||
tracker.complete("ai-select", f"{selected_ai}")
|
||||
tracker.add("script-select", "Select script type")
|
||||
tracker.complete("script-select", selected_script)
|
||||
|
||||
tracker.add("integration", "Install integration")
|
||||
tracker.add("shared-infra", "Install shared infrastructure")
|
||||
|
||||
for key, label in [
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("agent-context", "Install agent-context extension"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
git_default_notice = False
|
||||
|
||||
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
|
||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||
try:
|
||||
from ..integrations.manifest import IntegrationManifest
|
||||
tracker.start("integration")
|
||||
manifest = IntegrationManifest(
|
||||
resolved_integration.key, project_path, version=get_speckit_version()
|
||||
)
|
||||
|
||||
integration_parsed_options: dict[str, Any] = {}
|
||||
if ai_commands_dir:
|
||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||
if ai_skills:
|
||||
integration_parsed_options["skills"] = True
|
||||
if integration_options:
|
||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||
if extra:
|
||||
integration_parsed_options.update(extra)
|
||||
|
||||
resolved_integration.setup(
|
||||
project_path, manifest,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
integration_settings = _with_integration_setting(
|
||||
{},
|
||||
resolved_integration.key,
|
||||
resolved_integration,
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
)
|
||||
_write_integration_json(
|
||||
project_path,
|
||||
resolved_integration.key,
|
||||
[resolved_integration.key],
|
||||
integration_settings,
|
||||
)
|
||||
|
||||
tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key))
|
||||
|
||||
tracker.start("shared-infra")
|
||||
_install_shared_infra_or_exit(
|
||||
project_path,
|
||||
selected_script,
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options),
|
||||
)
|
||||
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
if not no_git:
|
||||
tracker.start("git")
|
||||
git_messages = []
|
||||
git_has_error = False
|
||||
if is_git_repo(project_path):
|
||||
git_messages.append("existing repo detected")
|
||||
elif should_init_git:
|
||||
success, error_msg = init_git_repo(project_path, quiet=True)
|
||||
if success:
|
||||
git_messages.append("initialized")
|
||||
else:
|
||||
git_has_error = True
|
||||
if error_msg:
|
||||
sanitized = error_msg.replace('\n', ' ').strip()
|
||||
git_messages.append(f"init failed: {sanitized[:120]}")
|
||||
else:
|
||||
git_messages.append("init failed")
|
||||
else:
|
||||
git_messages.append("git not available")
|
||||
try:
|
||||
from ..extensions import ExtensionManager
|
||||
bundled_path = _locate_bundled_extension("git")
|
||||
if bundled_path:
|
||||
manager = ExtensionManager(project_path)
|
||||
if manager.registry.is_installed("git"):
|
||||
git_messages.append("extension already installed")
|
||||
else:
|
||||
manager.install_from_directory(
|
||||
bundled_path, get_speckit_version()
|
||||
)
|
||||
git_default_notice = True
|
||||
git_messages.append("extension installed")
|
||||
else:
|
||||
git_has_error = True
|
||||
git_messages.append("bundled extension not found")
|
||||
except Exception as ext_err:
|
||||
git_has_error = True
|
||||
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
|
||||
git_messages.append(
|
||||
f"extension install failed: {sanitized_ext[:120]}"
|
||||
)
|
||||
summary = "; ".join(git_messages)
|
||||
if git_has_error:
|
||||
tracker.error("git", summary)
|
||||
else:
|
||||
tracker.complete("git", summary)
|
||||
else:
|
||||
tracker.skip("git", "--no-git flag")
|
||||
|
||||
try:
|
||||
bundled_wf = _locate_bundled_workflow("speckit")
|
||||
if bundled_wf:
|
||||
from ..workflows.catalog import WorkflowRegistry
|
||||
from ..workflows.engine import WorkflowDefinition
|
||||
wf_registry = WorkflowRegistry(project_path)
|
||||
if wf_registry.is_installed("speckit"):
|
||||
tracker.complete("workflow", "already installed")
|
||||
else:
|
||||
import shutil as _shutil
|
||||
dest_wf = project_path / ".specify" / "workflows" / "speckit"
|
||||
dest_wf.mkdir(parents=True, exist_ok=True)
|
||||
_shutil.copy2(
|
||||
bundled_wf / "workflow.yml",
|
||||
dest_wf / "workflow.yml",
|
||||
)
|
||||
definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
|
||||
wf_registry.add("speckit", {
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"source": "bundled",
|
||||
})
|
||||
tracker.complete("workflow", "speckit installed")
|
||||
else:
|
||||
tracker.skip("workflow", "bundled workflow not found")
|
||||
except Exception as wf_err:
|
||||
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
|
||||
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
|
||||
|
||||
init_opts = {
|
||||
"ai": selected_ai,
|
||||
"integration": resolved_integration.key,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"here": here,
|
||||
"script": selected_script,
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
# --- agent-context extension (bundled, auto-installed) ---
|
||||
# Installed after init-options.json is written so that skill
|
||||
# registration can read ai_skills + integration key.
|
||||
try:
|
||||
from ..extensions import ExtensionManager as _ExtMgr
|
||||
bundled_ac = _locate_bundled_extension("agent-context")
|
||||
if bundled_ac:
|
||||
ac_mgr = _ExtMgr(project_path)
|
||||
if ac_mgr.registry.is_installed("agent-context"):
|
||||
tracker.complete("agent-context", "already installed")
|
||||
else:
|
||||
ac_mgr.install_from_directory(
|
||||
bundled_ac, get_speckit_version()
|
||||
)
|
||||
tracker.complete("agent-context", "extension installed")
|
||||
else:
|
||||
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"bundled extension not found — installation may be "
|
||||
f"incomplete. Run: {_ac_reinstall}",
|
||||
)
|
||||
except Exception as ac_err:
|
||||
sanitized_ac = str(ac_err).replace('\n', ' ').strip()
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"extension install failed: {sanitized_ac[:120]}",
|
||||
)
|
||||
|
||||
# Write context_file to the agent-context extension config
|
||||
# AFTER the extension install (which copies the template config
|
||||
# with an empty context_file).
|
||||
if resolved_integration.context_file:
|
||||
_update_agent_context_config_file(
|
||||
project_path,
|
||||
resolved_integration.context_file,
|
||||
preserve_markers=True,
|
||||
)
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
if preset:
|
||||
try:
|
||||
from ..presets import PresetManager, PresetCatalog, PresetError
|
||||
preset_manager = PresetManager(project_path)
|
||||
speckit_ver = get_speckit_version()
|
||||
|
||||
local_path = Path(preset).resolve()
|
||||
if local_path.is_dir() and (local_path / "preset.yml").exists():
|
||||
preset_manager.install_from_directory(local_path, speckit_ver)
|
||||
else:
|
||||
bundled_path = _locate_bundled_preset(preset)
|
||||
if bundled_path:
|
||||
preset_manager.install_from_directory(bundled_path, speckit_ver)
|
||||
else:
|
||||
preset_catalog = PresetCatalog(project_path)
|
||||
pack_info = preset_catalog.get_pack_info(preset)
|
||||
if not pack_info:
|
||||
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
|
||||
elif pack_info.get("bundled") and not pack_info.get("download_url"):
|
||||
from ..extensions import REINSTALL_COMMAND
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
|
||||
f"but could not be found in the installed package."
|
||||
)
|
||||
console.print(
|
||||
"This usually means the spec-kit installation is incomplete or corrupted."
|
||||
)
|
||||
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
|
||||
else:
|
||||
zip_path = None
|
||||
try:
|
||||
zip_path = preset_catalog.download_pack(preset)
|
||||
preset_manager.install_from_zip(zip_path, speckit_ver)
|
||||
except PresetError as preset_err:
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
"preset",
|
||||
preset,
|
||||
preset_err,
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
finally:
|
||||
if zip_path is not None:
|
||||
try:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as preset_err:
|
||||
_print_cli_warning(
|
||||
"install",
|
||||
"preset",
|
||||
preset,
|
||||
preset_err,
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
tracker.complete("final", "project ready")
|
||||
except (typer.Exit, SystemExit):
|
||||
raise
|
||||
except Exception as e:
|
||||
tracker.error("final", str(e))
|
||||
console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red"))
|
||||
if debug:
|
||||
_env_pairs = [
|
||||
("Python", sys.version.split()[0]),
|
||||
("Platform", sys.platform),
|
||||
("CWD", str(Path.cwd())),
|
||||
]
|
||||
_label_width = max(len(k) for k, _ in _env_pairs)
|
||||
env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs]
|
||||
console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta"))
|
||||
if not here and project_path.exists() and not dir_existed_before:
|
||||
shutil.rmtree(project_path)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
pass
|
||||
|
||||
console.print(tracker.render())
|
||||
console.print("\n[bold green]Project ready.[/bold green]")
|
||||
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config:
|
||||
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
if ai_deprecation_warning:
|
||||
deprecation_notice = Panel(
|
||||
ai_deprecation_warning,
|
||||
title="[bold red]Deprecation Warning[/bold red]",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(deprecation_notice)
|
||||
|
||||
if git_default_notice:
|
||||
default_change_notice = Panel(
|
||||
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
|
||||
"Use [bold]specify extension add git[/bold] after init when needed.",
|
||||
title="[yellow]Notice: Git Default Changing[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(default_change_notice)
|
||||
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
|
||||
step_num = 2
|
||||
else:
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
||||
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
trae_skill_mode = selected_ai == "trae"
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||
devin_skill_mode = selected_ai == "devin"
|
||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
||||
|
||||
if codex_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
step_num += 1
|
||||
if claude_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
||||
step_num += 1
|
||||
if cursor_agent_skill_mode and not ai_skills:
|
||||
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
|
||||
step_num += 1
|
||||
if devin_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
def _display_cmd(name: str) -> str:
|
||||
if codex_skill_mode or agy_skill_mode or trae_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if claude_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit-{name}"
|
||||
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
|
||||
return f"/speckit-{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")
|
||||
|
||||
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
|
||||
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
|
||||
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
|
||||
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
|
||||
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
|
||||
|
||||
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2))
|
||||
console.print()
|
||||
console.print(steps_panel)
|
||||
|
||||
enhancement_intro = (
|
||||
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
if native_skill_mode
|
||||
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
|
||||
)
|
||||
enhancement_lines = [
|
||||
enhancement_intro,
|
||||
"",
|
||||
f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
|
||||
f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
|
||||
f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
|
||||
]
|
||||
enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
|
||||
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1, 2))
|
||||
console.print()
|
||||
console.print(enhancements_panel)
|
||||
2
src/specify_cli/commands/integration.py
Normal file
2
src/specify_cli/commands/integration.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify integration * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
2
src/specify_cli/commands/preset.py
Normal file
2
src/specify_cli/commands/preset.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify preset * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
2
src/specify_cli/commands/workflow.py
Normal file
2
src/specify_cli/commands/workflow.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""specify workflow * commands — placeholder for future extraction."""
|
||||
from __future__ import annotations
|
||||
@@ -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:
|
||||
@@ -804,42 +801,29 @@ class ExtensionManager:
|
||||
def _get_skills_dir(self) -> Optional[Path]:
|
||||
"""Return the active skills directory for extension skill registration.
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills
|
||||
are enabled and which agent was selected, then delegates to
|
||||
the module-level ``_get_skills_dir()`` helper for the concrete path.
|
||||
Delegates to :func:`resolve_active_skills_dir` which reads
|
||||
init-options, applies the Kimi native-skills fallback, and
|
||||
safely creates the directory when ``ai_skills`` is enabled.
|
||||
|
||||
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
|
||||
``.kimi/skills`` exists, extension installs should still propagate
|
||||
command skills even when ``ai_skills`` is false.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills were not
|
||||
enabled and no native-skills fallback applies.
|
||||
Returns ``None`` (instead of raising) when the directory cannot
|
||||
be created due to symlink, containment, or permission issues so
|
||||
that callers can fall back gracefully.
|
||||
"""
|
||||
from . import load_init_options, _get_skills_dir as resolve_skills_dir
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
opts = {}
|
||||
|
||||
agent = opts.get("ai")
|
||||
if not isinstance(agent, str) or not agent:
|
||||
from . import resolve_active_skills_dir, _print_cli_warning
|
||||
try:
|
||||
return resolve_active_skills_dir(self.project_root)
|
||||
except (ValueError, OSError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", None, exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
if not ai_skills_enabled and agent != "kimi":
|
||||
return None
|
||||
|
||||
skills_dir = resolve_skills_dir(self.project_root, agent)
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
|
||||
return skills_dir
|
||||
|
||||
def _register_extension_skills(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
link_outputs: bool = False,
|
||||
) -> List[str]:
|
||||
"""Generate SKILL.md files for extension commands as agent skills.
|
||||
|
||||
@@ -851,6 +835,8 @@ class ExtensionManager:
|
||||
Args:
|
||||
manifest: Extension manifest.
|
||||
extension_dir: Installed extension directory.
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
skill files when supported by the OS.
|
||||
|
||||
Returns:
|
||||
List of skill names that were created (for registry storage).
|
||||
@@ -903,9 +889,18 @@ class ExtensionManager:
|
||||
# Check if skill already exists before creating the directory
|
||||
skill_subdir = skills_dir / skill_name
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
if skill_file.exists():
|
||||
# Do not overwrite user-customized skills
|
||||
continue
|
||||
cache_root = extension_dir / ".specify-dev" / "extension-skills"
|
||||
cache_file = cache_root / skill_name / "SKILL.md"
|
||||
CommandRegistrar._ensure_inside(cache_file, cache_root)
|
||||
if skill_file.exists() or skill_file.is_symlink():
|
||||
# Do not overwrite user-customized skills, but allow dev-mode
|
||||
# symlinks that point back to this extension's generated cache
|
||||
# to be refreshed on a subsequent dev install.
|
||||
if not (
|
||||
link_outputs
|
||||
and self._is_expected_dev_symlink(skill_file, cache_file)
|
||||
):
|
||||
continue
|
||||
|
||||
# Create skill directory; track whether we created it so we can clean
|
||||
# up safely if reading the source file subsequently fails.
|
||||
@@ -957,11 +952,35 @@ class ExtensionManager:
|
||||
skill_content
|
||||
)
|
||||
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
if link_outputs:
|
||||
try:
|
||||
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(skill_content, encoding="utf-8")
|
||||
if skill_file.exists() or skill_file.is_symlink():
|
||||
skill_file.unlink()
|
||||
target = os.path.relpath(cache_file, skill_file.parent)
|
||||
os.symlink(target, skill_file)
|
||||
except (OSError, ValueError):
|
||||
if skill_file.is_symlink():
|
||||
skill_file.unlink()
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
else:
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(skill_name)
|
||||
|
||||
return written
|
||||
|
||||
@staticmethod
|
||||
def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool:
|
||||
"""Return True when an existing skill file links to its dev cache."""
|
||||
if not skill_file.is_symlink():
|
||||
return False
|
||||
|
||||
try:
|
||||
return skill_file.resolve(strict=False) == cache_file.resolve(strict=False)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _unregister_extension_skills(
|
||||
self,
|
||||
skill_names: List[str],
|
||||
@@ -1132,6 +1151,7 @@ class ExtensionManager:
|
||||
speckit_version: str,
|
||||
register_commands: bool = True,
|
||||
priority: int = 10,
|
||||
link_commands: bool = False,
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from a local directory.
|
||||
|
||||
@@ -1140,6 +1160,8 @@ class ExtensionManager:
|
||||
speckit_version: Current spec-kit version
|
||||
register_commands: If True, register commands with AI agents
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
link_commands: If True, register rendered agent artifacts as
|
||||
symlinks to a dev cache when supported by the OS.
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
@@ -1183,12 +1205,14 @@ class ExtensionManager:
|
||||
registrar = CommandRegistrar()
|
||||
# Register for all detected agents
|
||||
registered_commands = registrar.register_commands_for_all_agents(
|
||||
manifest, dest_dir, self.project_root
|
||||
manifest, dest_dir, self.project_root, link_outputs=link_commands
|
||||
)
|
||||
|
||||
# Auto-register extension commands as agent skills when --ai-skills
|
||||
# was used during project initialisation (feature parity).
|
||||
registered_skills = self._register_extension_skills(manifest, dest_dir)
|
||||
registered_skills = self._register_extension_skills(
|
||||
manifest, dest_dir, link_outputs=link_commands
|
||||
)
|
||||
|
||||
# Register hooks and update installed list in extensions.yml
|
||||
hook_executor = HookExecutor(self.project_root)
|
||||
@@ -1624,7 +1648,8 @@ class CommandRegistrar:
|
||||
agent_name: str,
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
project_root: Path,
|
||||
link_outputs: bool = False,
|
||||
) -> List[str]:
|
||||
"""Register extension commands for a specific agent."""
|
||||
if agent_name not in self.AGENT_CONFIGS:
|
||||
@@ -1632,20 +1657,23 @@ class CommandRegistrar:
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands(
|
||||
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
)
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
project_root: Path,
|
||||
link_outputs: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register extension commands for all detected agents."""
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
return self._registrar.register_commands_for_all_agents(
|
||||
manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
)
|
||||
|
||||
def unregister_commands(
|
||||
@@ -1660,18 +1688,25 @@ class CommandRegistrar:
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
project_root: Path
|
||||
project_root: Path,
|
||||
link_outputs: bool = False,
|
||||
) -> List[str]:
|
||||
"""Register extension commands for Claude Code agent."""
|
||||
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
|
||||
return self.register_commands_for_agent(
|
||||
"claude", manifest, extension_dir, project_root, link_outputs=link_outputs
|
||||
)
|
||||
|
||||
|
||||
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 +1720,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 +1736,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 +1765,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():
|
||||
|
||||
@@ -61,6 +61,7 @@ def _register_builtins() -> None:
|
||||
from .gemini import GeminiIntegration
|
||||
from .generic import GenericIntegration
|
||||
from .goose import GooseIntegration
|
||||
from .hermes import HermesIntegration
|
||||
from .iflow import IflowIntegration
|
||||
from .junie import JunieIntegration
|
||||
from .kilocode import KilocodeIntegration
|
||||
@@ -93,6 +94,7 @@ def _register_builtins() -> None:
|
||||
_register(GeminiIntegration())
|
||||
_register(GenericIntegration())
|
||||
_register(GooseIntegration())
|
||||
_register(HermesIntegration())
|
||||
_register(IflowIntegration())
|
||||
_register(JunieIntegration())
|
||||
_register(KilocodeIntegration())
|
||||
|
||||
@@ -5,6 +5,7 @@ Antigravity uses ``.agents/skills/speckit-<name>/SKILL.md`` layout (enforced sin
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -13,6 +14,15 @@ from ..base import SkillsIntegration
|
||||
if TYPE_CHECKING:
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so agy maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
# Without this, agy 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 AgyIntegration(SkillsIntegration):
|
||||
@@ -23,8 +33,8 @@ class AgyIntegration(SkillsIntegration):
|
||||
"name": "Antigravity",
|
||||
"folder": ".agents/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
"install_url": "https://antigravity.google/",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".agents/skills",
|
||||
@@ -34,6 +44,54 @@ class AgyIntegration(SkillsIntegration):
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
@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 build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# agy does not support --model or JSON output; both params are ignored
|
||||
return [self._resolve_executable(), "--print", prompt]
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -49,4 +107,21 @@ class AgyIntegration(SkillsIntegration):
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
created = super().setup(project_root, manifest, parsed_options=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
|
||||
|
||||
@@ -13,7 +13,10 @@ Provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
@@ -25,6 +28,12 @@ import yaml
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationOption
|
||||
@@ -138,6 +147,65 @@ class IntegrationBase(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the executable for this integration's CLI tool.
|
||||
|
||||
Checks ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE`` first, allowing
|
||||
operators to override the binary path without modifying the
|
||||
integration configuration — useful when the tool is installed in
|
||||
a non-standard location or a specific version must be pinned.
|
||||
Hyphens in the integration key are replaced with underscores and
|
||||
the key is uppercased so that, for example, ``kiro-cli`` maps to
|
||||
``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``.
|
||||
|
||||
Falls back to ``self.key`` when the env var is unset or
|
||||
whitespace-only so existing behaviour is unchanged.
|
||||
|
||||
See issue #2596.
|
||||
"""
|
||||
env_name = (
|
||||
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE"
|
||||
)
|
||||
override = os.environ.get(env_name, "").strip()
|
||||
return override if override else self.key
|
||||
|
||||
def _apply_extra_args_env_var(self, args: list[str]) -> None:
|
||||
"""Append `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` env-var value to *args*.
|
||||
|
||||
Operators can inject extra CLI flags into the spawned agent
|
||||
subprocess by setting an env var named for the integration key,
|
||||
e.g. `SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS="--dangerously-skip-permissions"`.
|
||||
The `INTEGRATION` segment scopes the variable to this subsystem
|
||||
so it does not collide with other Spec Kit env-var namespaces.
|
||||
Hyphens in the integration key are replaced with underscores
|
||||
and the key is uppercased
|
||||
(e.g. `kiro-cli` → `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`).
|
||||
|
||||
Useful in CI / non-interactive contexts where the spawned agent
|
||||
needs flags that change its prompt-handling behaviour.
|
||||
Default behaviour (env var unset or whitespace-only) is a no-op
|
||||
— *args* is unchanged. Multi-token values are parsed via
|
||||
`shlex.split`.
|
||||
|
||||
See issue #2595.
|
||||
"""
|
||||
env_name = (
|
||||
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXTRA_ARGS"
|
||||
)
|
||||
extra = os.environ.get(env_name, "").strip()
|
||||
if not extra:
|
||||
return
|
||||
try:
|
||||
tokens = shlex.split(extra)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"{env_name} is not parseable as a POSIX-quoted command line "
|
||||
f"(value: {extra!r}). shlex reported: {exc}. "
|
||||
f"Use single or double quotes to group multi-word values, e.g. "
|
||||
f'{env_name}=\'--flag "value with spaces"\'.'
|
||||
) from exc
|
||||
args.extend(tokens)
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Build the native slash-command invocation for a Spec Kit command.
|
||||
|
||||
@@ -482,6 +550,91 @@ class IntegrationBase(ABC):
|
||||
lines.append(f"at {plan_path}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _agent_context_extension_enabled(project_root: Path) -> bool:
|
||||
"""Return whether the bundled ``agent-context`` extension is enabled.
|
||||
|
||||
The extension is the single source of truth for managing coding
|
||||
agent context/instruction files (e.g. ``CLAUDE.md``,
|
||||
``.github/copilot-instructions.md``).
|
||||
|
||||
Returns ``True`` (enabled) when:
|
||||
- the extension registry does not exist (legacy project, backwards
|
||||
compatibility), or
|
||||
- the registry has no ``agent-context`` entry (older project layout
|
||||
predating the extension), or
|
||||
- the entry is present and not explicitly disabled.
|
||||
|
||||
Returns ``False`` only when an entry exists with ``enabled: false``.
|
||||
"""
|
||||
registry_path = (
|
||||
project_root / ".specify" / "extensions" / ".registry"
|
||||
)
|
||||
if not registry_path.exists():
|
||||
return True
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return True
|
||||
if not isinstance(data, dict):
|
||||
return True
|
||||
extensions = data.get("extensions")
|
||||
if not isinstance(extensions, dict):
|
||||
return True
|
||||
entry = extensions.get("agent-context")
|
||||
if not isinstance(entry, dict):
|
||||
return True
|
||||
return entry.get("enabled", True) is not False
|
||||
|
||||
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
|
||||
"""Return the (start, end) context markers to use for *project_root*.
|
||||
|
||||
Reads ``context_markers.start`` / ``context_markers.end`` from the
|
||||
agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present. Falls back to the class-level constants
|
||||
``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is
|
||||
missing, the section is absent, or the values are not non-empty
|
||||
strings.
|
||||
"""
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
start = self.CONTEXT_MARKER_START
|
||||
end = self.CONTEXT_MARKER_END
|
||||
config_path = (
|
||||
project_root
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
try:
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
cfg = yaml.safe_load(raw)
|
||||
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
|
||||
return start, end
|
||||
markers = cfg.get("context_markers") if isinstance(cfg, dict) else None
|
||||
if isinstance(markers, dict):
|
||||
cm_start = markers.get("start")
|
||||
cm_end = markers.get("end")
|
||||
s_valid = isinstance(cm_start, str) and cm_start
|
||||
e_valid = isinstance(cm_end, str) and cm_end
|
||||
if not s_valid and cm_start is not None:
|
||||
console.print(
|
||||
f"[yellow]agent-context: ignoring invalid context_markers.start "
|
||||
f"({cm_start!r}), using default[/yellow]"
|
||||
)
|
||||
if not e_valid and cm_end is not None:
|
||||
console.print(
|
||||
f"[yellow]agent-context: ignoring invalid context_markers.end "
|
||||
f"({cm_end!r}), using default[/yellow]"
|
||||
)
|
||||
if s_valid:
|
||||
start = cm_start # type: ignore[assignment]
|
||||
if e_valid:
|
||||
end = cm_end # type: ignore[assignment]
|
||||
return start, end
|
||||
|
||||
def upsert_context_section(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -490,34 +643,54 @@ class IntegrationBase(ABC):
|
||||
"""Create or update the managed section in the agent context file.
|
||||
|
||||
If the context file does not exist it is created with just the
|
||||
managed section. If it exists, the content between
|
||||
``<!-- SPECKIT START -->`` and ``<!-- SPECKIT END -->`` markers
|
||||
is replaced (or appended when no markers are found).
|
||||
managed section. If it exists, the content between the configured
|
||||
start/end markers (default ``<!-- SPECKIT START -->`` /
|
||||
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
|
||||
are found. Markers are read from the agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present, falling back to the class-level constants.
|
||||
|
||||
Returns the path to the context file, or ``None`` when
|
||||
``context_file`` is not set.
|
||||
``context_file`` is not set or the ``agent-context`` extension is
|
||||
disabled.
|
||||
"""
|
||||
if not self.context_file:
|
||||
return None
|
||||
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return None
|
||||
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
console.print(
|
||||
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
|
||||
"integration setup will be disabled in v0.12.0. Context file "
|
||||
"management has moved to the bundled [bold]agent-context[/bold] "
|
||||
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
|
||||
"to opt out early.",
|
||||
highlight=False,
|
||||
)
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
ctx_path = project_root / self.context_file
|
||||
section = (
|
||||
f"{self.CONTEXT_MARKER_START}\n"
|
||||
f"{marker_start}\n"
|
||||
f"{self._build_context_section(plan_path)}\n"
|
||||
f"{self.CONTEXT_MARKER_END}\n"
|
||||
f"{marker_end}\n"
|
||||
)
|
||||
|
||||
if ctx_path.exists():
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
|
||||
# Replace existing section (include the end marker + newline)
|
||||
end_of_marker = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
end_of_marker = end_idx + len(marker_end)
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
@@ -529,7 +702,7 @@ class IntegrationBase(ABC):
|
||||
new_content = content[:start_idx] + section
|
||||
elif end_idx != -1:
|
||||
# Corrupted: end marker without start — replace BOF through end marker
|
||||
end_of_marker = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
end_of_marker = end_idx + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
@@ -563,20 +736,27 @@ class IntegrationBase(ABC):
|
||||
"""Remove the managed section from the agent context file.
|
||||
|
||||
Returns ``True`` if the section was found and removed. If the
|
||||
file becomes empty (or whitespace-only) after removal it is
|
||||
deleted.
|
||||
file becomes empty (or whitespace-only) after removal it is deleted.
|
||||
Markers are read from the agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present, falling back to the class-level constants.
|
||||
"""
|
||||
if not self.context_file:
|
||||
return False
|
||||
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return False
|
||||
|
||||
ctx_path = project_root / self.context_file
|
||||
if not ctx_path.exists():
|
||||
return False
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
@@ -587,7 +767,7 @@ class IntegrationBase(ABC):
|
||||
return False
|
||||
|
||||
removal_start = start_idx
|
||||
removal_end = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
removal_end = end_idx + len(marker_end)
|
||||
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if removal_end < len(content) and content[removal_end] == "\r":
|
||||
@@ -850,7 +1030,8 @@ class MarkdownIntegration(IntegrationBase):
|
||||
) -> list[str] | None:
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
@@ -937,7 +1118,8 @@ class TomlIntegration(IntegrationBase):
|
||||
) -> list[str] | None:
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
@@ -1355,7 +1537,8 @@ class SkillsIntegration(IntegrationBase):
|
||||
) -> list[str] | None:
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
@@ -1391,15 +1574,53 @@ class SkillsIntegration(IntegrationBase):
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
@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 individual instructions that already have the note immediately
|
||||
above them.
|
||||
"""
|
||||
note = _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
previous_lines = content[:m.start()].splitlines()
|
||||
if previous_lines and previous_lines[-1] == indent + note:
|
||||
return m.group(0)
|
||||
# ``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
|
||||
+ note
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^([ \t]*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Post-process a SKILL.md file's content after generation.
|
||||
|
||||
Called by external skill generators (presets, extensions) to let
|
||||
the integration inject agent-specific frontmatter or body
|
||||
transformations. The default implementation returns *content*
|
||||
unchanged. Subclasses may override — see ``ClaudeIntegration``.
|
||||
transformations. The base implementation injects shared skills
|
||||
guidance for converting dotted hook command names to hyphenated
|
||||
slash commands. Subclasses may override — see ``ClaudeIntegration``.
|
||||
"""
|
||||
return content
|
||||
return self._inject_hook_command_note(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
@@ -1502,6 +1723,8 @@ class SkillsIntegration(IntegrationBase):
|
||||
f"{processed_body}"
|
||||
)
|
||||
|
||||
skill_content = self.post_process_skill_content(skill_content)
|
||||
|
||||
# Write speckit-<name>/SKILL.md
|
||||
skill_dir = skills_dir / skill_name
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
|
||||
@@ -5,21 +5,11 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so Claude maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
# Mapping of command template stem → argument-hint text shown inline
|
||||
# when a user invokes the slash command in Claude Code.
|
||||
ARGUMENT_HINTS: dict[str, str] = {
|
||||
@@ -159,41 +149,11 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
@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 = m.group(3)
|
||||
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 Claude-specific frontmatter flags and hook notes."""
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
updated = super().post_process_skill_content(content)
|
||||
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||
updated = self._inject_hook_command_note(updated)
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
@@ -203,10 +163,9 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
|
||||
"""Install Claude skills, then inject argument-hints."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Post-process generated skill files
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
|
||||
for path in created:
|
||||
@@ -221,7 +180,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_skill_content(content)
|
||||
updated = content
|
||||
|
||||
# Inject argument-hint if available for this skill
|
||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||
|
||||
@@ -37,7 +37,10 @@ class CodexIntegration(SkillsIntegration):
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
|
||||
args: list[str] = ["codex", "exec", prompt]
|
||||
# Resolve argv[0] via the shared executable resolver so operators can
|
||||
# override the binary with SPECKIT_INTEGRATION_CODEX_EXECUTABLE.
|
||||
args: list[str] = [self._resolve_executable(), "exec", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
|
||||
@@ -24,6 +24,16 @@ from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
def _copilot_executable() -> str:
|
||||
"""Return the executable name for Copilot CLI on this platform.
|
||||
|
||||
On Windows, subprocess invocation is reliable with `copilot.cmd`.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return "copilot.cmd"
|
||||
return "copilot"
|
||||
|
||||
|
||||
def _allow_all() -> bool:
|
||||
"""Return True if the Copilot CLI should run with full permissions.
|
||||
|
||||
@@ -124,6 +134,18 @@ class CopilotIntegration(IntegrationBase):
|
||||
),
|
||||
]
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the Copilot CLI executable, respecting the env-var override.
|
||||
|
||||
Checks ``SPECKIT_INTEGRATION_COPILOT_EXECUTABLE`` first. Falls
|
||||
back to the platform-specific default from ``_copilot_executable()``
|
||||
(``copilot.cmd`` on Windows, ``copilot`` elsewhere) so that
|
||||
existing behaviour is preserved when the env var is unset.
|
||||
"""
|
||||
env_name = "SPECKIT_INTEGRATION_COPILOT_EXECUTABLE"
|
||||
override = os.environ.get(env_name, "").strip()
|
||||
return override if override else _copilot_executable()
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -138,7 +160,8 @@ class CopilotIntegration(IntegrationBase):
|
||||
# Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var
|
||||
# (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS
|
||||
# is also honoured as a fallback.
|
||||
args = ["copilot", "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if _allow_all():
|
||||
args.append("--yolo")
|
||||
if model:
|
||||
@@ -206,7 +229,12 @@ class CopilotIntegration(IntegrationBase):
|
||||
agent_name = f"speckit.{stem}"
|
||||
prompt = args or ""
|
||||
|
||||
cli_args = ["copilot", "-p", prompt]
|
||||
cli_args = [self._resolve_executable(), "-p", prompt]
|
||||
# Honour SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS for real workflow
|
||||
# runs. `dispatch_command` builds cli_args inline rather than
|
||||
# going through `build_exec_args`, so the hook must be invoked
|
||||
# here too — otherwise the env var is silently ignored.
|
||||
self._apply_extra_args_env_var(cli_args)
|
||||
if not skills_mode:
|
||||
cli_args.extend(["--agent", agent_name])
|
||||
if _allow_all():
|
||||
@@ -255,12 +283,13 @@ class CopilotIntegration(IntegrationBase):
|
||||
return f"speckit.{template_name}.agent.md"
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
|
||||
"""Inject shared hook guidance and Copilot ``mode:`` frontmatter.
|
||||
|
||||
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
||||
Copilot can associate the skill with its agent mode.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
updated = _CopilotSkillsHelper().post_process_skill_content(content)
|
||||
lines = updated.splitlines(keepends=True)
|
||||
|
||||
# Extract skill name from frontmatter to derive the mode value
|
||||
dash_count = 0
|
||||
@@ -274,7 +303,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
continue
|
||||
if dash_count == 1:
|
||||
if stripped.startswith("mode:"):
|
||||
return content # already present
|
||||
return updated # already present
|
||||
if stripped.startswith("name:"):
|
||||
# Parse: name: "speckit-plan" → speckit.plan
|
||||
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
@@ -285,7 +314,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
skill_name = val
|
||||
|
||||
if not skill_name:
|
||||
return content
|
||||
return updated
|
||||
|
||||
# Inject mode: before the closing --- of frontmatter
|
||||
out: list[str] = []
|
||||
|
||||
@@ -48,7 +48,8 @@ class DevinIntegration(SkillsIntegration):
|
||||
stdout instead of structured JSON. ``requires_cli=True`` is
|
||||
kept on the integration for tool detection.
|
||||
"""
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
return args
|
||||
|
||||
280
src/specify_cli/integrations/hermes/__init__.py
Normal file
280
src/specify_cli/integrations/hermes/__init__.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Hermes Agent integration — skills-based agent.
|
||||
|
||||
Hermes Agent (https://github.com/NousResearch/hermes-agent) is an open-source
|
||||
AI agent framework by Nous Research. It stores skills in
|
||||
``~/.hermes/skills/`` (user-global) rather than a project-local directory.
|
||||
|
||||
Usage::
|
||||
|
||||
specify init my-project --integration hermes
|
||||
specify init --here --ai hermes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class HermesIntegration(SkillsIntegration):
|
||||
"""Integration for Hermes Agent skills.
|
||||
|
||||
Hermes loads skills from ``~/.hermes/skills/`` (user home directory)
|
||||
rather than a project-local path. Skills are installed directly to
|
||||
the global directory — no project-local copies are created since
|
||||
Hermes discovers them globally. A project-local marker directory
|
||||
(``.hermes/skills/`` empty) is created so extension commands (e.g.
|
||||
git) can detect Hermes as an active integration. Uninstall removes
|
||||
both the marker and all global ``speckit-*`` skills, matching the
|
||||
standard integration teardown behaviour.
|
||||
"""
|
||||
|
||||
key = "hermes"
|
||||
config = {
|
||||
"name": "Hermes Agent",
|
||||
"folder": ".hermes/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://github.com/NousResearch/hermes-agent",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": "~/.hermes/skills",
|
||||
"detect_dir": ".hermes/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
|
||||
# -- Helpers -----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _hermes_home_skills_dir() -> Path:
|
||||
"""Return ``~/.hermes/skills/`` — the global skills directory."""
|
||||
return Path.home() / ".hermes" / "skills"
|
||||
|
||||
# -- Options -----------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Hermes Agent)",
|
||||
),
|
||||
]
|
||||
|
||||
# -- Setup -------------------------------------------------------------
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install command templates as global Hermes skills.
|
||||
|
||||
Writes each skill directly to
|
||||
``~/.hermes/skills/speckit-<name>/SKILL.md`` where Hermes
|
||||
discovers them at runtime. No project-local SKILL.md copies are
|
||||
created — the global directory is the single source of truth.
|
||||
A project-local marker (``.hermes/skills/`` empty) is created
|
||||
so extension commands (e.g. git) can detect Hermes as an active
|
||||
integration.
|
||||
"""
|
||||
templates = self.list_command_templates()
|
||||
if not templates:
|
||||
return []
|
||||
|
||||
# Safety check: verify manifest project_root matches (standard pattern)
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
f"manifest.project_root ({manifest.project_root}) does not match "
|
||||
f"project_root ({project_root_resolved})"
|
||||
)
|
||||
|
||||
script_type = opts.get("script_type", "sh")
|
||||
arg_placeholder = (
|
||||
self.registrar_config.get("args", "$ARGUMENTS")
|
||||
if self.registrar_config
|
||||
else "$ARGUMENTS"
|
||||
)
|
||||
|
||||
global_skills_dir = self._hermes_home_skills_dir()
|
||||
global_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
created: list[Path] = []
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
|
||||
# Derive the skill name from the template stem
|
||||
command_name = src_file.stem # e.g. "plan"
|
||||
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||
|
||||
# Parse frontmatter for description
|
||||
frontmatter: dict[str, Any] = {}
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
fm = yaml.safe_load(parts[1])
|
||||
if isinstance(fm, dict):
|
||||
frontmatter = fm
|
||||
except yaml.YAMLError:
|
||||
pass
|
||||
|
||||
# Process body through the standard template pipeline
|
||||
processed_body = self.process_template(
|
||||
raw,
|
||||
self.key,
|
||||
script_type,
|
||||
arg_placeholder,
|
||||
context_file=self.context_file or "",
|
||||
invoke_separator=self.invoke_separator,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
if processed_body.startswith("---"):
|
||||
parts = processed_body.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
processed_body = parts[2]
|
||||
|
||||
# Select description
|
||||
description = frontmatter.get("description", "")
|
||||
if not description:
|
||||
description = f"Spec Kit: {command_name} workflow"
|
||||
|
||||
# Build SKILL.md with manually formatted frontmatter
|
||||
def _quote(v: str) -> str:
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"name: {_quote(skill_name)}\n"
|
||||
f"description: {_quote(description)}\n"
|
||||
f"compatibility: "
|
||||
f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n"
|
||||
f"metadata:\n"
|
||||
f" author: {_quote('github-spec-kit')}\n"
|
||||
f" source: {_quote('templates/commands/' + src_file.name)}\n"
|
||||
f"---\n"
|
||||
f"{processed_body}"
|
||||
)
|
||||
|
||||
skill_content = self.post_process_skill_content(skill_content)
|
||||
|
||||
# Write directly to global ~/.hermes/skills/speckit-<name>/SKILL.md
|
||||
skill_dir = global_skills_dir / skill_name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
normalized = skill_content.replace("\r\n", "\n")
|
||||
skill_file.write_bytes(normalized.encode("utf-8"))
|
||||
created.append(skill_file)
|
||||
|
||||
# Upsert managed context section into the agent context file
|
||||
self.upsert_context_section(project_root)
|
||||
|
||||
# Create project-local marker directory so extension commands
|
||||
# (e.g. git) can detect Hermes as an active integration.
|
||||
# Hermes itself ignores this directory — skills live globally.
|
||||
(project_root / ".hermes" / "skills").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return created
|
||||
|
||||
# -- Uninstall ---------------------------------------------------------
|
||||
|
||||
def teardown(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> tuple[list[Path], list[Path]]:
|
||||
"""Uninstall integration files including global Hermes skills.
|
||||
|
||||
Removes the managed context section from AGENTS.md, removes the
|
||||
project-local marker directory (if empty), delegates to
|
||||
``manifest.uninstall()`` for project-local tracked files, and
|
||||
removes all ``speckit-*`` skills under ``~/.hermes/skills/``.
|
||||
|
||||
Global skills are always removed on teardown — this matches the
|
||||
standard integration behaviour where all files created by the
|
||||
integration are removed on ``specify integration uninstall``.
|
||||
"""
|
||||
# Remove managed context section from AGENTS.md
|
||||
self.remove_context_section(project_root)
|
||||
|
||||
# Delegate to manifest for project-local tracked files (scripts,
|
||||
# templates, context entries tracked in the manifest).
|
||||
removed, skipped = manifest.uninstall(project_root, force=force)
|
||||
|
||||
# Remove project-local marker directory if empty
|
||||
local_skills_dir = project_root / ".hermes" / "skills"
|
||||
if local_skills_dir.is_dir() and not any(local_skills_dir.iterdir()):
|
||||
local_skills_dir.rmdir()
|
||||
hermes_dir = project_root / ".hermes"
|
||||
if hermes_dir.is_dir() and not any(hermes_dir.iterdir()):
|
||||
hermes_dir.rmdir()
|
||||
|
||||
# Remove all global Hermes skills for speckit — these are always
|
||||
# removed on uninstall regardless of the force flag, matching the
|
||||
# standard behaviour where all integration files are cleaned up.
|
||||
global_skills_dir = self._hermes_home_skills_dir()
|
||||
if global_skills_dir.is_dir():
|
||||
for skill_dir in sorted(global_skills_dir.iterdir()):
|
||||
if skill_dir.is_dir() and skill_dir.name.startswith("speckit-"):
|
||||
try:
|
||||
rmtree(skill_dir)
|
||||
removed.append(skill_dir)
|
||||
except OSError:
|
||||
skipped.append(skill_dir)
|
||||
|
||||
return removed, skipped
|
||||
|
||||
# -- CLI dispatch ------------------------------------------------------
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build Hermes CLI invocation for programmatic dispatch.
|
||||
|
||||
Uses ``hermes chat -Q -q`` for one-shot queries in quiet mode,
|
||||
mapping slash-command invocations to the appropriate skill-based
|
||||
dispatch.
|
||||
"""
|
||||
args = [self._resolve_executable(), "chat", "-Q"]
|
||||
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
args.append("--json")
|
||||
|
||||
# If prompt starts with a slash command, pass it directly
|
||||
# so Hermes can dispatch to the appropriate skill.
|
||||
if prompt.startswith("/"):
|
||||
command, _, remainder = prompt[1:].partition(" ")
|
||||
if command:
|
||||
args.extend(["-s", command])
|
||||
if remainder:
|
||||
args.extend(["-q", remainder])
|
||||
else:
|
||||
args.extend(["-q", prompt])
|
||||
else:
|
||||
args.extend(["-q", prompt])
|
||||
|
||||
return args
|
||||
@@ -28,7 +28,12 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
args = [self.key, "run"]
|
||||
args = [self._resolve_executable(), "run"]
|
||||
# Apply operator-injected extra args before the prompt-derived
|
||||
# --command and the canonical --format/-m flags so Spec Kit's
|
||||
# later appends remain authoritative under repeated-flag CLI
|
||||
# semantics.
|
||||
self._apply_extra_args_env_var(args)
|
||||
|
||||
message = prompt
|
||||
if prompt.startswith("/"):
|
||||
|
||||
@@ -81,13 +81,13 @@ class VibeIntegration(SkillsIntegration):
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""
|
||||
Inject Vibe-specific frontmatter flags:
|
||||
Inject shared hook guidance and Vibe-specific frontmatter flags:
|
||||
- user-invocable: allows the skill to be invoked by the user (not just other agents)
|
||||
"""
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
updated = super().post_process_skill_content(content)
|
||||
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
@@ -107,27 +107,4 @@ class VibeIntegration(SkillsIntegration):
|
||||
err=True,
|
||||
)
|
||||
|
||||
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
|
||||
# Post-process generated skill files
|
||||
skills_dir = self.skills_dest(project_root).resolve()
|
||||
|
||||
for path in created:
|
||||
# Only touch SKILL.md files under the skills directory
|
||||
try:
|
||||
path.resolve().relative_to(skills_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.name != "SKILL.md":
|
||||
continue
|
||||
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_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
|
||||
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
|
||||
|
||||
@@ -28,6 +28,7 @@ from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
@@ -1048,9 +1049,9 @@ class PresetManager:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
desc = SKILL_DESCRIPTIONS.get(
|
||||
desc = fm.get("description", "") or SKILL_DESCRIPTIONS.get(
|
||||
short_name.replace(".", "-"),
|
||||
fm.get("description", f"Command: {short_name}"),
|
||||
f"Command: {short_name}",
|
||||
)
|
||||
init_opts = load_init_options(self.project_root)
|
||||
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
|
||||
@@ -1058,6 +1059,9 @@ class PresetManager:
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, fm, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
)
|
||||
fm_data = registrar.build_skill_frontmatter(
|
||||
selected_ai if isinstance(selected_ai, str) else "",
|
||||
skill_name, desc,
|
||||
@@ -1097,37 +1101,24 @@ class PresetManager:
|
||||
def _get_skills_dir(self) -> Optional[Path]:
|
||||
"""Return the active skills directory for preset skill overrides.
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills
|
||||
are enabled and which agent was selected, then delegates to
|
||||
the module-level ``_get_skills_dir()`` helper for the concrete path.
|
||||
Delegates to :func:`resolve_active_skills_dir` which reads
|
||||
init-options, applies the Kimi native-skills fallback, and
|
||||
safely creates the directory when ``ai_skills`` is enabled.
|
||||
|
||||
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
|
||||
``.kimi/skills`` exists, presets should still propagate command
|
||||
overrides to skills even when ``ai_skills`` is false.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills were not
|
||||
enabled and no native-skills fallback applies.
|
||||
Returns ``None`` (instead of raising) when the directory cannot
|
||||
be created due to symlink, containment, or permission issues so
|
||||
that callers can fall back gracefully.
|
||||
"""
|
||||
from . import load_init_options, _get_skills_dir
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
opts = {}
|
||||
agent = opts.get("ai")
|
||||
if not isinstance(agent, str) or not agent:
|
||||
from . import resolve_active_skills_dir, _print_cli_warning
|
||||
try:
|
||||
return resolve_active_skills_dir(self.project_root)
|
||||
except (ValueError, OSError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", None, exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
if not ai_skills_enabled and agent != "kimi":
|
||||
return None
|
||||
|
||||
skills_dir = _get_skills_dir(self.project_root, agent)
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
|
||||
return skills_dir
|
||||
|
||||
@staticmethod
|
||||
def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
|
||||
"""Return the modern and legacy skill directory names for a command."""
|
||||
@@ -1147,6 +1138,23 @@ class PresetManager:
|
||||
title_name = title_name[len("speckit."):]
|
||||
return title_name.replace(".", " ").replace("-", " ").title()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_skill_command_refs(
|
||||
body: str, registrar: "CommandRegistrar", selected_ai: str
|
||||
) -> str:
|
||||
"""Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations.
|
||||
|
||||
Looks up the agent's invoke separator and rewrites each
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholder into the matching
|
||||
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
|
||||
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
|
||||
applies via ``CommandRegistrar.register_commands()``.
|
||||
"""
|
||||
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
|
||||
"invoke_separator", "."
|
||||
)
|
||||
return IntegrationBase.resolve_command_refs(body, separator)
|
||||
|
||||
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Index extension-backed skill restore data by skill directory name."""
|
||||
from .extensions import ExtensionManifest, ValidationError
|
||||
@@ -1314,15 +1322,16 @@ class PresetManager:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
frontmatter = dict(frontmatter)
|
||||
frontmatter["description"] = enhanced_desc
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(body, registrar, selected_ai)
|
||||
|
||||
for target_skill_name in target_skill_names:
|
||||
skill_subdir = skills_dir / target_skill_name
|
||||
@@ -1415,11 +1424,14 @@ class PresetManager:
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
enhanced_desc = original_desc or SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
|
||||
frontmatter_data = registrar.build_skill_frontmatter(
|
||||
@@ -1452,6 +1464,9 @@ class PresetManager:
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
body = self._resolve_skill_command_refs(
|
||||
body, registrar, selected_ai
|
||||
)
|
||||
|
||||
command_name = extension_restore["command_name"]
|
||||
title_name = self._skill_title_from_command(command_name)
|
||||
@@ -1903,12 +1918,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):
|
||||
|
||||
@@ -88,7 +88,13 @@ def _shared_relative_path(project_path: Path, dest: Path) -> Path:
|
||||
return rel
|
||||
|
||||
|
||||
def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None:
|
||||
def _ensure_safe_shared_directory(
|
||||
project_path: Path,
|
||||
directory: Path,
|
||||
*,
|
||||
create: bool = True,
|
||||
context: str = "shared infrastructure directory",
|
||||
) -> None:
|
||||
"""Create a shared infra directory without following symlinked parents."""
|
||||
root = project_path.resolve()
|
||||
rel = _shared_relative_path(project_path, directory)
|
||||
@@ -98,24 +104,24 @@ def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
|
||||
if current.exists():
|
||||
if not current.is_dir():
|
||||
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
||||
raise ValueError(f"{context.capitalize()} path is not a directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
|
||||
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
|
||||
continue
|
||||
if not create:
|
||||
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
|
||||
raise ValueError(f"{context.capitalize()} does not exist: {label}")
|
||||
current.mkdir()
|
||||
if current.is_symlink():
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
|
||||
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
|
||||
|
||||
|
||||
def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None:
|
||||
@@ -363,7 +369,16 @@ def install_shared_infra(
|
||||
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
rel,
|
||||
content.encode("utf-8"),
|
||||
src_path.stat().st_mode & 0o777,
|
||||
)
|
||||
)
|
||||
|
||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if templates_src.is_dir():
|
||||
|
||||
@@ -11,6 +11,7 @@ The engine is the orchestrator that:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -19,6 +20,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 +148,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.")
|
||||
@@ -392,7 +426,7 @@ class WorkflowEngine:
|
||||
inputs:
|
||||
User-provided input values.
|
||||
run_id:
|
||||
Optional run ID (auto-generated if not provided).
|
||||
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -400,8 +434,14 @@ class WorkflowEngine:
|
||||
"""
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
effective_run_id = run_id
|
||||
if effective_run_id is None:
|
||||
env_run_id = os.environ.get("SPECKIT_WORKFLOW_RUN_ID", "").strip()
|
||||
if env_run_id:
|
||||
effective_run_id = env_run_id
|
||||
|
||||
state = RunState(
|
||||
run_id=run_id,
|
||||
run_id=effective_run_id,
|
||||
workflow_id=definition.id,
|
||||
project_root=self.project_root,
|
||||
)
|
||||
@@ -640,22 +680,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 +758,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 +834,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 +857,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 = (
|
||||
|
||||
@@ -102,6 +102,15 @@ def _build_namespace(context: Any) -> dict[str, Any]:
|
||||
ns["item"] = context.item
|
||||
if hasattr(context, "fan_in"):
|
||||
ns["fan_in"] = context.fan_in or {}
|
||||
# Engine-managed runtime metadata. Always present (even outside a
|
||||
# run) so templates referencing it never error: `run_id` falls back
|
||||
# to an empty string when no run is active (dry-run, validation,
|
||||
# ad-hoc evaluator usage). The value is the same one Spec Kit
|
||||
# prints as `Run ID:` at the end of `workflow run` — auto-generated
|
||||
# runs use an 8-character uuid4 hex; operator-supplied ids may be
|
||||
# any alphanumeric string with hyphens or underscores.
|
||||
run_id = getattr(context, "run_id", None) or ""
|
||||
ns["context"] = {"run_id": run_id}
|
||||
return ns
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
@@ -106,13 +108,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
5. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
@@ -123,7 +125,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
6. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
@@ -241,9 +243,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
|
||||
@@ -66,7 +66,9 @@ Execution steps:
|
||||
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
@@ -122,7 +124,7 @@ Execution steps:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
@@ -133,7 +135,7 @@ Execution steps:
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
@@ -169,7 +171,7 @@ Execution steps:
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
6. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
@@ -187,7 +189,7 @@ Execution steps:
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
7. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
@@ -195,15 +197,26 @@ Execution steps:
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
8. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
|
||||
- Suggested next command.
|
||||
9. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
|
||||
- If it does NOT exist, skip this step silently.
|
||||
- If it exists:
|
||||
1. Read the checklist file.
|
||||
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
|
||||
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
|
||||
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
|
||||
5. For each checkbox item, update only if the checked/unchecked state actually changes:
|
||||
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
|
||||
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
|
||||
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
|
||||
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
|
||||
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
|
||||
- **Newly passing**: items that changed from unchecked to checked.
|
||||
- **Regressions**: items that changed from checked to unchecked.
|
||||
- **Still unchecked**: items that remain unchecked.
|
||||
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
|
||||
|
||||
Behavior rules:
|
||||
|
||||
@@ -217,17 +230,27 @@ Behavior rules:
|
||||
|
||||
Context for prioritization: {ARGS}
|
||||
|
||||
## Post-Execution Checks
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
**Check for extension hooks (after clarification)**:
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
@@ -239,12 +262,21 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
## Completion Report
|
||||
|
||||
Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `__SPECKIT_COMMAND_PLAN__` or run `__SPECKIT_COMMAND_CLARIFY__` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Spec ambiguities identified and clarifications integrated into spec file
|
||||
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
|
||||
|
||||
@@ -168,35 +168,49 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `__SPECKIT_COMMAND_TASKS__` first to regenerate the task list.
|
||||
|
||||
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_implement` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report final status with summary of completed work.
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] All tasks in tasks.md completed and marked `[X]`
|
||||
- [ ] Implementation validated against specification, plan, and test coverage
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with summary of completed work
|
||||
|
||||
@@ -70,36 +70,42 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Phase 1: Update agent context by running the agent script
|
||||
- Re-evaluate Constitution Check post-design
|
||||
|
||||
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_plan` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -150,3 +156,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
|
||||
- ERROR on gate failures or unresolved clarifications
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Plan workflow executed and design artifacts generated
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with branch, plan path, and generated artifacts
|
||||
|
||||
@@ -109,7 +109,9 @@ Given that feature description, do this:
|
||||
|
||||
4. Load `templates/spec-template.md` to understand required sections.
|
||||
|
||||
5. Follow this execution flow:
|
||||
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
6. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
@@ -183,7 +185,7 @@ Given that feature description, do this:
|
||||
|
||||
c. **Handle Validation Results**:
|
||||
|
||||
- **If all items pass**: Mark checklist complete and proceed to step 8
|
||||
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
|
||||
|
||||
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||
1. List the failing items and specific issues
|
||||
@@ -228,40 +230,46 @@ Given that feature description, do this:
|
||||
|
||||
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||
|
||||
8. **Report completion** to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_specify` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Report completion to the user with:
|
||||
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||
- `SPEC_FILE` — the spec file path
|
||||
- Checklist results summary
|
||||
- Readiness for the next phase (`__SPECKIT_COMMAND_CLARIFY__` or `__SPECKIT_COMMAND_PLAN__`)
|
||||
|
||||
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||
|
||||
@@ -325,3 +333,9 @@ Success criteria must be:
|
||||
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||
- "React components render efficiently" (framework-specific)
|
||||
- "Redis cache hit rate above 80%" (technology-specific)
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with feature directory, spec file path, and checklist results
|
||||
|
||||
@@ -63,6 +63,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
@@ -89,42 +90,48 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Parallel execution examples per story
|
||||
- Implementation strategy section (MVP first, incremental delivery)
|
||||
|
||||
5. **Report**: Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
## Mandatory Post-Execution Hooks
|
||||
|
||||
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
**You MUST complete this section before reporting completion to the user.**
|
||||
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
Check if `.specify/extensions.yml` exists in the project root.
|
||||
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
|
||||
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
|
||||
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
|
||||
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||
- For each executable hook, output the following based on its `optional` flag:
|
||||
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
- **Mandatory hook** (`optional: false`):
|
||||
```
|
||||
## Extension Hooks
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
**Automatic Hook**: {extension}
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
**Optional Hook**: {extension}
|
||||
Command: `/{command}`
|
||||
Description: {description}
|
||||
|
||||
Prompt: {prompt}
|
||||
To execute: `/{command}`
|
||||
```
|
||||
|
||||
## Completion Report
|
||||
|
||||
Output path to generated tasks.md and summary:
|
||||
- Total task count
|
||||
- Task count per user story
|
||||
- Parallel opportunities identified
|
||||
- Independent test criteria for each story
|
||||
- Suggested MVP scope (typically just User Story 1)
|
||||
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||
|
||||
Context for task generation: {ARGS}
|
||||
|
||||
@@ -201,3 +208,9 @@ Every task MUST strictly follow this format:
|
||||
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||
- Each phase should be a complete, independently testable increment
|
||||
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||
|
||||
## Done When
|
||||
|
||||
- [ ] tasks.md generated with all phases, task IDs, and file paths
|
||||
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
|
||||
- [ ] Completion reported to user with task count, story breakdown, and MVP scope
|
||||
|
||||
@@ -51,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
|
||||
455
tests/extensions/test_extension_agent_context.py
Normal file
455
tests/extensions/test_extension_agent_context.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Tests for the bundled ``agent-context`` extension and related plumbing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli import (
|
||||
_load_agent_context_config,
|
||||
_save_agent_context_config,
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
|
||||
|
||||
|
||||
def _write_ext_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write a minimal agent-context extension config."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
},
|
||||
),
|
||||
}
|
||||
_save_agent_context_config(project_root, cfg)
|
||||
|
||||
|
||||
# ── Bundled extension layout ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionLayout:
|
||||
"""The bundled agent-context extension ships a complete package."""
|
||||
|
||||
def test_extension_yml_exists(self):
|
||||
assert (EXT_DIR / "extension.yml").is_file()
|
||||
|
||||
def test_extension_yml_has_required_fields(self):
|
||||
manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text())
|
||||
assert manifest["extension"]["id"] == "agent-context"
|
||||
assert manifest["extension"]["name"] == "Coding Agent Context"
|
||||
assert manifest["extension"]["author"] == "spec-kit-core"
|
||||
# Provides at least the manual update command
|
||||
commands = {c["name"] for c in manifest["provides"]["commands"]}
|
||||
assert "speckit.agent-context.update" in commands
|
||||
|
||||
def test_readme_exists(self):
|
||||
readme = EXT_DIR / "README.md"
|
||||
assert readme.is_file()
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
assert "Coding Agent Context Extension" in text
|
||||
|
||||
def test_config_template_exists(self):
|
||||
cfg = EXT_DIR / "agent-context-config.yml"
|
||||
assert cfg.is_file()
|
||||
parsed = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
assert "context_file" in parsed
|
||||
assert "context_markers" in parsed
|
||||
|
||||
def test_command_file_exists(self):
|
||||
cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md"
|
||||
assert cmd.is_file()
|
||||
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
|
||||
|
||||
def test_bundled_scripts_exist(self):
|
||||
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
|
||||
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
|
||||
|
||||
def test_bash_script_reads_extension_config(self):
|
||||
text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# The script must consult the extension config, not init-options.json
|
||||
assert "agent-context-config.yml" in text
|
||||
assert "context_file" in text
|
||||
assert "context_markers" in text
|
||||
|
||||
|
||||
# ── Catalog registration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogEntry:
|
||||
def test_catalog_lists_agent_context_as_bundled(self):
|
||||
catalog = json.loads(
|
||||
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
entry = catalog["extensions"]["agent-context"]
|
||||
assert entry["bundled"] is True
|
||||
assert entry["id"] == "agent-context"
|
||||
assert entry["author"] == "spec-kit-core"
|
||||
|
||||
|
||||
# ── Marker resolution from extension config ──────────────────────────────────
|
||||
|
||||
|
||||
class _CtxIntegration(ClaudeIntegration):
|
||||
"""Use Claude as a concrete integration with a context_file."""
|
||||
|
||||
|
||||
class TestContextMarkerResolution:
|
||||
def test_defaults_when_ext_config_missing(self, tmp_path):
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_defaults_when_markers_field_missing(self, tmp_path):
|
||||
"""Config file exists with context_file but no context_markers key."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_custom_markers_respected(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_markers={"start": "<!-- BEGIN -->", "end": "<!-- END -->"},
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == "<!-- BEGIN -->"
|
||||
assert end == "<!-- END -->"
|
||||
|
||||
def test_partial_override_falls_back_for_missing_side(self, tmp_path):
|
||||
_write_ext_config(tmp_path, context_markers={"start": "<!-- ONLY START -->"})
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == "<!-- ONLY START -->"
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_invalid_markers_fall_back(self, tmp_path):
|
||||
_write_ext_config(tmp_path, context_markers={"start": 42, "end": ""})
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
|
||||
# ── upsert_context_section / remove_context_section honor markers ───────────
|
||||
|
||||
|
||||
class TestUpsertWithCustomMarkers:
|
||||
def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration:
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
**({"context_markers": markers} if markers is not None else {}),
|
||||
)
|
||||
return _CtxIntegration()
|
||||
|
||||
def test_upsert_uses_default_markers(self, tmp_path):
|
||||
i = self._setup(tmp_path)
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is not None
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END in text
|
||||
|
||||
def test_upsert_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
i.upsert_context_section(tmp_path)
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "<!-- BEGIN -->" in text
|
||||
assert "<!-- END -->" in text
|
||||
# Defaults must not appear
|
||||
assert IntegrationBase.CONTEXT_MARKER_START not in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END not in text
|
||||
|
||||
def test_upsert_replaces_existing_custom_section(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
ctx.write_text(
|
||||
"# header\n\n<!-- BEGIN -->\nold body\n<!-- END -->\n\nfooter\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md")
|
||||
text = ctx.read_text(encoding="utf-8")
|
||||
assert "old body" not in text
|
||||
assert "specs/001-foo/plan.md" in text
|
||||
assert text.startswith("# header\n")
|
||||
assert "footer" in text
|
||||
|
||||
def test_remove_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
ctx.write_text(
|
||||
"preamble\n\n<!-- BEGIN -->\nbody\n<!-- END -->\nepilogue\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
removed = i.remove_context_section(tmp_path)
|
||||
assert removed is True
|
||||
remaining = ctx.read_text(encoding="utf-8")
|
||||
assert "<!-- BEGIN -->" not in remaining
|
||||
assert "<!-- END -->" not in remaining
|
||||
assert "body" not in remaining
|
||||
assert "preamble" in remaining
|
||||
assert "epilogue" in remaining
|
||||
|
||||
def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path):
|
||||
# Extension config absent → default markers used. File contains only
|
||||
# custom markers — nothing should be removed.
|
||||
i = _CtxIntegration()
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
original = "x\n<!-- BEGIN -->\nbody\n<!-- END -->\n"
|
||||
ctx.write_text(original, encoding="utf-8")
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── Extension disabled gates setup/teardown ──────────────────────────────────
|
||||
|
||||
|
||||
def _write_registry(project_root: Path, *, enabled: bool) -> None:
|
||||
registry = project_root / ".specify" / "extensions" / ".registry"
|
||||
registry.parent.mkdir(parents=True, exist_ok=True)
|
||||
registry.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"version": "1.0.0",
|
||||
"enabled": enabled,
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestExtensionEnabledGate:
|
||||
def test_enabled_helper_default_when_no_registry(self, tmp_path):
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True
|
||||
|
||||
def test_enabled_helper_when_entry_present(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=True)
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True
|
||||
|
||||
def test_disabled_helper_when_entry_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False
|
||||
|
||||
def test_upsert_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is None
|
||||
assert not (tmp_path / "CLAUDE.md").exists()
|
||||
|
||||
def test_remove_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
original = (
|
||||
f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
|
||||
f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n"
|
||||
)
|
||||
ctx.write_text(original, encoding="utf-8")
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
# File must be unchanged when extension is disabled
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── Extension config writers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionConfigWriters:
|
||||
def test_clear_init_options_clears_ext_config_context_file(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_write_ext_config(tmp_path, context_file="CLAUDE.md")
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
|
||||
def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
|
||||
def test_clear_init_options_removes_legacy_context_keys_even_when_not_active(
|
||||
self, tmp_path
|
||||
):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{
|
||||
"integration": "copilot",
|
||||
"ai": "copilot",
|
||||
"context_file": "CLAUDE.md",
|
||||
"context_markers": {"start": "<!-- X -->", "end": "<!-- Y -->"},
|
||||
},
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
opts = load_init_options(tmp_path)
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["ai"] == "copilot"
|
||||
assert "context_file" not in opts
|
||||
assert "context_markers" not in opts
|
||||
|
||||
def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
# Pre-create the extension config so _update_init_options_for_integration
|
||||
# updates it (rather than skipping it when ext config doesn't exist yet).
|
||||
_write_ext_config(tmp_path, context_file="")
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i, script_type="sh")
|
||||
# init-options.json must NOT have context_file or context_markers
|
||||
opts = load_init_options(tmp_path)
|
||||
assert "context_file" not in opts
|
||||
assert "context_markers" not in opts
|
||||
# Extension config must have them
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert "context_markers" in cfg
|
||||
|
||||
def test_update_init_options_preserves_custom_markers(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="",
|
||||
context_markers={"start": "<!-- B -->", "end": "<!-- E -->"},
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i)
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_markers"] == {"start": "<!-- B -->", "end": "<!-- E -->"}
|
||||
|
||||
def test_reinit_preserves_custom_markers(self, tmp_path):
|
||||
"""specify init (reinit) must not overwrite user-customised markers."""
|
||||
from specify_cli import _update_agent_context_config_file
|
||||
|
||||
# Simulate existing project with custom markers
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_markers={"start": "<!-- CUSTOM -->", "end": "<!-- /CUSTOM -->"},
|
||||
)
|
||||
# Re-running init updates context_file but must preserve markers
|
||||
_update_agent_context_config_file(
|
||||
tmp_path, "CLAUDE.md", preserve_markers=True
|
||||
)
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_markers"] == {
|
||||
"start": "<!-- CUSTOM -->",
|
||||
"end": "<!-- /CUSTOM -->",
|
||||
}
|
||||
|
||||
|
||||
# ── Deprecation warning on upsert ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeprecationWarning:
|
||||
def test_upsert_emits_deprecation_warning(self, tmp_path, capsys):
|
||||
"""upsert_context_section must emit a deprecation notice on stdout."""
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
i = _CtxIntegration()
|
||||
_write_ext_config(tmp_path, context_file="CLAUDE.md")
|
||||
i.upsert_context_section(tmp_path)
|
||||
captured = capsys.readouterr()
|
||||
plain = strip_ansi(captured.out)
|
||||
assert "Deprecation" in plain
|
||||
assert "v0.12.0" in plain
|
||||
assert "agent-context" in plain
|
||||
|
||||
def test_upsert_no_warning_when_disabled(self, tmp_path, capsys):
|
||||
"""No deprecation warning when agent-context extension is disabled."""
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
i.upsert_context_section(tmp_path)
|
||||
captured = capsys.readouterr()
|
||||
assert "Deprecation" not in captured.out
|
||||
|
||||
|
||||
# ── Corrupt / invalid extension config ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestCorruptExtensionConfig:
|
||||
def test_marker_resolution_with_corrupt_yaml(self, tmp_path):
|
||||
"""Corrupt YAML in agent-context-config.yml falls back to defaults."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path):
|
||||
"""upsert_context_section still works when config YAML is corrupt."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is not None
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END in text
|
||||
|
||||
def test_marker_resolution_with_non_dict_yaml(self, tmp_path):
|
||||
"""Config file containing a scalar (not a dict) falls back to defaults."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("just a string\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
@@ -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
|
||||
@@ -67,7 +87,14 @@ class TestInitIntegrationFlag:
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["context_file"] == ".github/copilot-instructions.md"
|
||||
# context_file lives in the agent-context extension config, not init-options.json
|
||||
assert "context_file" not in opts
|
||||
|
||||
import yaml as _yaml
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
assert ext_cfg_path.exists(), "agent-context extension config must be created on init"
|
||||
ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8"))
|
||||
assert ext_cfg["context_file"] == ".github/copilot-instructions.md"
|
||||
|
||||
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
||||
|
||||
@@ -174,6 +201,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
|
||||
@@ -279,10 +342,11 @@ class TestInitIntegrationFlag:
|
||||
_install_shared_infra(project, "sh", force=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "already exist and were not updated" in captured.out
|
||||
assert "specify init --here --force" in captured.out
|
||||
plain = strip_ansi(captured.out)
|
||||
assert "already exist and were not updated" in plain
|
||||
assert "specify init --here --force" in plain
|
||||
# Rich may wrap long lines; normalize whitespace for the second command
|
||||
normalized = " ".join(captured.out.split())
|
||||
normalized = " ".join(plain.split())
|
||||
assert "specify integration upgrade --force" in normalized
|
||||
|
||||
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):
|
||||
@@ -866,7 +930,23 @@ class TestGitExtensionAutoInstall:
|
||||
|
||||
|
||||
class TestSharedInfraCommandRefs:
|
||||
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
|
||||
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in shared infra."""
|
||||
|
||||
@staticmethod
|
||||
def _combined_script_content(project, script_type):
|
||||
script_dir = "bash" if script_type == "sh" else "powershell"
|
||||
suffix = "sh" if script_type == "sh" else "ps1"
|
||||
names = [
|
||||
f"check-prerequisites.{suffix}",
|
||||
f"common.{suffix}",
|
||||
f"setup-tasks.{suffix}",
|
||||
]
|
||||
return "\n".join(
|
||||
(project / ".specify" / "scripts" / script_dir / name).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for name in names
|
||||
)
|
||||
|
||||
def test_dot_separator_in_page_templates(self, tmp_path):
|
||||
"""Markdown agents get /speckit.<name> in page templates."""
|
||||
@@ -911,6 +991,46 @@ class TestSharedInfraCommandRefs:
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
assert "/speckit-tasks" in content
|
||||
|
||||
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
||||
def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
|
||||
"""Markdown agents get /speckit.<name> in shared script hints."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / f"dot-script-{script_type}"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
_install_shared_infra(project, script_type, invoke_separator=".")
|
||||
|
||||
content = self._combined_script_content(project, script_type)
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
assert "/speckit.specify" in content
|
||||
assert "/speckit.plan" in content
|
||||
assert "/speckit.tasks" in content
|
||||
assert "/speckit-specify" not in content
|
||||
assert "/speckit-plan" not in content
|
||||
assert "/speckit-tasks" not in content
|
||||
|
||||
@pytest.mark.parametrize("script_type", ["sh", "ps"])
|
||||
def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type):
|
||||
"""Skills agents get /speckit-<name> in shared script hints."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / f"hyphen-script-{script_type}"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
_install_shared_infra(project, script_type, invoke_separator="-")
|
||||
|
||||
content = self._combined_script_content(project, script_type)
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
assert "/speckit-specify" in content
|
||||
assert "/speckit-plan" in content
|
||||
assert "/speckit-tasks" in content
|
||||
assert "/speckit.specify" not in content
|
||||
assert "/speckit.plan" not in content
|
||||
assert "/speckit.tasks" not in content
|
||||
|
||||
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -938,6 +1058,10 @@ class TestSharedInfraCommandRefs:
|
||||
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
script_content = self._combined_script_content(project, "sh")
|
||||
assert "/speckit-specify" in script_content
|
||||
assert "/speckit.specify" not in script_content
|
||||
|
||||
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -965,6 +1089,10 @@ class TestSharedInfraCommandRefs:
|
||||
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
script_content = self._combined_script_content(project, "sh")
|
||||
assert "/speckit.specify" in script_content
|
||||
assert "/speckit-specify" not in script_content
|
||||
|
||||
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -994,6 +1122,10 @@ class TestSharedInfraCommandRefs:
|
||||
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
script_content = self._combined_script_content(project, "sh")
|
||||
assert "/speckit-specify" in script_content
|
||||
assert "/speckit.specify" not in script_content
|
||||
|
||||
|
||||
class TestIntegrationCatalogDiscoveryCLI:
|
||||
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
|
||||
@@ -1055,6 +1187,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 +1488,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)
|
||||
|
||||
638
tests/integrations/test_extra_args.py
Normal file
638
tests/integrations/test_extra_args.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""Tests for the per-integration `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` and
|
||||
`SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` env-var hooks.
|
||||
|
||||
The hooks are implemented in `IntegrationBase._apply_extra_args_env_var` and
|
||||
`IntegrationBase._resolve_executable` and wired into every concrete
|
||||
`build_exec_args` — `MarkdownIntegration`, `TomlIntegration`,
|
||||
`SkillsIntegration`, plus override integrations.
|
||||
These tests cover both the shared mechanisms (via `SkillsIntegration` stubs
|
||||
near the top of the file) and override integrations end-to-end (further down).
|
||||
See issues #2595 and #2596."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import (
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
TomlIntegration,
|
||||
)
|
||||
|
||||
|
||||
class _ClaudeStub(SkillsIntegration):
|
||||
"""Minimal Claude-like SkillsIntegration for testing."""
|
||||
|
||||
key = "claude"
|
||||
config = {
|
||||
"name": "Claude (test stub)",
|
||||
"folder": ".claude/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".claude/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "CLAUDE.md"
|
||||
|
||||
|
||||
class _KiroCliStub(SkillsIntegration):
|
||||
"""SkillsIntegration with a hyphenated key to exercise key
|
||||
normalization (`kiro-cli` → `KIRO_CLI`)."""
|
||||
|
||||
key = "kiro-cli"
|
||||
config = {
|
||||
"name": "Kiro CLI (test stub)",
|
||||
"folder": ".kiro/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kiro/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "KIRO.md"
|
||||
|
||||
|
||||
class _NoCliStub(SkillsIntegration):
|
||||
"""SkillsIntegration with requires_cli=False — build_exec_args
|
||||
must return None and the env-var hook must not fire."""
|
||||
|
||||
key = "no-cli"
|
||||
config = {
|
||||
"name": "No-CLI agent (test stub)",
|
||||
"folder": ".no-cli/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".no-cli/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "NOCLI.md"
|
||||
|
||||
|
||||
class _MarkdownAgentStub(MarkdownIntegration):
|
||||
"""Bare MarkdownIntegration subclass — does NOT override
|
||||
`build_exec_args`. Locks the base implementation in
|
||||
`MarkdownIntegration.build_exec_args` for the common case
|
||||
(most concrete integrations: Amp, Auggie, Generic, …)."""
|
||||
|
||||
key = "md-agent"
|
||||
config = {
|
||||
"name": "Markdown agent (test stub)",
|
||||
"folder": ".md-agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".md-agent/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "MDAGENT.md"
|
||||
|
||||
|
||||
class _TomlAgentStub(TomlIntegration):
|
||||
"""Bare TomlIntegration subclass — does NOT override
|
||||
`build_exec_args`. Locks the base implementation in
|
||||
`TomlIntegration.build_exec_args` (Gemini, Tabnine)."""
|
||||
|
||||
key = "toml-agent"
|
||||
config = {
|
||||
"name": "TOML agent (test stub)",
|
||||
"folder": ".toml-agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".toml-agent/commands",
|
||||
"format": "toml",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".toml",
|
||||
}
|
||||
context_file = "TOMLAGENT.md"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_extra_args_env(monkeypatch):
|
||||
"""Strip any leaked SPECKIT_INTEGRATION_*_EXTRA_ARGS and
|
||||
SPECKIT_INTEGRATION_*_EXECUTABLE vars from the test env so a
|
||||
developer's shell setting doesn't pollute results."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith("SPECKIT_INTEGRATION_") and (
|
||||
key.endswith("_EXTRA_ARGS") or key.endswith("_EXECUTABLE")
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_env_var_unset_byte_identical_argv():
|
||||
"""Default behaviour: env var unset → no extra args inserted.
|
||||
|
||||
Locks the backward-compatibility guarantee that existing
|
||||
operators see no change.
|
||||
"""
|
||||
args = _ClaudeStub().build_exec_args("hello prompt")
|
||||
assert args == ["claude", "-p", "hello prompt", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_env_var_set_flag_inserted_before_model_and_output_format(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("hello prompt", model="sonnet")
|
||||
assert args == [
|
||||
"claude",
|
||||
"-p",
|
||||
"hello prompt",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_env_var_multi_token_parsed_via_shlex(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
|
||||
"--dangerously-skip-permissions --max-turns 3",
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"claude",
|
||||
"-p",
|
||||
"p",
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
"3",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_malformed_quoting_raises_actionable_value_error(monkeypatch):
|
||||
"""An unmatched quote in the env-var value must surface a clear
|
||||
error naming the offending env var and showing the invalid value,
|
||||
rather than crashing workflow dispatch with a bare shlex traceback."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
|
||||
'--flag "unterminated',
|
||||
)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_ClaudeStub().build_exec_args("p")
|
||||
msg = str(excinfo.value)
|
||||
assert "SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS" in msg
|
||||
assert "--flag \"unterminated" in msg
|
||||
|
||||
|
||||
def test_env_var_empty_or_whitespace_is_noop(monkeypatch):
|
||||
"""An env var set to '' or ' ' is treated as unset."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", " ")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == ["claude", "-p", "p", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_other_integration_env_var_ignored(monkeypatch):
|
||||
"""`SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS` set must NOT leak into
|
||||
Claude's argv (per-integration scoping)."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS", "--gemini-only-flag")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == ["claude", "-p", "p", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_key_normalization_hyphen_to_underscore_uppercase(monkeypatch):
|
||||
"""`kiro-cli` key looks up `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`
|
||||
(hyphens replaced with underscores, then uppercased)."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--some-kiro-flag"
|
||||
)
|
||||
args = _KiroCliStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"kiro-cli",
|
||||
"-p",
|
||||
"p",
|
||||
"--some-kiro-flag",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_requires_cli_false_returns_none(monkeypatch):
|
||||
"""`requires_cli: False` short-circuits to None — the env-var
|
||||
hook is never reached and no argv is built."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXTRA_ARGS", "--should-not-appear")
|
||||
assert _NoCliStub().build_exec_args("p") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base-class coverage
|
||||
#
|
||||
# Most integrations inherit `build_exec_args` from `MarkdownIntegration`
|
||||
# or `TomlIntegration` without overriding it. The tests above use
|
||||
# `SkillsIntegration` stubs (which share the same hook mechanism) — these
|
||||
# tests exercise the two other base implementations directly so all three
|
||||
# concrete bases are covered.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_markdown_integration_base_honours_extra_args(monkeypatch):
|
||||
"""A bare `MarkdownIntegration` subclass — which does not override
|
||||
`build_exec_args` — must honour the env var via the base
|
||||
implementation. Covers the most common integration pattern."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_MD_AGENT_EXTRA_ARGS", "--debug --max-tokens 100"
|
||||
)
|
||||
args = _MarkdownAgentStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"md-agent",
|
||||
"-p",
|
||||
"p",
|
||||
"--debug",
|
||||
"--max-tokens",
|
||||
"100",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_toml_integration_base_honours_extra_args(monkeypatch):
|
||||
"""A bare `TomlIntegration` subclass — which does not override
|
||||
`build_exec_args` — must honour the env var via the base
|
||||
implementation. Covers Gemini/Tabnine-style integrations."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_TOML_AGENT_EXTRA_ARGS", "--yolo"
|
||||
)
|
||||
args = _TomlAgentStub().build_exec_args("p", model="gemini-pro")
|
||||
# TomlIntegration uses `-m` for model (vs Markdown's `--model`).
|
||||
assert args == [
|
||||
"toml-agent",
|
||||
"-p",
|
||||
"p",
|
||||
"--yolo",
|
||||
"-m",
|
||||
"gemini-pro",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override-integration coverage
|
||||
#
|
||||
# CodexIntegration, DevinIntegration, OpencodeIntegration and
|
||||
# CopilotIntegration each override `build_exec_args` rather than using the
|
||||
# base implementations. The env-var hook must be wired into every override
|
||||
# so the documented behaviour ("works for every requires_cli integration")
|
||||
# is honoured. These tests lock that contract per integration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_codex_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
|
||||
args = CodexIntegration().build_exec_args("p", model="gpt-5")
|
||||
assert args == [
|
||||
"codex",
|
||||
"exec",
|
||||
"p",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--model",
|
||||
"gpt-5",
|
||||
"--json",
|
||||
]
|
||||
|
||||
|
||||
def test_devin_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXTRA_ARGS", "--no-confirm")
|
||||
args = DevinIntegration().build_exec_args("p")
|
||||
assert args == ["devin", "-p", "p", "--no-confirm"]
|
||||
|
||||
|
||||
def test_opencode_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--quiet")
|
||||
args = OpencodeIntegration().build_exec_args("p")
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--quiet",
|
||||
"--format",
|
||||
"json",
|
||||
"p",
|
||||
]
|
||||
|
||||
|
||||
def test_opencode_extra_args_cannot_clobber_prompt_derived_command(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Operator-injected extra args must appear BEFORE the prompt-derived
|
||||
``--command <X>`` so that Spec Kit's command selection wins under
|
||||
repeated-flag CLI semantics (last value typically takes precedence).
|
||||
|
||||
Locks against the regression where an operator setting
|
||||
``SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS="--command malicious"`` could redirect
|
||||
a slash-prefixed prompt to a different command.
|
||||
"""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--command operator-override"
|
||||
)
|
||||
args = OpencodeIntegration().build_exec_args("/speckit body text")
|
||||
# Prompt-derived "--command speckit" appears AFTER the
|
||||
# operator-injected one, so a CLI that resolves repeated flags
|
||||
# last-wins will honour Spec Kit's choice.
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"operator-override",
|
||||
"--command",
|
||||
"speckit",
|
||||
"--format",
|
||||
"json",
|
||||
"body text",
|
||||
]
|
||||
|
||||
|
||||
def test_copilot_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.copilot import (
|
||||
CopilotIntegration,
|
||||
_copilot_executable,
|
||||
)
|
||||
|
||||
# Disable --yolo so the argv shape stays deterministic.
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
# `_copilot_executable()` returns "copilot.cmd" on Windows and
|
||||
# "copilot" elsewhere; the test must mirror that to stay portable.
|
||||
assert args == [
|
||||
_copilot_executable(),
|
||||
"-p",
|
||||
"p",
|
||||
"--allow-tool",
|
||||
"shell(echo)",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `dispatch_command` end-to-end coverage
|
||||
#
|
||||
# Workflow execution calls `impl.dispatch_command(...)`, not
|
||||
# `build_exec_args` directly. `IntegrationBase.dispatch_command` delegates
|
||||
# to `build_exec_args` (so the override fixes above flow through), but
|
||||
# `CopilotIntegration` overrides `dispatch_command` and constructs
|
||||
# `cli_args` inline — the hook must be invoked there too or the env var
|
||||
# is silently ignored at workflow runtime. These tests monkeypatch
|
||||
# `subprocess.run` and assert the env-var args reach the executed argv.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunCapture:
|
||||
"""Test double that captures argv passed to subprocess.run."""
|
||||
|
||||
def __init__(self):
|
||||
self.captured_args: list[str] | None = None
|
||||
|
||||
def __call__(self, args, **kwargs):
|
||||
self.captured_args = list(args)
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _Result()
|
||||
|
||||
|
||||
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
|
||||
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
|
||||
CopilotIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
# Hook inserted between `-p prompt` and the canonical Copilot flags.
|
||||
p_idx = capture.captured_args.index("-p")
|
||||
agent_idx = capture.captured_args.index("--agent")
|
||||
extra_idx = capture.captured_args.index("--allow-tool")
|
||||
assert p_idx < extra_idx < agent_idx
|
||||
assert "shell(echo)" in capture.captured_args
|
||||
|
||||
|
||||
def test_codex_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Lock the inherited `IntegrationBase.dispatch_command` path:
|
||||
Codex (and by transitivity Devin, Opencode) flow through
|
||||
`build_exec_args`, so the env var must reach argv at workflow
|
||||
runtime.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
|
||||
|
||||
CodexIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
assert "--sandbox" in capture.captured_args
|
||||
assert "read-only" in capture.captured_args
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPECKIT_INTEGRATION_<KEY>_EXECUTABLE tests
|
||||
#
|
||||
# The `_resolve_executable()` method on `IntegrationBase` checks
|
||||
# `SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` and, when set, substitutes that
|
||||
# value for `self.key` as the first token in argv. The tests below lock
|
||||
# the behaviour across shared and override integration paths:
|
||||
# - the shared SkillsIntegration/MarkdownIntegration/TomlIntegration bases,
|
||||
# - representative override integrations,
|
||||
# - the hyphen→underscore key normalisation, and
|
||||
# - whitespace/unset no-op guarantee.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_executable_env_var_unset_uses_key():
|
||||
"""Default: no override → executable is the integration key."""
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_replaces_first_argv_token(monkeypatch):
|
||||
"""Setting the env var substitutes the executable name in argv."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude/bin/claude")
|
||||
args = _ClaudeStub().build_exec_args("hello")
|
||||
assert args[0] == "/opt/claude/bin/claude"
|
||||
assert args[1:] == ["-p", "hello", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_executable_env_var_whitespace_only_falls_back_to_key(monkeypatch):
|
||||
"""Whitespace-only value is treated as unset → falls back to self.key."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", " ")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_key_normalization_hyphen_to_underscore(monkeypatch):
|
||||
"""`kiro-cli` key maps to `SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE`."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", "/usr/local/bin/kiro-cli")
|
||||
args = _KiroCliStub().build_exec_args("p")
|
||||
assert args[0] == "/usr/local/bin/kiro-cli"
|
||||
|
||||
|
||||
def test_executable_env_var_other_integration_ignored(monkeypatch):
|
||||
"""`SPECKIT_INTEGRATION_GEMINI_EXECUTABLE` must NOT affect Claude."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXECUTABLE", "/custom/gemini")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_markdown_integration(monkeypatch):
|
||||
"""MarkdownIntegration base honours the executable env var."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_MD_AGENT_EXECUTABLE", "/custom/md-agent")
|
||||
args = _MarkdownAgentStub().build_exec_args("p")
|
||||
assert args[0] == "/custom/md-agent"
|
||||
|
||||
|
||||
def test_executable_env_var_toml_integration(monkeypatch):
|
||||
"""TomlIntegration base honours the executable env var."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_TOML_AGENT_EXECUTABLE", "/custom/toml-agent")
|
||||
args = _TomlAgentStub().build_exec_args("p")
|
||||
assert args[0] == "/custom/toml-agent"
|
||||
|
||||
|
||||
def test_executable_env_var_requires_cli_false_returns_none(monkeypatch):
|
||||
"""`requires_cli: False` still returns None even when executable is set."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXECUTABLE", "/custom/no-cli")
|
||||
assert _NoCliStub().build_exec_args("p") is None
|
||||
|
||||
|
||||
def test_executable_env_var_codex_integration(monkeypatch):
|
||||
"""CodexIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXECUTABLE", "/opt/codex")
|
||||
args = CodexIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/codex"
|
||||
assert args[1] == "exec"
|
||||
|
||||
|
||||
def test_executable_env_var_devin_integration(monkeypatch):
|
||||
"""DevinIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXECUTABLE", "/opt/devin")
|
||||
args = DevinIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/devin"
|
||||
|
||||
|
||||
def test_executable_env_var_opencode_integration(monkeypatch):
|
||||
"""OpencodeIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXECUTABLE", "/opt/opencode")
|
||||
args = OpencodeIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/opencode"
|
||||
assert args[1] == "run"
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_integration(monkeypatch):
|
||||
"""CopilotIntegration honours the executable env var, overriding the
|
||||
platform-specific default from `_copilot_executable()`."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot")
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/copilot"
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_unset_uses_platform_default(monkeypatch):
|
||||
"""When `SPECKIT_INTEGRATION_COPILOT_EXECUTABLE` is unset, Copilot
|
||||
falls back to the platform-specific default from `_copilot_executable()`."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration, _copilot_executable
|
||||
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
assert args[0] == _copilot_executable()
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_dispatch_command(monkeypatch):
|
||||
"""CopilotIntegration.dispatch_command honours the executable env var."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot")
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
|
||||
CopilotIntegration().dispatch_command("speckit.plan", args="body", stream=False)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
assert capture.captured_args[0] == "/opt/copilot"
|
||||
|
||||
|
||||
def test_executable_and_extra_args_both_honoured(monkeypatch):
|
||||
"""Both the executable override and extra args env vars can be set
|
||||
simultaneously — they are independent hooks."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("hello", model="sonnet")
|
||||
assert args == [
|
||||
"/opt/claude",
|
||||
"-p",
|
||||
"hello",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for AgyIntegration (Antigravity)."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
@@ -12,10 +14,21 @@ class TestAgyIntegration(SkillsIntegrationTests):
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration(self.KEY)
|
||||
skills_opts = [o for o in i.options() if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0
|
||||
|
||||
def test_requires_cli_is_true(self):
|
||||
"""agy is a CLI tool; requires_cli must be True."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["requires_cli"] is True
|
||||
|
||||
def test_install_url_is_set(self):
|
||||
"""install_url must point to the official installation page."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["install_url"] == "https://antigravity.google/"
|
||||
|
||||
|
||||
class TestAgyAutoPromote:
|
||||
"""--ai agy auto-promotes to integration path."""
|
||||
|
||||
@@ -26,7 +39,7 @@ class TestAgyAutoPromote:
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -36,10 +49,87 @@ class TestAgyAutoPromote:
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed
|
||||
# Click >= 8.2 separates stdout and stderr natively
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
|
||||
|
||||
class TestAgyBuildExecArgs:
|
||||
"""agy non-interactive execution argument building."""
|
||||
|
||||
def test_build_exec_args_returns_print_command(self):
|
||||
"""build_exec_args should return ['agy', '--print', prompt]."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("describe my feature")
|
||||
assert result == ["agy", "--print", "describe my feature"]
|
||||
|
||||
def test_build_exec_args_ignores_model(self):
|
||||
"""agy does not support --model; model param must be ignored."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("my prompt", model="gemini-pro")
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
def test_build_exec_args_ignores_output_json(self):
|
||||
"""agy does not support JSON output; output_json param must be ignored."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("my prompt", output_json=False)
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
|
||||
class TestAgyHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills with hook sections should contain the normalization note."""
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
i = get_integration("agy")
|
||||
m = IntegrationManifest("agy", 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.agy import AgyIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice must not duplicate it."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = AgyIntegration._inject_hook_command_note(content)
|
||||
twice = AgyIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note must match the indentation of the target line."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = AgyIntegration._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"
|
||||
|
||||
@@ -226,8 +226,8 @@ class MarkdownIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -243,15 +243,17 @@ class MarkdownIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
@@ -291,6 +293,16 @@ class MarkdownIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -176,6 +176,39 @@ class SkillsIntegrationTests:
|
||||
f"skills agents must use /speckit-<name>"
|
||||
)
|
||||
|
||||
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||
"""Generated skills with hook sections must explain dotted command conversion."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should explain dotted hook command conversion"
|
||||
)
|
||||
assert content.count("replace dots") == content.count(
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
|
||||
def test_hook_note_injected_for_each_instruction_independently(self):
|
||||
"""Existing hook notes should not suppress later missing notes."""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: test\n"
|
||||
"---\n\n"
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
"- For each executable hook, output the following first block:\n"
|
||||
"\n"
|
||||
"- For each executable hook, output the following second block:\n"
|
||||
)
|
||||
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||
i = get_integration(self.KEY)
|
||||
@@ -324,8 +357,8 @@ class SkillsIntegrationTests:
|
||||
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -341,10 +374,11 @@ class SkillsIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- IntegrationOption ------------------------------------------------
|
||||
@@ -369,9 +403,11 @@ class SkillsIntegrationTests:
|
||||
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
|
||||
|
||||
files = []
|
||||
# Skill files
|
||||
# Skill files (core commands)
|
||||
for cmd in self._SKILL_COMMANDS:
|
||||
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
||||
# Extension-installed skill (agent-context)
|
||||
files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md")
|
||||
# Integration metadata
|
||||
files += [
|
||||
".specify/init-options.json",
|
||||
@@ -410,6 +446,15 @@ class SkillsIntegrationTests:
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
]
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -457,8 +457,8 @@ class TomlIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -474,15 +474,17 @@ class TomlIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
@@ -543,6 +545,16 @@ class TomlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -336,8 +336,8 @@ class YamlIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -353,15 +353,17 @@ class YamlIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
@@ -422,6 +424,16 @@ class YamlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
|
||||
from specify_cli.integrations.claude import ARGUMENT_HINTS
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
@@ -197,8 +197,8 @@ class TestClaudeIntegration:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("specify_cli._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.select_with_arrows", return_value="claude"),
|
||||
patch("specify_cli.commands.init._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -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_preserves_content_without_hooks(self, tmp_path):
|
||||
"""SkillsIntegration agents without an override preserve non-hook content."""
|
||||
# ``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:
|
||||
@@ -503,7 +505,7 @@ class TestClaudeHookCommandNote:
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
@@ -514,35 +516,54 @@ class TestClaudeHookCommandNote:
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = ClaudeIntegration._inject_hook_command_note(content)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self, tmp_path):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = ClaudeIntegration._inject_hook_command_note(content)
|
||||
twice = ClaudeIntegration._inject_hook_command_note(once)
|
||||
once = SkillsIntegration._inject_hook_command_note(content)
|
||||
twice = SkillsIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
|
||||
"""Already-noted hook sections should not suppress later sections."""
|
||||
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
f"{_HOOK_COMMAND_NOTE}"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
"\n"
|
||||
" - For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
|
||||
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"This paragraph says replace dots in a different context.\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert "This paragraph says replace dots in a different context." in result
|
||||
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||
|
||||
def test_hook_note_preserves_indentation(self, tmp_path):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = ClaudeIntegration._inject_hook_command_note(content)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
note_line = [line for line in lines if "replace dots" in line][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_post_process_injects_all_claude_flags(self):
|
||||
|
||||
@@ -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,117 @@ 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_fills_missing_repeated_instructions(self):
|
||||
"""Already-noted hook sections should not suppress later sections."""
|
||||
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
f"{_HOOK_COMMAND_NOTE}"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
"\n"
|
||||
" - For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_hook_note_not_suppressed_by_unrelated_phrase(self):
|
||||
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"This paragraph says replace dots in a different context.\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert "This paragraph says replace dots in a different context." in result
|
||||
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||
|
||||
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 = [line for line in lines if "replace dots" in line][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, line in enumerate(lines) if "replace dots" in line
|
||||
)
|
||||
instruction_line_idx = next(
|
||||
i for i, line in enumerate(lines)
|
||||
if line.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"
|
||||
)
|
||||
|
||||
@@ -178,6 +178,7 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -187,6 +188,7 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -198,6 +200,14 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -238,6 +248,7 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -247,6 +258,7 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -258,6 +270,14 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -404,6 +424,20 @@ class TestCopilotSkillsMode:
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "mode: speckit.plan" in updated
|
||||
|
||||
def test_post_process_skill_content_injects_hook_note(self):
|
||||
"""post_process_skill_content() should inject shared hook guidance."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-specify"\n'
|
||||
'description: "Specify workflow"\n'
|
||||
"---\n"
|
||||
"\n- For each executable hook, output the following\n"
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "replace dots" in updated
|
||||
assert "mode: speckit.specify" in updated
|
||||
|
||||
def test_post_process_idempotent(self):
|
||||
"""post_process_skill_content() must be idempotent."""
|
||||
copilot = self._make_copilot()
|
||||
@@ -434,6 +468,14 @@ class TestCopilotSkillsMode:
|
||||
stem = skill_dir_name.removeprefix("speckit-")
|
||||
assert fm["mode"] == f"speckit.{stem}"
|
||||
|
||||
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||
"""Generated skills with hook sections should include shared hook guidance."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content
|
||||
|
||||
# -- Template processing ----------------------------------------------
|
||||
|
||||
def test_skills_templates_are_processed(self, tmp_path):
|
||||
@@ -602,10 +644,20 @@ class TestCopilotSkillsMode:
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
# Skill files
|
||||
# Skill files (core + extension-installed agent-context command)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
".github/skills/speckit-agent-context-update/SKILL.md",
|
||||
# Context file
|
||||
".github/copilot-instructions.md",
|
||||
# Bundled agent-context extension
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
# Integration metadata
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
@@ -724,4 +776,4 @@ class TestCopilotSkillsMode:
|
||||
# Must NOT show the dotted /speckit.plan form
|
||||
assert "/speckit.plan" not in result.output, (
|
||||
f"Should not show /speckit.plan in skills mode:\n{result.output}"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -195,6 +195,39 @@ class TestGenericIntegration:
|
||||
content = implement_file.read_text(encoding="utf-8")
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
],
|
||||
)
|
||||
def test_command_loads_constitution_context(self, tmp_path, command_stem):
|
||||
"""Every command except constitution must reference constitution.md."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / f"speckit.{command_stem}.md"
|
||||
assert cmd_file.exists(), f"Command file missing: {cmd_file.name}"
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
assert "constitution.md" in content, (
|
||||
f"speckit.{command_stem}.md must reference constitution.md"
|
||||
)
|
||||
|
||||
def test_constitution_command_exists(self, tmp_path):
|
||||
"""The constitution command itself must exist but is not required to load itself."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / "speckit.constitution.md"
|
||||
assert cmd_file.exists()
|
||||
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
@@ -211,8 +244,8 @@ class TestGenericIntegration:
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the generic integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the generic integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -229,8 +262,9 @@ class TestGenericIntegration:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
assert opts.get("context_file") == "AGENTS.md"
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
assert ext_cfg.get("context_file") == "AGENTS.md"
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||||
@@ -265,6 +299,14 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
@@ -321,6 +363,14 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
|
||||
362
tests/integrations/test_integration_hermes.py
Normal file
362
tests/integrations/test_integration_hermes.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""Tests for HermesIntegration.
|
||||
|
||||
Hermes is special among SkillsIntegration subclasses: it writes skills
|
||||
to ``~/.hermes/skills/`` (global) rather than the project-local
|
||||
``.hermes/skills/`` directory. A project-local marker (empty directory)
|
||||
is created so extension commands (e.g. git) can detect Hermes.
|
||||
|
||||
All tests that touch ``~/.hermes/`` use ``monkeypatch`` to isolate
|
||||
``Path.home()`` to a temp directory so the test suite is hermetic and
|
||||
non-destructive to a developer's real Hermes installation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
def _fake_home(tmp_path: Path) -> Path:
|
||||
"""Create and return an isolated home directory under *tmp_path*."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
return home
|
||||
|
||||
|
||||
class TestHermesIntegration(SkillsIntegrationTests):
|
||||
KEY = "hermes"
|
||||
FOLDER = ".hermes/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = "~/.hermes/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
# -- Hermes-specific setup: skills go to ~/.hermes/skills/ -------------
|
||||
|
||||
def test_setup_writes_to_global_skills_dir(self, tmp_path, monkeypatch):
|
||||
"""Skills are written to ~/.hermes/skills/, not project-local."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
assert len(skill_files) > 0, "No skill files were created"
|
||||
for f in skill_files:
|
||||
# Every skill file should be under ~/.hermes/skills/speckit-*/
|
||||
expected_prefix = str(home / ".hermes" / "skills")
|
||||
assert str(f).startswith(expected_prefix), (
|
||||
f"{f} is not under ~/.hermes/skills/"
|
||||
)
|
||||
|
||||
def test_local_marker_dir_created(self, tmp_path, monkeypatch):
|
||||
"""Project-local .hermes/skills/ should exist but be empty."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
marker = tmp_path / ".hermes" / "skills"
|
||||
assert marker.is_dir(), "Marker directory was not created"
|
||||
# Should be empty (no SKILL.md files)
|
||||
children = list(marker.iterdir())
|
||||
assert children == [], f"Marker directory should be empty, got: {children}"
|
||||
|
||||
# -- Override shared tests that assume project-local skills ------------
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes writes to global, not project-local."""
|
||||
self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch)
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path, monkeypatch):
|
||||
"""Plan skill goes to global dir, but we check it still references AGENTS.md."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
if not i.context_file:
|
||||
return
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
# Find the plan skill in global ~/.hermes/skills/
|
||||
plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists(), f"Plan skill {plan_file} not created globally"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan skill should reference {i.context_file!r} but it was not found"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes does not track skills in the project manifest
|
||||
since they live globally. Only project-local files (scripts,
|
||||
templates, context) are tracked."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
for f in created:
|
||||
# Global files (in ~/.hermes/) are not tracked in manifest
|
||||
if str(f).startswith(str(home)):
|
||||
continue
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes uninstall removes global skills + local marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
# All SKILL.md files should exist globally
|
||||
for f in created:
|
||||
if "SKILL.md" in str(f):
|
||||
assert f.exists(), f"{f} does not exist"
|
||||
# Global skills are removed on teardown without needing force
|
||||
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||
for f in created:
|
||||
if "SKILL.md" in str(f):
|
||||
assert not f.exists(), f"{f} should have been removed"
|
||||
# Local marker should be gone
|
||||
assert not (tmp_path / ".hermes" / "skills").exists()
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes global skills are ALWAYS removed on uninstall
|
||||
(they live outside the project root and aren't hash-tracked in the
|
||||
manifest), so a modified global skill is still removed — matching
|
||||
the standard behaviour where all integration files are cleaned up."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
# Pick a global skill file
|
||||
skill_files = [f for f in created if "SKILL.md" in str(f)]
|
||||
assert len(skill_files) > 0
|
||||
modified_file = skill_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert not modified_file.exists(), (
|
||||
"Modified global skill should be removed on teardown (standard behaviour)"
|
||||
)
|
||||
|
||||
def test_modified_global_skill_removed_on_teardown(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes global skills are removed on uninstall regardless
|
||||
of the force flag, matching standard integration behaviour."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
# Pick a global skill file
|
||||
skill_files = [f for f in created if "SKILL.md" in str(f)]
|
||||
assert len(skill_files) > 0
|
||||
modified_file = skill_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
# Global skills are removed on teardown regardless of force flag
|
||||
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||
assert not modified_file.exists(), (
|
||||
"Modified global skill should be removed on teardown (standard behaviour)"
|
||||
)
|
||||
|
||||
def test_pre_existing_skills_not_removed(self, tmp_path, monkeypatch):
|
||||
"""Pre-existing non-speckit global skills should survive Hermes uninstall."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
# Create a foreign skill in the global dir first
|
||||
global_skills_dir = i._hermes_home_skills_dir()
|
||||
foreign_dir = global_skills_dir / "other-tool"
|
||||
foreign_dir.mkdir(parents=True, exist_ok=True)
|
||||
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
|
||||
# Run teardown to verify foreign skill survives uninstall
|
||||
i.teardown(tmp_path, m)
|
||||
|
||||
assert (foreign_dir / "SKILL.md").exists(), (
|
||||
"Foreign skill was removed by teardown"
|
||||
)
|
||||
|
||||
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes skills live in global ~/.hermes/skills/."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
specify_skill = home / ".hermes" / "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 explain dotted hook command conversion"
|
||||
)
|
||||
assert content.count("replace dots") == content.count(
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes init produces no local SKILL.md files,
|
||||
only the empty .hermes/skills/ marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = Path.cwd()
|
||||
import os
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
# Ensure the marker exists (empty dir won't appear in file listing)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path, monkeypatch):
|
||||
"""Override: Same as sh variant but for PowerShell script type."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = Path.cwd()
|
||||
import os
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
|
||||
def test_install_uninstall_cleanup(self, tmp_path, monkeypatch):
|
||||
"""Verify global skills are cleaned and local marker is removed."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
|
||||
# Verify global skills exist
|
||||
global_skills = [
|
||||
f for f in created
|
||||
if "SKILL.md" in str(f)
|
||||
and str(f).startswith(str(home / ".hermes"))
|
||||
]
|
||||
assert len(global_skills) > 0
|
||||
for f in global_skills:
|
||||
assert f.exists()
|
||||
|
||||
# Verify local marker exists
|
||||
assert (tmp_path / ".hermes" / "skills").is_dir()
|
||||
|
||||
# Teardown — global skills removed without needing force=True
|
||||
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||
|
||||
# Global skills removed
|
||||
for f in global_skills:
|
||||
assert not f.exists(), f"{f} should have been removed"
|
||||
|
||||
# Local marker removed
|
||||
assert not (tmp_path / ".hermes" / "skills").exists(), (
|
||||
"Local marker should be removed on teardown"
|
||||
)
|
||||
|
||||
|
||||
class TestHermesAutoPromote:
|
||||
"""--ai hermes auto-promotes to integration path."""
|
||||
|
||||
def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch):
|
||||
"""--ai hermes should work the same as --integration hermes,
|
||||
creating global skills and a local marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target),
|
||||
"--ai", "hermes",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai hermes failed: {result.output}"
|
||||
# Skills should be in global ~/.hermes/skills/
|
||||
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
# Local marker should exist
|
||||
assert (target / ".hermes" / "skills").is_dir()
|
||||
# No core SKILL.md files in project-local dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
local_skills = [
|
||||
d for d in (target / ".hermes" / "skills").iterdir()
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -49,7 +50,8 @@ def _write_invalid_manifest(project, key):
|
||||
|
||||
|
||||
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
||||
row = next(line for line in output.splitlines() if line.startswith(f"│ {key}"))
|
||||
plain = strip_ansi(output)
|
||||
row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}"))
|
||||
return [cell.strip() for cell in row.split("│")[1:-1]]
|
||||
|
||||
|
||||
@@ -160,10 +162,34 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already installed" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
plain = strip_ansi(result.output)
|
||||
assert "already installed" in plain
|
||||
normalized = " ".join(plain.split())
|
||||
assert "specify integration upgrade copilot" in normalized
|
||||
assert "specify integration uninstall copilot" in normalized
|
||||
assert "already the default integration" in normalized
|
||||
assert "No files were changed" in normalized
|
||||
assert "specify integration uninstall copilot" not in normalized
|
||||
|
||||
def test_install_already_installed_non_default_guides_use(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "install", "codex"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "already installed" in normalized
|
||||
assert "specify integration use codex" in normalized
|
||||
assert "specify integration upgrade codex" in normalized
|
||||
assert "specify integration uninstall codex" not in normalized
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -174,9 +200,14 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "--force" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Installed integrations: copilot" in plain
|
||||
assert "Default integration: copilot" in plain
|
||||
normalized = " ".join(plain.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
|
||||
def test_install_multi_safe_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -203,6 +234,29 @@ class TestIntegrationInstall:
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "claude"
|
||||
assert updated["ai"] == "claude"
|
||||
assert "context_file" not in updated
|
||||
|
||||
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
@@ -259,9 +313,14 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "multi-install safe" in result.output
|
||||
assert "--force" in result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Installed integrations: copilot" in plain
|
||||
assert "multi-install safe" in plain
|
||||
normalized = " ".join(plain.split())
|
||||
assert "To replace the default integration" in normalized
|
||||
assert "specify integration switch claude" in normalized
|
||||
assert "To install 'claude' alongside" in normalized
|
||||
assert "retry the same install command with --force" in normalized
|
||||
|
||||
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -327,6 +386,10 @@ class TestIntegrationInstall:
|
||||
# Shared infrastructure should be present
|
||||
assert (project / ".specify" / "scripts").is_dir()
|
||||
assert (project / ".specify" / "templates").is_dir()
|
||||
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
script_content = script.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" in script_content
|
||||
assert "/speckit.specify" not in script_content
|
||||
|
||||
|
||||
# ── uninstall ────────────────────────────────────────────────────────
|
||||
@@ -455,7 +518,9 @@ class TestIntegrationUninstall:
|
||||
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -474,6 +539,7 @@ class TestIntegrationUninstall:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||
|
||||
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
||||
"""Shared scripts and templates are not removed by integration uninstall."""
|
||||
@@ -534,7 +600,9 @@ class TestIntegrationUse:
|
||||
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -548,10 +616,14 @@ class TestIntegrationUse:
|
||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||
assert use_gemini.exit_code == 0, use_gemini.output
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
|
||||
|
||||
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
|
||||
assert use_claude.exit_code == 0, use_claude.output
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" not in script.read_text(encoding="utf-8")
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
@@ -571,6 +643,8 @@ class TestIntegrationUse:
|
||||
|
||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||
assert use_gemini.exit_code == 0, use_gemini.output
|
||||
normalized = " ".join(use_gemini.output.split())
|
||||
assert "specify integration use gemini --force" in normalized
|
||||
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
|
||||
|
||||
force_use = runner.invoke(app, [
|
||||
@@ -585,8 +659,7 @@ class TestIntegrationUse:
|
||||
assert "/speckit.plan" in updated
|
||||
assert "custom template" not in updated
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path):
|
||||
def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
@@ -602,12 +675,12 @@ class TestIntegrationUse:
|
||||
|
||||
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
import specify_cli
|
||||
|
||||
outside = tmp_path / "outside-template.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
template.unlink()
|
||||
os.symlink(outside, template)
|
||||
def fail_refresh(*args, **kwargs):
|
||||
raise ValueError("refuse refresh")
|
||||
|
||||
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "use", "codex",
|
||||
@@ -617,10 +690,9 @@ class TestIntegrationUse:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to refresh shared templates" in result.output
|
||||
assert "Failed to refresh shared infrastructure" in result.output
|
||||
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
||||
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
|
||||
# ── switch ───────────────────────────────────────────────────────────
|
||||
@@ -678,7 +750,9 @@ class TestIntegrationSwitch:
|
||||
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
template.write_text("# custom shared template\n", encoding="utf-8")
|
||||
script.write_text("# custom shared script\n", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -690,8 +764,10 @@ class TestIntegrationSwitch:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "managed shared templates refreshed" in result.output
|
||||
assert "shared infrastructure refreshed" in result.output
|
||||
assert "managed shared infrastructure refreshed" not in result.output
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in script.read_text(encoding="utf-8")
|
||||
|
||||
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -720,6 +796,8 @@ class TestIntegrationSwitch:
|
||||
project = _init_project(tmp_path, "claude")
|
||||
# Verify claude files exist (claude uses skills)
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -738,6 +816,8 @@ class TestIntegrationSwitch:
|
||||
|
||||
# New copilot files created
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
|
||||
|
||||
# integration.json updated
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
@@ -879,12 +959,13 @@ class TestIntegrationSwitch:
|
||||
assert "claude" not in git_meta["registered_commands"]
|
||||
assert "opencode" not in git_meta["registered_commands"]
|
||||
|
||||
def test_switch_preserves_shared_infra(self, tmp_path):
|
||||
"""Switching preserves shared scripts, templates, and memory."""
|
||||
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
|
||||
"""Switching refreshes managed shared scripts to the target command style."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
assert shared_script.exists()
|
||||
shared_content = shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in shared_content
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -897,9 +978,10 @@ class TestIntegrationSwitch:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Shared infra untouched
|
||||
assert shared_script.exists()
|
||||
assert shared_script.read_text(encoding="utf-8") == shared_content
|
||||
updated = shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in updated
|
||||
assert "/speckit-plan" not in updated
|
||||
|
||||
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
|
||||
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
|
||||
@@ -907,7 +989,7 @@ class TestIntegrationSwitch:
|
||||
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||
|
||||
# Simulate a stale vendored script: write truncated content as bytes
|
||||
# (write_text would translate \n→\r\n on Windows and break the hash)
|
||||
@@ -934,8 +1016,11 @@ class TestIntegrationSwitch:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Stale managed file should be replaced by the bundled version
|
||||
assert shared_script.read_bytes() == bundled_bytes
|
||||
# Stale managed file should be replaced by the target integration's rendered version.
|
||||
updated = shared_script.read_text(encoding="utf-8")
|
||||
assert "# stale vendored copy" not in updated
|
||||
assert "/speckit.plan" in updated
|
||||
assert "/speckit-plan" not in updated
|
||||
|
||||
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
|
||||
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
|
||||
@@ -965,10 +1050,11 @@ class TestIntegrationSwitch:
|
||||
"""--refresh-shared-infra explicitly overwrites user customizations on switch."""
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
bundled_bytes = shared_script.read_bytes()
|
||||
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
|
||||
rendered_bytes = shared_script.read_bytes()
|
||||
|
||||
# User customization (hash diverges from manifest)
|
||||
custom_bytes = bundled_bytes + b"\n# user customization\n"
|
||||
custom_bytes = rendered_bytes + b"\n# user customization\n"
|
||||
shared_script.write_bytes(custom_bytes)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
@@ -982,8 +1068,11 @@ class TestIntegrationSwitch:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
# Customization is overwritten with the bundled version
|
||||
assert shared_script.read_bytes() == bundled_bytes
|
||||
# Customization is overwritten with the target integration's rendered version.
|
||||
updated = shared_script.read_text(encoding="utf-8")
|
||||
assert "# user customization" not in updated
|
||||
assert "/speckit.plan" in updated
|
||||
assert "/speckit-plan" not in updated
|
||||
|
||||
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
|
||||
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
|
||||
@@ -1113,7 +1202,57 @@ class TestIntegrationUpgrade:
|
||||
assert "manifest" in result.output
|
||||
assert "unreadable" in result.output
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
|
||||
def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
|
||||
def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
install = _run_in_project(project, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
])
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
opts = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
opts["speckit_version"] = "0.6.1"
|
||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
updated = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "gemini"
|
||||
assert updated["ai"] == "gemini"
|
||||
assert "context_file" not in updated
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
@@ -1125,10 +1264,16 @@ class TestIntegrationUpgrade:
|
||||
|
||||
import specify_cli
|
||||
|
||||
def fail_refresh(*args, **kwargs):
|
||||
raise ValueError("refuse refresh")
|
||||
real_install_shared_infra = specify_cli._install_shared_infra
|
||||
calls = {"count": 0}
|
||||
|
||||
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
|
||||
def fail_refresh(*args, **kwargs):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 2:
|
||||
raise ValueError("refuse refresh")
|
||||
return real_install_shared_infra(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
@@ -1136,15 +1281,40 @@ class TestIntegrationUpgrade:
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to refresh shared templates" in result.output
|
||||
assert "Failed to refresh shared infrastructure" in result.output
|
||||
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
|
||||
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
|
||||
assert manifest_path.read_text(encoding="utf-8") == before_manifest
|
||||
|
||||
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit.specify" in managed_script.read_text(encoding="utf-8")
|
||||
customized_before = customized_script.read_text(encoding="utf-8") + "\n# user customization\n"
|
||||
customized_script.write_text(customized_before, encoding="utf-8")
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
managed_content = managed_script.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" in managed_content
|
||||
assert "/speckit.specify" not in managed_content
|
||||
assert customized_script.read_text(encoding="utf-8") == customized_before
|
||||
|
||||
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
@@ -1167,6 +1337,8 @@ class TestIntegrationUpgrade:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "gemini"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in script.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" not in script.read_text(encoding="utf-8")
|
||||
|
||||
def test_upgrade_migrates_opencode_legacy_dir(self, tmp_path):
|
||||
"""Upgrade moves OpenCode commands from .opencode/command/ to .opencode/commands/."""
|
||||
@@ -1204,11 +1376,16 @@ class TestIntegrationUpgrade:
|
||||
new_commands = sorted(canonical.glob("speckit.*.md"))
|
||||
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
|
||||
|
||||
# Stale files removed from legacy dir
|
||||
remaining = list(legacy.glob("speckit.*.md"))
|
||||
assert len(remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no speckit files after upgrade, "
|
||||
f"found: {[f.name for f in remaining]}"
|
||||
# Stale files removed from legacy dir (extension-installed commands
|
||||
# like agent-context.update may still appear — only check the original
|
||||
# core command stems that should have been migrated).
|
||||
core_remaining = [
|
||||
f for f in legacy.glob("speckit.*.md")
|
||||
if "agent-context" not in f.name
|
||||
]
|
||||
assert len(core_remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no core speckit files after upgrade, "
|
||||
f"found: {[f.name for f in core_remaining]}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -832,7 +832,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
@@ -843,7 +843,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
@@ -852,7 +852,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli import _fetch_latest_release_tag
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
|
||||
205
tests/test_check_prerequisites_paths_only.py
Normal file
205
tests/test_check_prerequisites_paths_only.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for check-prerequisites --paths-only skipping branch validation (#2653)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "bash"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, d / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, d / "check-prerequisites.sh")
|
||||
|
||||
|
||||
def _install_ps_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "powershell"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, d / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
# ── Bash tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must return paths without branch validation (main branch)."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "REPO_ROOT" in data
|
||||
assert "BRANCH" in data
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must also work on a properly named spec branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "FEATURE_DIR" in data
|
||||
assert "001-my-feature" in data.get("BRANCH", "")
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only without --json must return text paths on a non-spec branch."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "REPO_ROOT:" in result.stdout
|
||||
assert "FEATURE_DIR:" in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, branch validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must return paths without branch validation (main branch)."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "REPO_ROOT" in data
|
||||
assert "BRANCH" in data
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must also work on a properly named spec branch."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, branch validation must still fail on main."""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the --version CLI flag."""
|
||||
"""Tests for CLI version reporting."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
@@ -33,3 +34,46 @@ class TestVersionFlag:
|
||||
result = runner.invoke(app, ["--version", "init"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 0.7.2" in result.output
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the `specify version` subcommand."""
|
||||
|
||||
def test_version_features_text(self):
|
||||
"""specify version --features prints local capability flags."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Spec Kit CLI: 1.2.3" in result.output
|
||||
assert "Features:" in result.output
|
||||
assert "- controlled multi install integrations: yes" in result.output
|
||||
assert "- integration use command: yes" in result.output
|
||||
assert "- self check command: yes" in result.output
|
||||
|
||||
def test_version_features_json(self):
|
||||
"""specify version --features --json prints machine-readable capabilities."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {
|
||||
"version": "1.2.3",
|
||||
"features": {
|
||||
"controlled_multi_install_integrations": True,
|
||||
"integration_use_command": True,
|
||||
"multi_install_safe_registry_metadata": True,
|
||||
"integration_upgrade_command": True,
|
||||
"self_check_command": True,
|
||||
"workflow_catalog": True,
|
||||
"bundled_templates": True,
|
||||
},
|
||||
}
|
||||
|
||||
def test_version_json_requires_features(self):
|
||||
"""specify version --json is rejected until a JSON surface exists."""
|
||||
result = runner.invoke(app, ["version", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--json requires --features" in result.output
|
||||
|
||||
48
tests/test_commands_package.py
Normal file
48
tests/test_commands_package.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for the commands/ package structure."""
|
||||
import importlib
|
||||
|
||||
|
||||
def test_commands_package_importable():
|
||||
mod = importlib.import_module("specify_cli.commands")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_commands_init_importable():
|
||||
mod = importlib.import_module("specify_cli.commands.init")
|
||||
assert hasattr(mod, "register")
|
||||
assert callable(mod.register)
|
||||
|
||||
|
||||
def test_commands_stubs_importable():
|
||||
for name in ("integration", "preset", "extension", "workflow"):
|
||||
mod = importlib.import_module(f"specify_cli.commands.{name}")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_agent_config_importable():
|
||||
from specify_cli._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert isinstance(AI_ASSISTANT_ALIASES, dict)
|
||||
assert isinstance(AI_ASSISTANT_HELP, str)
|
||||
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_init_command_registered():
|
||||
from specify_cli import app
|
||||
callback_names = [
|
||||
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
|
||||
]
|
||||
assert "init" in callback_names
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user