Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
5301b34132 chore: bump version to 0.8.11 2026-05-15 19:58:53 +00:00
109 changed files with 1681 additions and 13506 deletions

2
.gitattributes vendored
View File

@@ -1,3 +1 @@
* text=auto eol=lf
.github/workflows/*.lock.yml linguist-generated=true merge=ours -whitespace

View File

@@ -1,14 +0,0 @@
{
"entries": {
"actions/github-script@v9.0.0": {
"repo": "actions/github-script",
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.74.8": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.74.8",
"sha": "efa55847f72aadb03490d955263ff911bf758700"
}
}
}

View File

@@ -1,12 +1,11 @@
updates:
- directory: /
package-ecosystem: pip
schedule:
interval: weekly
- directory: /
ignore:
- dependency-name: "github/gh-aw-actions/**" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
package-ecosystem: github-actions
schedule:
interval: weekly
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

File diff suppressed because it is too large Load Diff

View File

@@ -1,285 +0,0 @@
---
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`

File diff suppressed because it is too large Load Diff

View File

@@ -1,279 +0,0 @@
---
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`

View File

@@ -22,11 +22,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -35,7 +35,7 @@ jobs:
fetch-depth: 0 # Fetch all history for git info
- name: Setup .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
dotnet-version: '8.x'

View File

@@ -13,28 +13,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 1
- name: Run git diff --check
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/checks/pr-base"
git diff --check refs/checks/pr-base HEAD
elif [ "$PUSH_BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
git diff-tree --check --no-commit-id --root -r "$GITHUB_SHA"
else
git fetch --no-tags --depth=1 origin "+${PUSH_BEFORE_SHA}:refs/checks/push-before"
git diff --check refs/checks/push-before HEAD
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23

View File

@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
with:
# Days of inactivity before an issue or PR becomes stale
days-before-stale: 150

View File

@@ -177,24 +177,7 @@ 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.
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.
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.
### 5. Test it
@@ -396,37 +379,10 @@ 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 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.
2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated.
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.

View File

@@ -2,134 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.9.1] - 2026-06-02
### Changed
- fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
- docs: list Hermes in supported integrations table (#2768)
- fix(copilot): resolve active spec template (#2765)
- fix: add missing agent-context extension entries to Cline _expected_files (#2797)
- Add spec-kit-linear extension to community catalog (#2795)
- feat: add native Cline integration (#2508)
- Update workflow-preset community catalog entry (#2756)
- chore: release 0.9.0, begin 0.9.1.dev0 development (#2794)
- Add RAG Azure Builder extension to community catalog (#2793)
## [0.9.0] - 2026-06-01
### Changed
- chore: recompile workflow lock files (#2774)
- Add Multi-Sites Spec Kit extension to community catalog (#2791)
- Update Product Spec Extension to v0.8.3 (#2790)
- Publish May 2026 Newsletter (#2787)
- fix: move URL install confirmation prompt before spinner (#2783) (#2784)
- Update Reqnroll BDD extension to v1.1.0 (#2775)
- Extract agent context updates into bundled agent-context extension (#2546)
- chore(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 (#2755)
- chore: release 0.8.18, begin 0.8.19.dev0 development (#2766)
## [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

View File

@@ -38,7 +38,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler
1. Fork and clone the repository
1. Configure and install the dependencies: `uv sync --extra test`
1. Make sure the CLI works on your machine: `uv run specify --help`
1. Create a new branch: `git checkout -b <type>/<number>-<short-slug>` (see [Branch naming](#branch-naming) below)
1. Create a new branch: `git checkout -b my-branch-name`
1. Make your change, add tests, and make sure everything still works
1. Test the CLI functionality with a sample project if relevant
1. Push to your fork and submit a pull request
@@ -55,20 +55,6 @@ Here are a few things you can do that will increase the likelihood of your pull
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
### Branch naming
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
| Prefix | When to use | Example |
|---|---|---|
| `feat/` | New features | `feat/2342-workflow-cli-alignment` |
| `fix/` | Bug fixes | `fix/2653-paths-only-validation` |
| `docs/` | Documentation changes | `docs/2677-branch-naming-convention` |
| `community/` | Community catalog additions | `community/2492-add-mde-extension` |
| `chore/` | Maintenance, tooling, CI | `chore/2366-editorconfig` |
Including the issue or PR number makes branches traceable — especially useful since the project uses squash merges and `git branch --merged` won't detect merged branches. If you start with a PR (no issue), use the PR number once it's assigned.
## Development workflow
When working on spec-kit:

127
README.md
View File

@@ -22,7 +22,10 @@
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
- [⚡ Get Started](#-get-started)
- [📽️ Video Overview](#-video-overview)
- [🌍 Community](#-community)
- [🧩 Community Extensions](#-community-extensions)
- [🎨 Community Presets](#-community-presets)
- [🚶 Community Walkthroughs](#-community-walkthroughs)
- [🛠️ Community Friends](#-community-friends)
- [🤖 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)
@@ -32,7 +35,7 @@
- [🔧 Prerequisites](#-prerequisites)
- [📖 Learn More](#-learn-more)
- [📋 Detailed Process](#-detailed-process)
- [💬 Support](#-support)
- [ Support](#-support)
- [🙏 Acknowledgements](#-acknowledgements)
- [📄 License](#-license)
@@ -109,19 +112,31 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
## 🌍 Community
## 🧩 Community Extensions
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
Community-contributed extensions add new commands, hooks, and capabilities to Spec Kit. See the full list on the [Community Extensions](https://github.github.io/spec-kit/community/extensions.html) page.
> [!NOTE]
> Community contributions are independently created and maintained by their respective authors. Review source code before installation and use at your own discretion.
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md).
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.
## 🤖 Supported AI Coding Agent Integrations
@@ -191,7 +206,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](https://github.github.io/spec-kit/community/extensions.html) 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](#-community-extensions) above for what's available.
### Presets — Customize Existing Workflows
@@ -266,7 +281,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://pipx.pypa.io/) for persistent installation
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)
@@ -385,24 +400,23 @@ The produced specification should contain a set of user stories and functional r
At this stage, your project folder contents should resemble the following:
```text
.
├── .specify
── memory
│ └── constitution.md
├── scripts
└── bash
├── check-prerequisites.sh
├── common.sh
├── create-new-feature.sh
── setup-plan.sh
│ │ ── setup-tasks.sh
└── templates
── plan-template.md
── spec-template.md
── tasks-template.md
└── specs
└── 001-create-taskify
└── spec.md
└── .specify
├── memory
── constitution.md
├── scripts
│ └── bash
├── check-prerequisites.sh
│ ├── common.sh
│ ├── create-new-feature.sh
│ ├── setup-plan.sh
── setup-tasks.sh
── specs
│ └── 001-create-taskify
── spec.md
── templates
── plan-template.md
├── spec-template.md
└── tasks-template.md
```
### **STEP 3:** Functional specification clarification (required before planning)
@@ -449,31 +463,30 @@ The output of this step will include a number of implementation detail documents
```text
.
├── CLAUDE.md
├── .specify
── memory
│ │ └── constitution.md
── scripts
── bash
├── check-prerequisites.sh
├── common.sh
├── create-new-feature.sh
│ ├── setup-plan.sh
│ │ └── setup-tasks.sh
└── templates
├── CLAUDE-template.md
│ ├── plan-template.md
├── spec-template.md
── tasks-template.md
└── specs
── 001-create-taskify
├── contracts
│ ├── api-spec.json
│ └── signalr-spec.md
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
└── spec.md
├── memory
── constitution.md
├── scripts
── bash
── check-prerequisites.sh
│ ├── common.sh
│ ├── create-new-feature.sh
│ ├── setup-plan.sh
── setup-tasks.sh
├── specs
│ └── 001-create-taskify
│ ├── contracts
├── api-spec.json
│ └── signalr-spec.md
── data-model.md
│ ├── plan.md
── quickstart.md
├── research.md
│ └── spec.md
└── templates
├── CLAUDE-template.md
├── plan-template.md
├── spec-template.md
└── tasks-template.md
```
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).
@@ -566,7 +579,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.

View File

@@ -23,12 +23,12 @@ The following community-contributed extensions are available in [`catalog.commun
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| Agent Governance | 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) |
| Agent Governance | Project-local agent governance memory and context projection | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | 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) |
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Workflow | Generate project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
@@ -51,12 +51,10 @@ 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) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) |
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
@@ -71,7 +69,6 @@ The following community-contributed extensions are available in [`catalog.commun
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
@@ -80,11 +77,9 @@ 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) |
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
@@ -110,16 +105,13 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token 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) |

View File

@@ -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 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) |
| 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) |
| 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,10 +23,9 @@ 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, 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) |
| 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) |
| 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 | 22 templates, 8 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).

View File

@@ -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">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.
<span class="pillar-stat">91 community extensions</span> (50+ authors), <span class="pillar-stat">18 presets</span>, and growing. Tune the core process with presets, extend it with extensions, orchestrate it with workflows, or replace it entirely. Build and publish your own.
Including entirely different SDD processes:
@@ -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">106K+</span>
<span class="stat-number">96K+</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">105</span>
<span class="stat-number">91</span>
<span class="stat-label">Extensions</span>
</div>
<div class="stat-item">
<span class="stat-number">22</span>
<span class="stat-number">18</span>
<span class="stat-label">Presets</span>
</div>
<div class="stat-item">
@@ -150,5 +150,3 @@ 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>

View File

@@ -1,6 +1,6 @@
# Installing with pipx
[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/).
[pipx](https://pypa.github.io/pipx/) is a tool for installing Python CLI applications in isolated environments. It does not require [uv](https://docs.astral.sh/uv/).
## Install Specify CLI

View File

@@ -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://pipx.pypa.io/) for persistent installation
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)

View File

@@ -69,8 +69,6 @@ specify check
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.
## Version Information
```bash

View File

@@ -10,7 +10,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
@@ -19,7 +18,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
| [Junie](https://junie.jetbrains.com/) | `junie` | |

View File

@@ -388,14 +388,6 @@ Only Spec Kit infrastructure files:
### "CLI upgrade doesn't seem to work"
If a command behaves like an older Spec Kit version, first check for local CLI drift:
```bash
specify self check
```
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
Verify the installation:
```bash

View File

@@ -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](https://github.github.io/spec-kit/community/extensions.html) page for the full list of available community-contributed extensions.
See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions.
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).

View File

@@ -1,57 +0,0 @@
# 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()`).

View File

@@ -1,15 +0,0 @@
# 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 -->"

View File

@@ -1,26 +0,0 @@
---
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`.

View File

@@ -1,34 +0,0 @@
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"

View File

@@ -1,200 +0,0 @@
#!/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"

View File

@@ -1,237 +0,0 @@
#!/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"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-05-15T00: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": "Generate agent-platform repository governance files from Spec Kit metadata.",
"description": "Project-local agent governance memory and context projection.",
"author": "bigben",
"version": "1.2.0",
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.2.0.zip",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-agent-governance/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-agent-governance",
"homepage": "https://github.com/bigsmartben/spec-kit-agent-governance",
"documentation": "https://github.com/bigsmartben/spec-kit-agent-governance/blob/main/README.md",
@@ -84,8 +84,8 @@
"speckit_version": ">=0.8.0",
"tools": [
{
"name": "uv",
"required": true
"name": "python3",
"required": false
}
]
},
@@ -103,7 +103,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-05-21T00:00:00Z"
"updated_at": "2026-05-14T00:00:00Z"
},
"agent-orchestrator": {
"name": "Intelligent Agent Orchestrator",
@@ -177,10 +177,10 @@
"arch": {
"name": "Architecture Workflow",
"id": "arch",
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
"description": "Generate 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",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-arch",
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
@@ -190,7 +190,7 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 2,
"commands": 1,
"hooks": 0
},
"tags": [
@@ -203,7 +203,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
"updated_at": "2026-05-14T00:00:00Z"
},
"architect-preview": {
"name": "Architect Impact Previewer",
@@ -240,10 +240,10 @@
"architecture-guard": {
"name": "Architecture Guard",
"id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
"author": "DyanGalih",
"version": "1.8.9",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.9.zip",
"version": "1.8.4",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.4.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",
@@ -258,18 +258,17 @@
},
"tags": [
"architecture",
"spec-kit",
"review",
"refactor",
"workflow",
"governance",
"guardrails"
"drift-detection",
"refactor",
"monolithic",
"microservices"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-05-27T00:00:00Z"
"updated_at": "2026-05-11T14:58:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -1246,39 +1245,6 @@
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z"
},
"linear": {
"name": "Linear Integration",
"id": "linear",
"description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
"author": "Ash Brener",
"version": "0.2.0",
"download_url": "https://github.com/ashbrener/spec-kit-linear/archive/refs/tags/v0.2.0.zip",
"repository": "https://github.com/ashbrener/spec-kit-linear",
"homepage": "https://github.com/ashbrener/spec-kit-linear",
"documentation": "https://github.com/ashbrener/spec-kit-linear/blob/main/README.md",
"changelog": "https://github.com/ashbrener/spec-kit-linear/releases",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 5,
"hooks": 6
},
"tags": [
"issue-tracking",
"linear",
"tasks-sync",
"lifecycle-mirror",
"memory",
"cross-repo"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
},
"m365": {
"name": "Microsoft 365 Integration",
"id": "m365",
@@ -1680,8 +1646,8 @@
"id": "memorylint",
"description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.",
"author": "RbBtSn0w",
"version": "1.4.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.4.0/memorylint.zip",
"version": "1.3.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.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",
@@ -1691,8 +1657,8 @@
"speckit_version": ">=0.5.1"
},
"provides": {
"commands": 2,
"hooks": 3
"commands": 1,
"hooks": 1
},
"tags": [
"memory",
@@ -1705,7 +1671,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-05-24T01:06:49Z"
"updated_at": "2026-04-16T13:10:26Z"
},
"multi-model-review": {
"name": "Multi-Model Review",
@@ -1757,37 +1723,6 @@
"created_at": "2026-05-04T02:51:52Z",
"updated_at": "2026-05-04T02:51:52Z"
},
"multi-sites": {
"name": "Multi-Sites Spec Kit",
"id": "multi-sites",
"description": "Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support",
"author": "teeyo",
"version": "1.0.0",
"download_url": "https://github.com/teeyo/spec-kit-multi-sites/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/teeyo/spec-kit-multi-sites",
"homepage": "https://github.com/teeyo/spec-kit-multi-sites",
"documentation": "https://github.com/teeyo/spec-kit-multi-sites/blob/main/README.md",
"changelog": "https://github.com/teeyo/spec-kit-multi-sites/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"multi-site",
"drupal",
"workflow",
"process"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
},
"onboard": {
"name": "Onboard",
"id": "onboard",
@@ -1979,79 +1914,6 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"preview": {
"name": "Interactive HTML Preview",
"id": "preview",
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
"author": "bigsmartben",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-preview",
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-preview/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"preview",
"prototype",
"html",
"ux"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
},
"product": {
"name": "Product Spec Extension",
"id": "product",
"description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.",
"author": "d0whc3r",
"version": "0.8.3",
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.8.3/product-0.8.3.zip",
"repository": "https://github.com/d0whc3r/spec-kit-product",
"homepage": "https://d0whc3r.github.io/spec-kit-product/",
"documentation": "https://github.com/d0whc3r/spec-kit-product/wiki",
"changelog": "https://github.com/d0whc3r/spec-kit-product/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 4,
"hooks": 3
},
"tags": [
"design",
"documentation",
"jtbd",
"lean-prd",
"planning",
"prd",
"prfaq",
"product",
"product-management",
"requirements",
"spec",
"spec-kit",
"spec-kit-extension",
"stakeholder",
"technical-design"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-26T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
},
"product-forge": {
"name": "Product Forge",
"id": "product-forge",
@@ -2114,38 +1976,6 @@
"created_at": "2026-04-01T00:00:00Z",
"updated_at": "2026-04-01T00:00:00Z"
},
"rag-azure-builder": {
"name": "RAG Azure Builder",
"id": "rag-azure-builder",
"description": "Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows.",
"author": "Sertxito",
"version": "1.2.0",
"download_url": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder/archive/refs/tags/v1.2.0.zip",
"repository": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder",
"homepage": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder",
"documentation": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder#readme",
"changelog": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"commands": 5,
"hooks": 0
},
"tags": [
"azure",
"rag",
"search",
"onboarding",
"cost-optimization"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
},
"ralph": {
"name": "Ralph Loop",
"id": "ralph",
@@ -2324,8 +2154,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.1.0",
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.1.0.zip",
"version": "1.0.0",
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
"homepage": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
"documentation": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd#readme",
@@ -2355,7 +2185,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-13T00:00:00Z",
"updated_at": "2026-05-30T00:00:00Z"
"updated_at": "2026-05-13T00:00:00Z"
},
"retro": {
"name": "Retro Extension",
@@ -2751,55 +2581,6 @@
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
"speckit-superpowers-bridge": {
"name": "Superpowers Implementation Bridge",
"id": "speckit-superpowers-bridge",
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
"author": "lihan3238",
"version": "0.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",
@@ -2868,21 +2649,21 @@
"squad": {
"name": "Squad Bridge",
"id": "squad",
"description": "Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks.",
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
"author": "jwill824",
"version": "1.3.0",
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.3.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/jwill824/spec-kit-squad",
"homepage": "https://github.com/jwill824/spec-kit-squad",
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.11",
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "@bradygaster/squad-cli",
"version": ">=0.9.4",
"version": ">=0.1.0",
"required": true
}
]
@@ -2902,7 +2683,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-29T00:00:00Z",
"updated_at": "2026-05-20T00:00:00Z"
"updated_at": "2026-04-29T00:00:00Z"
},
"staff-review": {
"name": "Staff Review Extension",
@@ -3001,8 +2782,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.4.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.4.0/superpowers-bridge.zip",
"version": "1.3.0",
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.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",
@@ -3020,7 +2801,7 @@
},
"provides": {
"commands": 8,
"hooks": 3
"hooks": 4
},
"tags": [
"methodology",
@@ -3037,7 +2818,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-30T00:00:00Z",
"updated_at": "2026-05-24T01:07:34Z"
"updated_at": "2026-04-16T14:08:23Z"
},
"superpowers-bridge": {
"name": "Superpowers Bridge",
@@ -3104,37 +2885,6 @@
"created_at": "2026-03-02T00:00:00Z",
"updated_at": "2026-03-02T00:00:00Z"
},
"team-assign": {
"name": "Team Assign",
"id": "team-assign",
"description": "Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard",
"author": "tarunkumarbhati",
"version": "1.0.0",
"download_url": "https://github.com/tarunkumarbhati/spec-kit-team-assign/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
"homepage": "https://github.com/tarunkumarbhati/spec-kit-team-assign",
"documentation": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/README.md",
"changelog": "https://github.com/tarunkumarbhati/spec-kit-team-assign/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3
},
"tags": [
"team",
"assignment",
"process",
"planning",
"subtasks"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-20T00:00:00Z",
"updated_at": "2026-05-20T00:00:00Z"
},
"time-machine": {
"name": "Time Machine",
"id": "time-machine",
@@ -3267,48 +3017,6 @@
"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",

View File

@@ -3,20 +3,6 @@
"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",

View File

@@ -35,7 +35,7 @@ Replace the script to add project-specific Git initialization steps:
## Output
On success:
- `[OK] Git repository initialized`
- ` Git repository initialized`
## Graceful Degradation

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 "[OK] Git repository initialized"
Write-Host " Git repository initialized"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-13T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -12,15 +12,6 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "anthropic"]
},
"cline": {
"id": "cline",
"name": "Cline",
"version": "1.0.0",
"description": "Cline IDE integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"copilot": {
"id": "copilot",
"name": "GitHub Copilot",
@@ -281,15 +272,6 @@
"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"]
}
}
}

View File

@@ -1,138 +0,0 @@
# Spec Kit - May 2026 Newsletter
This edition covers Spec Kit activity in May 2026 — a month defined by three milestone 100s: **100,000+ stars**, **100+ community extensions**, and recognition as a **top-100 GitHub project**. Fourteen releases shipped (v0.8.4 through v0.8.17), delivering multi-agent install support, constitution governance enforcement, and continued architecture cleanup. The Open Source Friday livestream, a wave of multilingual coverage, and analyst recognition from The Futurum Group marked the project's transition from fast-moving experiment to established ecosystem. A summary is in the table below, followed by details.
| **Spec Kit Core (May 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and 25+ more articles — now tracked across multiple languages following an expanded discovery methodology. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
***
> **A Month of 100s.** May 2026 was defined by three milestones that all share the same number. The community extension catalog crossed **100 entries** during the week of May 21, making Spec Kit a genuine platform with more capabilities in its ecosystem than in its core. The repository crossed **100,000 GitHub stars** on the same week. And with 107K stars at month's end, Spec Kit now ranks among the **top 100 most-starred projects on all of GitHub**. None of this would have happened without the community — the contributors, extension authors, preset builders, article writers, and practitioners who turned a spec-driven development experiment into an ecosystem. Thank you.
## Spec Kit Project Updates
### Releases Overview
**v0.8.4v0.8.7** (May 17) opened the month with four patch releases delivering the most-requested feature of the year: **multi-install support for concurrent AI agent integrations** (#2389), enabling multiple agents in a single project. This closed five long-standing issues dating back 228 days. The releases also added **constitution governance in `/speckit.implement`** (#2460), ensuring the implement phase now loads `constitution.md` to enforce governance during code generation. An **authentication provider registry** (#2393) added config-driven multi-platform auth. The **Lingma agent** joined the integration roster. Security hardening included pinning all remaining GitHub Actions to immutable SHAs (#2441) and URL scheme validation to prevent SSRF-style bugs (#2449). Seven new community extensions and six new governance-themed presets landed. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.8.8v0.8.10** (May 814) shipped three releases focused on stability. **Version feature reporting** (#2548) improved upgrade visibility. Bug fixes addressed the Kiro CLI `$ARGUMENTS` placeholder (#1926, open 52 days), markdownlint-safe template metadata (#1343, open 147 days), and preset skill description precedence. The `__init__.py` decomposition series began with PRs 12/8, extracting `_console.py`, `_assets.py`, and `_utils.py`. Seven new extensions joined (Architecture Workflow, Agent Governance, BrownKit, Schedule, Reqnroll BDD, MDE, Changelog) along with two new presets (MDE, game-narrative-writing). The docs site received a major overhaul: the landing page was revamped with a four-pillar card layout, the install section was streamlined, and the community extensions table moved to the docs site. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.8.11v0.8.13** (May 1521) delivered three releases as the repo **crossed 100K stars**. **Agentic catalog submissions** (#2655) added AI-assisted workflows for community catalog contributions. A **high-assurance spec workflow** was documented (#2518). The while/do-while loop stale output bug (#2592) was caught and fixed same-day. **Integration auto mode** (#2421) now follows the project's initialized AI instead of hardcoding Copilot. The PowerShell UTF-8 BOM issue (#2280) was resolved. Four new extensions joined (Team Assign, Interactive HTML Preview, Time Machine, Superpowers Implementation Bridge), bringing the catalog to **103 entries** — crossing the 100 mark. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.8.14v0.8.17** (May 2228) closed the month with four releases. The **Hermes Agent** joined as a new integration target (#2651). Workflows gained a **`{{ context.run_id }}` template variable** (#2664). A new `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` environment variable (#2596) lets users pass extra flags to agent subprocesses. **Extension installs from URLs now prompt for confirmation** (#2745), a security improvement for URL-based installs. The spec quality checklist is now **re-validated after clarify updates the spec** (#2715). Token Budget, Product Spec, and Workflow Preset extensions joined the catalog, bringing it to **105 entries**. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Architecture & Refactoring
The most significant internal effort in May was the **`__init__.py` decomposition series**, progressing through PRs 14 of 8. This systematic extraction moved `_console.py`, `_assets.py`, `_utils.py`, `_version.py`, and the `commands/` package out of the monolithic init module, improving maintainability and contributor onboarding. The **ExtensionCatalog was migrated to the shared catalog stack base** (#2437), reducing duplicated catalog handling across extension, preset, and integration catalogs. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Bug Fixes and Security
Fourteen releases produced a strong cadence of fixes. Long-standing issues resolved include the Kiro CLI `$ARGUMENTS` placeholder (52 days), markdownlint template metadata line breaks (147 days), and the `--ai` flag for adding agent commands (136 days). The PowerShell UTF-8 BOM issue was fixed, preset skill rendering now correctly resolves `__SPECKIT_COMMAND_*__` refs (#2717), and a Windows gate-step crash was addressed (#2635).
Security improvements included **URL-based extension install confirmation** (#2745), **pinning GitHub Actions to immutable SHAs** (#2441), **URL scheme validation** (#2449), and restricting community submission workflows to labeled events only (#2741). [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Extension & Preset Ecosystem
The community extension catalog grew from 92 to **105 entries** during May, crossing the **100 mark** on May 21. Thirteen new extensions were added over the month. Community presets grew from 18 to **21 entries**, with three new presets added.
Notable new extensions by category:
- **Architecture & governance**: Architecture Workflow (bigsmartben), Agent Governance (bigben), Architecture Guard (DyanGalih), BrownKit (Maksim Shautsou)
- **Cost & token management**: Cost Tracker (Quratulain-bilal), Token Analyzer (Chris Roberts), Token Budget (Tine Kondo)
- **Agent orchestration**: Agent Orchestrator (pragya247), Multi-Model Review (formin)
- **Project management**: Team Assign (tarunkumarbhati), Changelog (Quratulain-bilal)
- **Cloud & enterprise**: Spec2Cloud for Azure (Azure Samples), .NET Framework to Modern .NET Migration (RogerBestMsft)
- **API & lifecycle**: API Evolve (Quratulain-bilal), Product Spec (spec-kit-product contributors)
- **Quality**: Schedule with CP-SAT solver (Julio César Franco Ardila), Reqnroll BDD (LoogaCY Studio), MDE (AI-MDE)
- **Spec exploration**: Interactive HTML Preview (bigsmartben), Time Machine (te3yo)
- **Cross-tool bridges**: Superpowers Implementation Bridge (lihan3238)
New governance-themed presets dominated: a11y-governance, architecture-governance, security-governance, cross-platform-governance, agent-parity-governance, and Spec2Cloud preset. Creative presets included game-narrative-writing and MDE.
The extension ecosystem also showed maturation through active maintenance. **Architecture Guard** progressed through four releases (v1.6.7 → v1.8.9), adding documentation quality improvements and governance features. **Memory MD** shipped multiple updates (v0.6.9 → v0.8.0), adding a `speckit.memory-md.log-finding` command. **Security Review** reached v1.4.5 with a new `speckit.security-review.log-finding` command. **Superpowers Implementation Bridge** evolved rapidly (v0.5.0 → v0.7.0). **Squad Bridge** updated to v1.3.0, **Fiction Book Writing** to v1.8.1, **Security Governance** to v0.4.0, and **MemoryLint** to v1.4.0. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
### Documentation & Docs Site
The docs site received its most significant update since launch. The **landing page was revamped** with a four-pillar card layout (#2531). The **install section was streamlined** (#2561). The **community extensions table** was moved from the README to the docs site (#2560), reducing README length while improving discoverability. **Community sections in the README** were consolidated (#2736). The **uv installation guide** was added with inline callouts (#2465). Landing page stats and branch naming conventions were updated (#2727). [\[github.com\]](https://github.com/github/spec-kit/releases)
## Community & Content
### The Open Source Friday Livestream
On **May 8**, the **GitHub Open Source Friday livestream** featured Spec Kit, hosted by Andrea Griffiths with lead maintainer Manfred Riem. The livestream demonstrated a full SDD workflow building a time-zone-aware command-line utility with GitHub Copilot in VS Code. Riem described AI agents as "a very capable intern and a very quick intern but it's still an intern nonetheless." He emphasized that "the spec is always the source of truth" and highlighted the community ecosystem, noting the project was "nearing the 100 mark" for extensions. The livestream drove significant press attention in the following days. [\[youtube.com\]](https://www.youtube.com/watch?v=2IArMAhkJcE)
### Press and Industry Coverage
May produced the broadest press coverage to date, with publications from the mainstream developer media covering Spec Kit for the first time.
**Visual Studio Magazine** (David Ramel, May 12) published *"GitHub Spec Kit Takes Off as Antidote to Piecemeal 'Vibe Coding'"*, reporting on the Open Source Friday livestream and the growing ecosystem. The article noted Spec Kit's story is "no longer just that GitHub open sourced a spec-driven development toolkit last fall" but that "the toolkit is becoming a fast-moving ecosystem for teams trying to make AI-assisted development more structured, repeatable and traceable." [\[visualstudiomagazine.com\]](https://visualstudiomagazine.com/articles/2026/05/12/github-spec-kit-takes-off-as-antidote-to-piecemeal-vibe-coding.aspx)
**DevOps.com** (Tom Smith, May 11) published *"GitHub's Spec Kit Puts the Spec Back in Software Development"*, featuring analyst commentary from The Futurum Group (see The Analyst View below). [\[devops.com\]](https://devops.com/githubs-spec-kit-puts-the-spec-back-in-software-development/)
**MarkTechPost** (Asif Razzaq, May 8) published two articles: a comprehensive step-by-step tutorial calling Spec Kit an open-source toolkit with "90k+ stars" and "one of the faster-growing developer tooling repositories," and a 9-tool SDD comparison calling Spec Kit **"the most community-adopted open-source option"** and "the default starting point for teams new to SDD." [\[marktechpost.com\]](https://www.marktechpost.com/2026/05/08/meet-github-spec-kit-an-open-source-toolkit-for-spec-driven-development-with-ai-coding-agents/)
**HackerNoon** (Andrey Kucherenko, May 6) published *"The Spec-First Development Showdown"*, a hands-on comparison of Spec Kit, OpenSpec, BMAD, and Gangsta Agents. [\[hackernoon.com\]](https://hackernoon.com/the-spec-first-development-showdown-spec-kit-openspec-bmad-and-gangsta-agents-compared)
### Developer Articles and Blog Posts
May produced a wave of independent coverage — well beyond any previous month. Starting this month, article discovery was expanded beyond English-centric search engines to include language-appropriate engines for 25+ languages, so the broader coverage partly reflects wider discovery rather than a sudden spike.
Notable non-English coverage:
- **Japanese**: テックオーシャン published a detailed experience report on *"Claude Code × Spec Kit"* on note.com, praising task decomposition accuracy while noting spec sync requires manual workarounds. [\[note.com\]](https://note.com/techocean_corp/n/nd2bd63106c16)
- **Portuguese**: Jady Sobjak de Mello Godoi published *"GitHub Spec Kit: Revolucionando o Desenvolvimento com SDD"* on DEV Community. [\[dev.to\]](https://dev.to/jadysmgodoi/github-speckit-revolucionando-o-desenvolvimento-com-sdd-l66)
- **Italian**: Cosmonet published a comprehensive guide, *"GitHub Spec Kit: la guida completa allo Spec-Driven Development."* [\[cosmonet.info\]](https://www.cosmonet.info/github-spec-kit-guida-spec-driven-development/)
- **French**: InnoSpira covered Spec Kit's rapid growth past 100K stars. [\[innospira.fr\]](https://www.innospira.fr/index.php/2026/05/12/github-spec-kit-place-au-developpement-pilote-par-la-spec/)
- **Spanish**: Q2B Studio published an overview for Spanish-speaking developers. [\[q2bstudio.com\]](https://www.q2bstudio.com/nuestro-blog/1727819/github-spec-kit-desarrollo-especificaciones-ia)
Notable English-language articles:
- **Truong Phung** (DEV Community, May 29) published a comprehensive production playbook for AI-assisted development, referencing Spec Kit (see The Production Playbook Pattern below). [\[dev.to\]](https://dev.to/truongpx396/building-production-grade-fullstack-products-with-ai-coding-agents-a-practical-playbook-2idd)
- **Mehul Gupta** (Medium, May 17) called Spec Kit "an operating system for AI-assisted software engineering." [\[medium.com\]](https://medium.com/data-science-in-your-pocket/what-is-github-spec-kit-bye-bye-vibe-coding-37efbaa32880)
- **Kento IKEDA** (DEV Community / AWS Builders, May 2) examined the emerging three-layer pattern for AI agent instructions (AGENTS.md, SKILL.md, DESIGN.md), referencing Spec Kit's approach. [\[dev.to\]](https://dev.to/aws-builders/agentsmd-skillmd-designmd-how-ai-instructions-split-into-three-layers-d0g)
- **PyShine** (May 13) published a detailed guide covering the 6-step workflow, 30+ integrations, and 60+ extensions. [\[pyshine.com\]](https://pyshine.com/GitHub-Spec-Kit-Spec-Driven-Development/)
- **DeployHQ** (Alex M, May 13) examined the "deployment gap" — Spec Kit ends at code, Workspaces ends at PR — and showed how to wire DeployHQ into the post-merge step. [\[deployhq.com\]](https://www.deployhq.com/blog/spec-kit-copilot-workspaces-deployment)
- **spec-coding.dev** (May 11) examined five practical SDD patterns shared by OpenSpec, Superpowers, and Spec Kit. [\[spec-coding.dev\]](https://spec-coding.dev/blog/spec-driven-development-tools-openspec-spec-kit-superpowers)
- **kiadev.net** (Ignaty Kashnitsky, May 9) published two articles: a detailed technical protocol and a 9-tool comparison recommending Spec Kit as a "portable, community-driven starting point." [\[kiadev.net\]](https://www.kiadev.net/news/2026-05-09-github-spec-kit-sdd-toolkit)
Coverage also appeared on WinBuzzer, Let's Data Science, Openflows, AI in Plain English (Medium), Artiverse, KnightLi Blog (multilingual EN/CN/JP/ES), and fundesk.io.
### Community Growth by the Numbers
| Metric | Start of May | End of May | Change |
| --- | --- | --- | --- |
| GitHub stars | 92,038 | 106,951 | +14,913 (+16%) |
| Forks | ~8,000 | 9,464 | +~1,500 |
| Contributors | — | 217 | — |
| Releases (total) | 135 | 152 | +17 (incl. 3 late-April) |
| Community extensions | 92 | 105 | +13 |
| Community presets | 18 | 21 | +3 |
| Discussions (open) | ~400 | 422 | +~22 |
## SDD Ecosystem & Industry Trends
### The Analyst View
The Futurum Group's **Mitch Ashley** provided the most significant analyst framing of SDD to date on DevOps.com: "GitHub's Spec Kit signals AI-assisted coding is shifting from prompts to durable, versioned specifications. Vendors are competing to own the artifact that governs intent across Copilot, Claude Code, and Gemini CLI." He warned that "verification at each checkpoint cannot be deferred to the agent producing it" — echoing the project's own emphasis on human oversight at phase boundaries. [\[devops.com\]](https://devops.com/githubs-spec-kit-puts-the-spec-back-in-software-development/)
### The Production Playbook Pattern
**Truong Phung's** 61-minute production playbook represented a new level of depth in community content. Rather than reviewing Spec Kit as a tool, Phung treated SDD as a given and built a comprehensive guide around the **Spec → Plan → Code → Verify loop**, with Spec Kit and Superpowers as the reference implementations. His seven opening truths — "the bottleneck moved from typing to thinking," "context engineering > prompt engineering," and "the PR is the unit of work, not the ticket" — capture the emerging practitioner consensus around structured AI development. [\[dev.to\]](https://dev.to/truongpx396/building-production-grade-fullstack-products-with-ai-coding-agents-a-practical-playbook-2idd)
### Competitive Landscape
The **MarkTechPost comparison** of nine SDD tools called Spec Kit "the most community-adopted open-source option," while positioning competitors along distinct axes: **Kiro** (integrated IDE with EARS-based specs and agent hooks), **BMAD-METHOD** (~48K stars, 12+ specialized agents), **GSD** (~64K stars, lean meta-prompting), **Augment Code** (context engine for 400K+ files, not a spec authoring tool), **OpenSpec** (~52K stars, change accountability and audit trails), and **Tessl** (spec registry with 10K+ library specs). [\[marktechpost.com\]](https://www.marktechpost.com/2026/05/08/9-best-ai-tools-for-spec-driven-development-in-2026-kiro-bmad-gsd-and-more-compare/)
With 107K stars at month's end, Spec Kit is the **only spec-driven development tool in the top 100 most-starred repositories on GitHub** — none of the competitors above are close to the 100K threshold. The broader top-100 list includes AI-adjacent projects like agentic skills frameworks (obra/superpowers at 212K, anthropics/skills at 143K), agent harness tools, and LLM inference engines, but Spec Kit is the only one built around a spec-first development workflow. [\[github.com\]](https://github.com/search?q=stars%3A%3E100000&type=repositories&s=stars&o=desc)
## Roadmap
Areas under discussion or in progress for future development:
- **CLI architecture cleanup** — the `__init__.py` decomposition (4/8 complete) continues toward a modular command structure. This internal cleanup improves contributor onboarding and test isolation. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Spec lifecycle management** — spec drift and context rot remain the most cited concern across articles (DevOps.com, DeployHQ, テックオーシャン). The clarify re-validation (#2715) and reconcile extensions are incremental steps; a more comprehensive solution is expected. [\[devops.com\]](https://devops.com/githubs-spec-kit-puts-the-spec-back-in-software-development/)
- **Multi-agent workflows** — multi-install support (#2389) was the most-requested feature. The next frontier is orchestrating multiple agents across phases, a pattern the community's MAQA, Fleet, and Conduct extensions already explore. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Catalog maturity** — catalog discovery CLI (v0.8.3), agentic submissions (v0.8.13), and GITHUB_TOKEN auth (v0.8.2) are building toward a package-manager experience. As the catalog grows past 100 entries, curation and quality signals become critical. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Experience simplification** — the deployment gap (DeployHQ), ceremony overhead for small tasks (テックオーシャン, spec-coding.dev), and verbose output (Thoughtworks Radar) continue as open concerns. The lean preset, TinySpec extension, and workflow engine provide answers; discoverability of these options remains an opportunity. [\[deployhq.com\]](https://www.deployhq.com/blog/spec-kit-copilot-workspaces-deployment)
- **Toward a stable release** — fourteen releases in one month reflects pre-1.0 momentum. The git extension default-off notice (#2432, gated at v0.10.0) and the `--no-git` deprecation (removal at v0.10.0) signal a path toward API stabilization. [\[github.com\]](https://github.com/github/spec-kit/releases)

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-05-28T00:00:00Z",
"updated_at": "2026-05-05T10: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.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.",
"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.",
"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.8.1.zip",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.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": 25,
"commands": 33,
"templates": 22,
"commands": 27,
"scripts": 2
},
"tags": [
@@ -254,7 +254,7 @@
"language-support"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-05-24T08:00:00Z"
"updated_at": "2026-04-27T08:00:00Z"
},
"game-narrative-writing": {
"name": "Game Narrative Writing",
@@ -472,11 +472,11 @@
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
"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.",
"version": "0.2.0",
"description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA 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.4.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.2.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,28 +491,11 @@
"security",
"governance",
"msl",
"ssdf",
"asvs",
"supply-chain",
"sbom",
"ai-sbom",
"vex",
"slsa",
"cwe-top-25",
"secure-coding",
"rust",
"go",
"swift",
"java",
"kotlin",
"python",
"typescript",
"g7",
"bsi",
"cra"
"supply-chain"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-05-26T00:00:00Z"
"updated_at": "2026-04-27T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",
@@ -589,34 +572,6 @@
"clarify",
"interactive"
]
},
"workflow-preset": {
"name": "Workflow Preset",
"id": "workflow-preset",
"version": "1.3.1",
"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/releases/download/v1.3.1/spec-kit-workflow-preset-v1.3.1.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": 22,
"commands": 8
},
"tags": [
"behavior",
"bdd",
"planning",
"implementation",
"handoff"
],
"created_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-05-28T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.9.1"
version = "0.8.11"
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,7 +40,6 @@ 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>`)

View File

@@ -78,12 +78,13 @@ done
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
# Get feature paths and validate branch
_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 (no validation)
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
if $PATHS_ONLY; then
if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed)
@@ -111,26 +112,23 @@ 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_COMMAND_SPECIFY__ first to create the feature structure." >&2
echo "Run /speckit.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_COMMAND_PLAN__ first to create the implementation plan." >&2
echo "Run /speckit.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_COMMAND_TASKS__ first to create the task list." >&2
echo "Run /speckit.tasks first to create the task list." >&2
exit 1
fi

View File

@@ -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_COMMAND_PLAN__ can skip git branch pattern checks).
# and matches the resolved active FEATURE_DIR (so /speckit.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_COMMAND_SPECIFY__)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Branch-name-based prefix lookup (legacy fallback)
local feature_dir
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
@@ -642,3 +642,4 @@ except Exception:
printf '%s' "$content"
return 0
}

View File

@@ -40,31 +40,15 @@ fi
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"
# 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
# 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"
else
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
echo "Warning: Plan template not found"
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
# Output results

View File

@@ -35,13 +35,13 @@ fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
echo "Run /speckit.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_COMMAND_SPECIFY__ first to create the feature structure." >&2
echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi

View File

@@ -56,10 +56,14 @@ EXAMPLES:
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
# Get feature paths and validate branch
$paths = Get-FeaturePathsEnv
# If paths-only mode, output paths and exit (no validation)
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 ($PathsOnly) {
if ($Json) {
[PSCustomObject]@{
@@ -81,28 +85,23 @@ 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_COMMAND_SPECIFY__ first to create the feature structure."
Write-Output "Run /speckit.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_COMMAND_PLAN__ first to create the implementation plan."
Write-Output "Run /speckit.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_COMMAND_TASKS__ first to create the task list."
Write-Output "Run /speckit.tasks first to create the task list."
exit 1
}

View File

@@ -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_COMMAND_PLAN__ can skip git branch pattern checks).
# active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.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_COMMAND_SPECIFY__)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.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 " [OK] $Description"
Write-Output " $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
Write-Output " $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 " [OK] $Description"
Write-Output " $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
Write-Output " $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
}
}

View File

@@ -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

View File

@@ -33,25 +33,17 @@ 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 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"
}
# Copy plan template if it exists, otherwise note it or create empty file
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
$content = [System.IO.File]::ReadAllText($template)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
} else {
$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
}
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

View File

@@ -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_COMMAND_PLAN__ first to create the implementation plan.")
[Console]::Error.WriteLine("Run /speckit.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_COMMAND_SPECIFY__ first to create the feature structure.")
[Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.")
exit 1
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
"""Agent configuration constants derived from the integration registry."""
from __future__ import annotations
from typing import Any
def _build_agent_config() -> dict[str, dict[str, Any]]:
from .integrations import INTEGRATION_REGISTRY
config: dict[str, dict[str, Any]] = {}
for key, integration in INTEGRATION_REGISTRY.items():
if integration.config:
config[key] = dict(integration.config)
return config
AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot"
AI_ASSISTANT_ALIASES: dict[str, str] = {
"kiro": "kiro-cli",
}
def _build_ai_assistant_help() -> str:
non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
base_help = (
f"AI assistant to use: {', '.join(non_generic_agents)}, "
"or generic (requires --ai-commands-dir)."
)
if not AI_ASSISTANT_ALIASES:
return base_help
alias_phrases = []
for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
alias_phrases.append(f"'{alias}' as an alias for '{target}'")
if len(alias_phrases) == 1:
aliases_text = alias_phrases[0]
else:
aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1]
return base_help + " Use " + aliases_text + "."
AI_ASSISTANT_HELP: str = _build_ai_assistant_help()
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}

View File

@@ -67,33 +67,6 @@ class CommandRegistrar:
except ImportError:
pass # Circular import during module init; retry on next access
@staticmethod
def _hyphenate_frontmatter_refs(val: Any) -> Any:
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
if isinstance(val, dict):
return {
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
for k, v in val.items()
}
elif isinstance(val, list):
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
elif isinstance(val, str):
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
val,
)
return val
@staticmethod
def _hyphenate_body_refs(body: str) -> str:
"""Hyphenate dotted speckit references in command body text."""
return re.sub(
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
lambda m: m.group(0).replace(".", "-"),
body,
)
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
@@ -401,15 +374,8 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
# 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 ""
# Resolve __CONTEXT_FILE__ from init-options
context_file = init_opts.get("context_file") or ""
body = body.replace("__CONTEXT_FILE__", context_file)
return CommandRegistrar.rewrite_project_relative_paths(body)
@@ -435,9 +401,6 @@ class CommandRegistrar:
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
format_name = agent_config.get("format_name")
if format_name:
return format_name(cmd_name)
return cmd_name
short_name = cmd_name
@@ -467,13 +430,6 @@ class CommandRegistrar:
if not normalized.is_relative_to(base_normalized):
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
@staticmethod
def _is_safe_command_name(name: str) -> bool:
"""Reject names that could escape the commands directory via path traversal."""
if os.path.sep in name or "/" in name or "\\" in name:
return False
return os.path.normpath(name) == name
def register_commands(
self,
agent_name: str,
@@ -483,7 +439,6 @@ 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.
@@ -498,9 +453,6 @@ 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
@@ -519,11 +471,9 @@ class CommandRegistrar:
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
is_cline_ext = agent_name == "cline" and source_id != "core"
for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]
source_file = source_dir / cmd_file
@@ -555,10 +505,6 @@ class CommandRegistrar:
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
if is_cline_ext:
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
body = self._hyphenate_body_refs(body)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
@@ -613,22 +559,14 @@ 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)
self._write_registered_output(
dest_file,
output,
source_dir,
agent_name,
output_name,
agent_config["extension"],
link_outputs,
)
dest_file.write_text(output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, cmd_name)
registered.append(cmd_name)
for alias in aliases:
for alias in cmd_info.get("aliases", []):
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
@@ -687,56 +625,13 @@ class CommandRegistrar:
)
self._ensure_inside(alias_file, commands_dir)
alias_file.parent.mkdir(parents=True, exist_ok=True)
self._write_registered_output(
alias_file,
alias_output,
source_dir,
agent_name,
alias_output_name,
agent_config["extension"],
link_outputs,
)
alias_file.write_text(alias_output, encoding="utf-8")
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.
@@ -759,28 +654,15 @@ class CommandRegistrar:
) -> Path:
"""Return the agent command directory, falling back to legacy_dir.
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.
When the canonical directory (``agent_config["dir"]``) does not
exist but a ``legacy_dir`` is configured and present on disk,
returns the legacy path and emits a deprecation warning advising
the user to upgrade.
Integrations that do not declare ``legacy_dir`` get the canonical
path unconditionally — no fallback, no warning.
"""
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
agent_dir = project_root / agent_config["dir"]
if not agent_dir.exists():
legacy = agent_config.get("legacy_dir")
if legacy:
@@ -805,7 +687,6 @@ 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.
@@ -815,8 +696,6 @@ 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
@@ -825,15 +704,6 @@ 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,
)
@@ -848,7 +718,6 @@ class CommandRegistrar:
project_root,
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
)
if registered:
results[agent_name] = registered
@@ -864,7 +733,6 @@ 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.
@@ -878,8 +746,6 @@ 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
@@ -889,11 +755,6 @@ 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,
)
@@ -907,7 +768,6 @@ class CommandRegistrar:
project_root,
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
)
if registered:
results[agent_name] = registered
@@ -952,32 +812,22 @@ class CommandRegistrar:
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
names_to_clean = [output_name]
if output_name != cmd_name and self._is_safe_command_name(cmd_name):
names_to_clean.append(cmd_name)
for target_dir in dirs_to_clean:
for name in names_to_clean:
cmd_file = (
target_dir / f"{name}{agent_config['extension']}"
)
try:
self._ensure_inside(cmd_file, target_dir)
except ValueError:
continue
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/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
cmd_file = (
target_dir / f"{output_name}{agent_config['extension']}"
)
if cmd_file.exists():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
# SKILL.md). Remove the parent dir when it becomes
# empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != target_dir and parent.exists():
try:
parent.rmdir()
except OSError:
pass
if agent_name == "copilot":
prompt_file = (

View File

@@ -1,7 +0,0 @@
"""CLI command groups extracted from the main application.
Implemented command modules expose a ``register(app)`` function. Placeholder
modules are import-only anchors for command groups that still live in the main
application module.
"""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify extension * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,784 +0,0 @@
"""specify init command."""
from __future__ import annotations
import os
import shlex
import shutil
import sys
from pathlib import Path
from typing import Any
import typer
from rich.live import Live
from rich.panel import Panel
from .._agent_config import (
AGENT_CONFIG,
AI_ASSISTANT_ALIASES,
AI_ASSISTANT_HELP,
DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES,
)
from .._assets import (
_locate_bundled_extension,
_locate_bundled_preset,
_locate_bundled_workflow,
get_speckit_version,
)
from .._console import StepTracker, console, select_with_arrows, show_banner
from .._utils import check_tool, init_git_repo, is_git_repo
def _build_integration_equivalent(
integration_key: str,
ai_commands_dir: str | None = None,
) -> str:
parts = [f"--integration {integration_key}"]
if integration_key == "generic" and ai_commands_dir:
parts.append(
f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
)
return " ".join(parts)
def _build_ai_deprecation_warning(
integration_key: str,
ai_commands_dir: str | None = None,
) -> str:
replacement = _build_integration_equivalent(
integration_key,
ai_commands_dir=ai_commands_dir,
)
return (
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
f"Use [bold]{replacement}[/bold] instead."
)
def _stdin_is_interactive() -> bool:
return sys.stdin.isatty()
def ensure_constitution_from_template(
project_path: Path, tracker: StepTracker | None = None
) -> None:
"""Copy constitution template to memory if it doesn't exist."""
memory_constitution = project_path / ".specify" / "memory" / "constitution.md"
template_constitution = project_path / ".specify" / "templates" / "constitution-template.md"
if memory_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.skip("constitution", "existing file preserved")
return
if not template_constitution.exists():
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", "template not found")
return
try:
memory_constitution.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(template_constitution, memory_constitution)
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
else:
console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e:
if tracker:
tracker.add("constitution", "Constitution setup")
tracker.error("constitution", str(e))
else:
console.print(f"[yellow]Warning: Could not initialize constitution: {e}[/yellow]")
def register(app: typer.Typer) -> None:
@app.command()
def init(
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
"""
Initialize a new Specify project.
Project files are scaffolded from assets bundled inside the specify-cli
package, so initialization does not need network access and templates
match the installed CLI version.
This command will:
1. Check that required tools are installed (git is optional)
2. Let you choose your coding agent integration, or default to Copilot
in non-interactive sessions
3. Install bundled Spec Kit templates, scripts, workflow, and shared
project infrastructure
4. Initialize a fresh git repository (if not --no-git and no existing repo)
5. Set up coding agent integration commands and optional presets
Examples:
specify init my-project
specify init my-project --integration claude
specify init my-project --integration copilot --no-git
specify init --ignore-agent-tools my-project
specify init . --integration claude # Initialize in current directory
specify init . # Initialize in current directory (interactive integration selection)
specify init --here --integration claude # Alternative syntax for current directory
specify init --here --integration codex --integration-options="--skills"
specify init --here --integration codebuddy
specify init --here --integration vibe # Initialize with Mistral Vibe support
specify init --here
specify init --here --force # Skip confirmation when current directory not empty
specify init my-project --integration claude # Claude installs skills by default
specify init --here --integration gemini
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
specify init my-project --integration claude --preset healthcare-compliance # With preset
"""
# Lazy imports to avoid circular dependency — __init__.py imports this module
from .. import (
_install_shared_infra_or_exit,
_parse_integration_options,
_print_cli_warning,
_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"
cline_skill_mode = selected_ai == "cline"
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 or cline_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)

View File

@@ -1,2 +0,0 @@
"""specify integration * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify preset * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -1,2 +0,0 @@
"""specify workflow * commands — placeholder for future extraction."""
from __future__ import annotations

View File

@@ -25,8 +25,6 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
"analyze",
"checklist",
@@ -109,8 +107,13 @@ def normalize_priority(value: Any, default: int = 10) -> int:
@dataclass
class CatalogEntry(BaseCatalogEntry):
class CatalogEntry:
"""Represents a single catalog entry in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
class ExtensionManifest:
@@ -761,28 +764,7 @@ class ExtensionManager:
if not ignore_file.exists():
return None
# Pin UTF-8 explicitly: ``Path.read_text`` defaults to the system
# locale codec on Windows (cp1252 / gb2312 / cp932), which silently
# corrupts multibyte patterns when the file is shared across
# machines with different locales. The next line already
# normalises backslashes "so Windows-authored files work" — the
# codebase already expects Windows authors to write this file.
#
# A file that is not valid UTF-8 is a user-authoring mistake, so
# surface it as ``ValidationError`` with a pointer to the offending
# byte — the same pattern ``ExtensionManifest._load_yaml`` uses
# for ``extension.yml`` (see ``UnicodeDecodeError`` handler in
# this module). Without the wrap, the raw ``UnicodeDecodeError``
# would abort installation with a Python traceback instead of a
# clear message naming the file.
try:
raw = ignore_file.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
raise ValidationError(
f".extensionignore is not valid UTF-8: {ignore_file} "
f"({e.reason} at byte {e.start})"
)
lines: List[str] = raw.splitlines()
lines: List[str] = ignore_file.read_text().splitlines()
# Normalise backslashes in patterns so Windows-authored files work
normalised: List[str] = []
@@ -822,29 +804,42 @@ class ExtensionManager:
def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for extension skill registration.
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.
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.
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.
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.
"""
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.",
)
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:
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.
@@ -856,8 +851,6 @@ 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).
@@ -910,18 +903,9 @@ class ExtensionManager:
# Check if skill already exists before creating the directory
skill_subdir = skills_dir / skill_name
skill_file = skill_subdir / "SKILL.md"
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
if skill_file.exists():
# Do not overwrite user-customized skills
continue
# Create skill directory; track whether we created it so we can clean
# up safely if reading the source file subsequently fails.
@@ -973,35 +957,11 @@ class ExtensionManager:
skill_content
)
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")
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],
@@ -1172,7 +1132,6 @@ class ExtensionManager:
speckit_version: str,
register_commands: bool = True,
priority: int = 10,
link_commands: bool = False,
) -> ExtensionManifest:
"""Install extension from a local directory.
@@ -1181,8 +1140,6 @@ 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
@@ -1226,14 +1183,12 @@ class ExtensionManager:
registrar = CommandRegistrar()
# Register for all detected agents
registered_commands = registrar.register_commands_for_all_agents(
manifest, dest_dir, self.project_root, link_outputs=link_commands
manifest, dest_dir, self.project_root
)
# 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, link_outputs=link_commands
)
registered_skills = self._register_extension_skills(manifest, dest_dir)
# Register hooks and update installed list in extensions.yml
hook_executor = HookExecutor(self.project_root)
@@ -1669,8 +1624,7 @@ class CommandRegistrar:
agent_name: str,
manifest: ExtensionManifest,
extension_dir: Path,
project_root: Path,
link_outputs: bool = False,
project_root: Path
) -> List[str]:
"""Register extension commands for a specific agent."""
if agent_name not in self.AGENT_CONFIGS:
@@ -1678,23 +1632,20 @@ 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,
link_outputs=link_outputs,
context_note=context_note
)
def register_commands_for_all_agents(
self,
manifest: ExtensionManifest,
extension_dir: Path,
project_root: Path,
link_outputs: bool = False,
project_root: Path
) -> 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,
link_outputs=link_outputs,
context_note=context_note
)
def unregister_commands(
@@ -1709,25 +1660,18 @@ class CommandRegistrar:
self,
manifest: ExtensionManifest,
extension_dir: Path,
project_root: Path,
link_outputs: bool = False,
project_root: Path
) -> List[str]:
"""Register extension commands for Claude Code agent."""
return self.register_commands_for_agent(
"claude", manifest, extension_dir, project_root, link_outputs=link_outputs
)
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)
class ExtensionCatalog(CatalogStackBase):
class ExtensionCatalog:
"""Manages extension catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json"
COMMUNITY_CATALOG_URL = "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json"
CACHE_DURATION = 3600 # 1 hour in seconds
CONFIG_FILENAME = "extension-catalogs.yml"
ENTRY_CLASS = CatalogEntry
ERROR_TYPE = ValidationError
VALIDATION_ERROR_TYPE = ValidationError
def __init__(self, project_root: Path):
"""Initialize extension catalog manager.
@@ -1741,6 +1685,27 @@ class ExtensionCatalog(CatalogStackBase):
self.cache_file = self.cache_dir / "catalog.json"
self.cache_metadata_file = self.cache_dir / "catalog-metadata.json"
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed).
Args:
url: URL to validate
Raises:
ValidationError: If URL is invalid or uses non-HTTPS scheme
"""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise ValidationError("Catalog URL must be a valid URL with a host.")
def _make_request(self, url: str):
"""Build a urllib Request, adding auth headers when a provider matches.
@@ -1757,6 +1722,81 @@ class ExtensionCatalog(CatalogStackBase):
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]:
"""Load catalog stack configuration from a YAML file.
Args:
config_path: Path to extension-catalogs.yml
Returns:
Ordered list of CatalogEntry objects, or None if file doesn't exist.
Raises:
ValidationError: If any catalog entry has an invalid URL,
the file cannot be parsed, a priority value is invalid,
or the file exists but contains no valid catalog entries
(fail-closed for security).
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e:
raise ValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
# File exists but has no catalogs key or empty list - fail closed
raise ValidationError(
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
f"Remove the file to use built-in defaults, or add valid catalog entries."
)
if not isinstance(catalogs_data, list):
raise ValidationError(
f"Invalid catalog config: 'catalogs' must be a list, got {type(catalogs_data).__name__}"
)
entries: List[CatalogEntry] = []
skipped_entries: List[int] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise ValidationError(
f"Invalid catalog entry at index {idx}: expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
skipped_entries.append(idx)
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise ValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
entries.append(CatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
))
entries.sort(key=lambda e: e.priority)
if not entries:
# All entries were invalid (missing URLs) - fail closed for security
raise ValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} entries but none have valid URLs "
f"(entries at indices {skipped_entries} were skipped). "
f"Each catalog entry must have a 'url' field."
)
return entries
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -1786,44 +1826,24 @@ class ExtensionCatalog(CatalogStackBase):
file=sys.stderr,
)
self._non_default_catalog_warning_shown = True
return [
self._entry(
url=catalog_url,
name="custom",
priority=1,
install_allowed=True,
description="Custom catalog via SPECKIT_CATALOG_URL",
)
]
return [CatalogEntry(url=catalog_url, name="custom", priority=1, install_allowed=True, description="Custom catalog via SPECKIT_CATALOG_URL")]
# 2. Project-level config overrides all defaults
project_config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
project_config_path = self.project_root / ".specify" / "extension-catalogs.yml"
catalogs = self._load_catalog_config(project_config_path)
if catalogs is not None:
return catalogs
# 3. User-level config
user_config_path = Path.home() / ".specify" / self.CONFIG_FILENAME
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
catalogs = self._load_catalog_config(user_config_path)
if catalogs is not None:
return catalogs
# 4. Built-in default stack
return [
self._entry(
url=self.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
description="Built-in catalog of installable extensions",
),
self._entry(
url=self.COMMUNITY_CATALOG_URL,
name="community",
priority=2,
install_allowed=False,
description="Community-contributed extensions (discovery only)",
),
CatalogEntry(url=self.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True, description="Built-in catalog of installable extensions"),
CatalogEntry(url=self.COMMUNITY_CATALOG_URL, name="community", priority=2, install_allowed=False, description="Community-contributed extensions (discovery only)"),
]
def get_catalog_url(self) -> str:
@@ -2434,7 +2454,6 @@ class HookExecutor:
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
cline_mode = selected_ai == "cline"
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
@@ -2445,10 +2464,6 @@ class HookExecutor:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
if cline_mode:
from .integrations.cline import format_cline_command_name
return f"/{format_cline_command_name(command_id)}"
return f"/{command_id}"

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -12,67 +11,6 @@ INTEGRATION_JSON = ".specify/integration.json"
INTEGRATION_STATE_SCHEMA = 1
@dataclass(frozen=True)
class IntegrationReadError:
"""Structured failure from :func:`try_read_integration_json`.
Callers map ``kind`` to whatever surface they need (loud CLI error,
silent fallback, etc.) without re-implementing the parse/validation logic.
"""
kind: str # "decode", "os", "not_object", "schema_too_new"
detail: str = ""
schema: int | None = None
def try_read_integration_json(
project_root: Path,
) -> tuple[dict[str, Any] | None, IntegrationReadError | None]:
"""Parse ``.specify/integration.json`` without raising.
Returns ``(normalized_state, None)`` on success, ``(None, None)`` when the
file does not exist, or ``(None, error)`` for any parse / validation
failure. This is the single low-level reader; both the CLI's loud
``_read_integration_json`` and the workflow engine's silent
``_load_project_integration`` consume it so the schema guard and parse
logic cannot drift between them.
"""
path = project_root / INTEGRATION_JSON
# Avoid Path.exists() / Path.is_file() as a pre-check: both return False
# on some OSErrors (e.g. permission errors during stat), which would
# silently treat an unreadable-but-present file as missing. Attempt the
# read directly and distinguish FileNotFoundError (genuinely absent) from
# other OSErrors (which become loud errors via the IntegrationReadError
# path).
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None, None
except IsADirectoryError as exc:
return None, IntegrationReadError(
kind="os",
detail=f"{path} exists but is not a regular file: {exc}",
)
except UnicodeDecodeError as exc:
return None, IntegrationReadError(kind="decode", detail=str(exc))
except OSError as exc:
return None, IntegrationReadError(kind="os", detail=str(exc))
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
return None, IntegrationReadError(kind="decode", detail=str(exc))
if not isinstance(data, dict):
return None, IntegrationReadError(kind="not_object", detail=type(data).__name__)
schema = data.get("integration_state_schema")
if (
isinstance(schema, int)
and not isinstance(schema, bool)
and schema > INTEGRATION_STATE_SCHEMA
):
return None, IntegrationReadError(kind="schema_too_new", schema=schema)
return normalize_integration_state(data), None
def clean_integration_key(key: Any) -> str | None:
"""Return a stripped integration key, or None for empty/non-string values."""
if not isinstance(key, str) or not key.strip():

View File

@@ -52,7 +52,6 @@ def _register_builtins() -> None:
from .auggie import AuggieIntegration
from .bob import BobIntegration
from .claude import ClaudeIntegration
from .cline import ClineIntegration
from .codebuddy import CodebuddyIntegration
from .codex import CodexIntegration
from .copilot import CopilotIntegration
@@ -62,7 +61,6 @@ 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
@@ -86,7 +84,6 @@ def _register_builtins() -> None:
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(ClineIntegration())
_register(CodebuddyIntegration())
_register(CodexIntegration())
_register(CopilotIntegration())
@@ -96,7 +93,6 @@ def _register_builtins() -> None:
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(HermesIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())

View File

@@ -5,7 +5,6 @@ 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
@@ -14,15 +13,6 @@ 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):
@@ -33,8 +23,8 @@ class AgyIntegration(SkillsIntegration):
"name": "Antigravity",
"folder": ".agents/",
"commands_subdir": "skills",
"install_url": "https://antigravity.google/",
"requires_cli": True,
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".agents/skills",
@@ -44,54 +34,6 @@ 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,
@@ -107,21 +49,4 @@ class AgyIntegration(SkillsIntegration):
fg="yellow",
err=True,
)
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
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)

View File

@@ -13,10 +13,7 @@ Provides:
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
from abc import ABC
from dataclasses import dataclass
@@ -28,12 +25,6 @@ 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
@@ -147,65 +138,6 @@ 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.
@@ -550,91 +482,6 @@ 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,
@@ -643,54 +490,34 @@ 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 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.
managed section. If it exists, the content between
``<!-- SPECKIT START -->`` and ``<!-- SPECKIT END -->`` markers
is replaced (or appended when no markers are found).
Returns the path to the context file, or ``None`` when
``context_file`` is not set or the ``agent-context`` extension is
disabled.
``context_file`` is not set.
"""
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"{marker_start}\n"
f"{self.CONTEXT_MARKER_START}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
f"{self.CONTEXT_MARKER_END}\n"
)
if ctx_path.exists():
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
start_idx = content.find(self.CONTEXT_MARKER_START)
end_idx = content.find(
marker_end,
self.CONTEXT_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(marker_end)
end_of_marker = end_idx + len(self.CONTEXT_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
@@ -702,7 +529,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(marker_end)
end_of_marker = end_idx + len(self.CONTEXT_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":
@@ -736,27 +563,20 @@ 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.
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.
file becomes empty (or whitespace-only) after removal it is
deleted.
"""
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(marker_start)
start_idx = content.find(self.CONTEXT_MARKER_START)
end_idx = content.find(
marker_end,
self.CONTEXT_MARKER_END,
start_idx if start_idx != -1 else 0,
)
@@ -767,7 +587,7 @@ class IntegrationBase(ABC):
return False
removal_start = start_idx
removal_end = end_idx + len(marker_end)
removal_end = end_idx + len(self.CONTEXT_MARKER_END)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
@@ -1030,8 +850,7 @@ class MarkdownIntegration(IntegrationBase):
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "-p", prompt]
self._apply_extra_args_env_var(args)
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
@@ -1118,8 +937,7 @@ class TomlIntegration(IntegrationBase):
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "-p", prompt]
self._apply_extra_args_env_var(args)
args = [self.key, "-p", prompt]
if model:
args.extend(["-m", model])
if output_json:
@@ -1537,8 +1355,7 @@ class SkillsIntegration(IntegrationBase):
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "-p", prompt]
self._apply_extra_args_env_var(args)
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
@@ -1574,53 +1391,15 @@ 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 base implementation injects shared skills
guidance for converting dotted hook command names to hyphenated
slash commands. Subclasses may override — see ``ClaudeIntegration``.
transformations. The default implementation returns *content*
unchanged. Subclasses may override — see ``ClaudeIntegration``.
"""
return self._inject_hook_command_note(content)
return content
def setup(
self,
@@ -1723,8 +1502,6 @@ 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"

View File

@@ -5,11 +5,21 @@ 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] = {
@@ -149,11 +159,41 @@ 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 = super().post_process_skill_content(content)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(content, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
updated = self._inject_hook_command_note(updated)
return updated
def setup(
@@ -163,9 +203,10 @@ class ClaudeIntegration(SkillsIntegration):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Claude skills, then inject argument-hints."""
"""Install Claude skills, then inject Claude-specific flags and 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:
@@ -180,7 +221,7 @@ class ClaudeIntegration(SkillsIntegration):
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = content
updated = self.post_process_skill_content(content)
# Inject argument-hint if available for this skill
skill_dir_name = path.parent.name # e.g. "speckit-plan"

View File

@@ -1,162 +0,0 @@
"""Cline IDE integration."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..base import MarkdownIntegration
from ..manifest import IntegrationManifest
# Note injected into hook sections so Cline maps dot-notation command
# names (from extensions.yml) to the hyphenated slash commands 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"
)
def format_cline_command_name(cmd_name: str) -> str:
"""Convert command name to Cline-compatible hyphenated format.
Cline handles slash-commands optimally when they use hyphens instead of dots.
This function converts dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_cline_command_name("plan")
'speckit-plan'
>>> format_cline_command_name("speckit.plan")
'speckit-plan'
>>> format_cline_command_name("speckit.git.commit")
'speckit-git-commit'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
cmd_name = cmd_name.replace(".", "-")
if not cmd_name.startswith("speckit-"):
cmd_name = f"speckit-{cmd_name}"
return cmd_name
class ClineIntegration(MarkdownIntegration):
"""Integration for Cline IDE."""
key = "cline"
config = {
"name": "Cline",
"folder": ".clinerules/",
"commands_subdir": "workflows",
"install_url": "https://github.com/cline/cline",
"requires_cli": False,
}
registrar_config = {
"dir": ".clinerules/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"inject_name": True,
"format_name": format_cline_command_name,
"invoke_separator": "-",
}
context_file = ".clinerules/specify-rules.md"
invoke_separator = "-"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
return format_cline_command_name(template_name) + ".md"
def process_template(self, *args, **kwargs):
"""Ensure shared templates render Cline command references with hyphens."""
kwargs.setdefault("invoke_separator", self.invoke_separator)
return super().process_template(*args, **kwargs)
@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,
)
@staticmethod
def _rewrite_handoff_references(content: str) -> str:
"""Replace dot-notation agent references in handoffs with hyphens."""
return re.sub(
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
lambda m: f"{m.group(1)}{format_cline_command_name(m.group(2))}",
content,
)
def post_process_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content."""
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Cline commands and apply post-processing transformations."""
created = super().setup(project_root, manifest, parsed_options, **opts)
# Post-process generated command files
dest_dir = self.commands_dest(project_root).resolve()
for path in created:
# Only touch .md files under the commands directory
try:
path.resolve().relative_to(dest_dir)
except ValueError:
continue
if path.suffix != ".md":
continue
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = self.post_process_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
return created

View File

@@ -37,10 +37,7 @@ class CodexIntegration(SkillsIntegration):
output_json: bool = True,
) -> list[str] | None:
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
# 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)
args: list[str] = ["codex", "exec", prompt]
if model:
args.extend(["--model", model])
if output_json:

View File

@@ -24,16 +24,6 @@ from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
def _copilot_executable() -> str:
"""Return the executable name for Copilot CLI on this platform.
On Windows, subprocess invocation is reliable with `copilot.cmd`.
"""
if os.name == "nt":
return "copilot.cmd"
return "copilot"
def _allow_all() -> bool:
"""Return True if the Copilot CLI should run with full permissions.
@@ -134,18 +124,6 @@ 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,
@@ -160,8 +138,7 @@ class CopilotIntegration(IntegrationBase):
# Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var
# (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS
# is also honoured as a fallback.
args = [self._resolve_executable(), "-p", prompt]
self._apply_extra_args_env_var(args)
args = ["copilot", "-p", prompt]
if _allow_all():
args.append("--yolo")
if model:
@@ -229,12 +206,7 @@ class CopilotIntegration(IntegrationBase):
agent_name = f"speckit.{stem}"
prompt = args or ""
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)
cli_args = ["copilot", "-p", prompt]
if not skills_mode:
cli_args.extend(["--agent", agent_name])
if _allow_all():
@@ -283,13 +255,12 @@ class CopilotIntegration(IntegrationBase):
return f"speckit.{template_name}.agent.md"
def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance and Copilot ``mode:`` frontmatter.
"""Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter.
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
Copilot can associate the skill with its agent mode.
"""
updated = _CopilotSkillsHelper().post_process_skill_content(content)
lines = updated.splitlines(keepends=True)
lines = content.splitlines(keepends=True)
# Extract skill name from frontmatter to derive the mode value
dash_count = 0
@@ -303,7 +274,7 @@ class CopilotIntegration(IntegrationBase):
continue
if dash_count == 1:
if stripped.startswith("mode:"):
return updated # already present
return content # already present
if stripped.startswith("name:"):
# Parse: name: "speckit-plan" → speckit.plan
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
@@ -314,7 +285,7 @@ class CopilotIntegration(IntegrationBase):
skill_name = val
if not skill_name:
return updated
return content
# Inject mode: before the closing --- of frontmatter
out: list[str] = []

View File

@@ -48,8 +48,7 @@ class DevinIntegration(SkillsIntegration):
stdout instead of structured JSON. ``requires_cli=True`` is
kept on the integration for tool detection.
"""
args = [self._resolve_executable(), "-p", prompt]
self._apply_extra_args_env_var(args)
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
return args

View File

@@ -1,280 +0,0 @@
"""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

View File

@@ -28,12 +28,7 @@ class OpencodeIntegration(MarkdownIntegration):
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
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)
args = [self.key, "run"]
message = prompt
if prompt.startswith("/"):

View File

@@ -81,13 +81,13 @@ class VibeIntegration(SkillsIntegration):
out.append(line)
return "".join(out)
def post_process_skill_content(self, content: str) -> str:
"""
Inject shared hook guidance and Vibe-specific frontmatter flags:
Inject Vibe-specific frontmatter flags:
- user-invocable: allows the skill to be invoked by the user (not just other agents)
"""
updated = super().post_process_skill_content(content)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(content, "user-invocable")
return updated
def setup(
@@ -107,4 +107,27 @@ class VibeIntegration(SkillsIntegration):
err=True,
)
return super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
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

View File

@@ -28,7 +28,6 @@ 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(
@@ -1059,9 +1058,6 @@ 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,
@@ -1101,24 +1097,37 @@ class PresetManager:
def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for preset skill overrides.
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.
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.
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.
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.
"""
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.",
)
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:
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."""
@@ -1138,23 +1147,6 @@ 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
@@ -1331,7 +1323,6 @@ class PresetManager:
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
@@ -1424,9 +1415,6 @@ 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 = original_desc or SKILL_DESCRIPTIONS.get(
@@ -1464,9 +1452,6 @@ 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)
@@ -1918,24 +1903,12 @@ class PresetCatalog:
if not url:
continue
self._validate_catalog_url(url)
raw_priority = item.get("priority", idx + 1)
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
# ``int(True)`` silently returns 1, which would let a YAML
# ``priority: true`` slip through as a valid priority of 1. The
# sibling integration-catalog reader in ``catalogs.py`` already
# guards this; mirror the check here so the three catalog
# validators stay consistent.
if isinstance(raw_priority, bool):
raise PresetValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(raw_priority)
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise PresetValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):

View File

@@ -88,13 +88,7 @@ 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,
context: str = "shared infrastructure directory",
) -> None:
def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None:
"""Create a shared infra directory without following symlinked parents."""
root = project_path.resolve()
rel = _shared_relative_path(project_path, directory)
@@ -104,24 +98,24 @@ def _ensure_safe_shared_directory(
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if current.exists():
if not current.is_dir():
raise ValueError(f"{context.capitalize()} path is not a directory: {label}")
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
continue
if not create:
raise ValueError(f"{context.capitalize()} does not exist: {label}")
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
current.mkdir()
if current.is_symlink():
raise SymlinkedSharedPathError(f"Refusing to use symlinked {context}: {label}")
raise SymlinkedSharedPathError(f"Refusing to use symlinked shared infrastructure directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"{context.capitalize()} escapes project root: {label}") from None
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None:
@@ -369,16 +363,7 @@ def install_shared_infra(
if not _ensure_or_bucket_dir(dst_path.parent):
continue
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,
)
)
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():

View File

@@ -11,7 +11,6 @@ The engine is the orchestrator that:
from __future__ import annotations
import json
import os
import re
import uuid
from datetime import datetime, timezone
@@ -20,10 +19,6 @@ from typing import Any
import yaml
from ..integration_state import (
default_integration_key,
try_read_integration_json,
)
from .base import RunStatus, StepContext, StepResult, StepStatus
@@ -148,35 +143,6 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Must be 'string', 'number', or 'boolean'."
)
# Validate the default eagerly so authoring mistakes (e.g. a
# default not in the declared enum, or a non-numeric default for
# a number input) surface at install/validation time instead of
# at workflow-execution time. ``"auto"`` for the integration
# input is a runtime-resolved sentinel, so only the
# enum-membership check is exempted for that exact case — the
# declared type is still enforced (e.g. ``type: number`` paired
# with ``default: "auto"`` is still rejected).
if "default" in input_def:
default_value = input_def["default"]
is_auto_integration = (
input_name == "integration" and default_value == "auto"
)
validation_input_def: dict[str, Any] = input_def
if is_auto_integration and "enum" in input_def:
validation_input_def = {
key: value
for key, value in input_def.items()
if key != "enum"
}
try:
WorkflowEngine._coerce_input(
input_name, default_value, validation_input_def
)
except ValueError as exc:
errors.append(
f"Input {input_name!r} has invalid default: {exc}"
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")
@@ -426,7 +392,7 @@ class WorkflowEngine:
inputs:
User-provided input values.
run_id:
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
Optional run ID (auto-generated if not provided).
Returns
-------
@@ -434,14 +400,8 @@ 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=effective_run_id,
run_id=run_id,
workflow_id=definition.id,
project_root=self.project_root,
)
@@ -680,29 +640,22 @@ class WorkflowEngine:
if not evaluate_condition(condition, context):
break
# Namespace nested step IDs per iteration
# so logs and state keys are unique.
# Execute one step at a time and alias each
# result back to the unprefixed key so that
# later steps in the same body and the loop
# condition see the latest values.
for ns_idx, ns in enumerate(result.next_steps):
iter_steps = []
for ns in result.next_steps:
ns_copy = dict(ns)
orig = ns_copy.get("id")
base_id = orig or f"step-{ns_idx}"
ns_copy["id"] = f"{step_id}:{base_id}:{_loop_iter + 1}"
self._execute_steps(
[ns_copy], context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
if orig and ns_copy["id"] in context.steps:
context.steps[orig] = context.steps[ns_copy["id"]]
state.step_results[orig] = context.steps[ns_copy["id"]]
if "id" in ns_copy:
ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}"
iter_steps.append(ns_copy)
self._execute_steps(
iter_steps, context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
# Fan-out: execute nested step template per item with unique IDs
if step_type == "fan-out":
@@ -758,73 +711,16 @@ class WorkflowEngine:
if not isinstance(input_def, dict):
continue
if name in provided:
# Resolve sentinels for explicitly-provided values too: a
# caller passing ``{"integration": "auto"}`` (which the
# workflow prompt advertises as a valid value) must be
# treated identically to omitting the input and letting the
# default flow through, so dispatch never sees the literal
# sentinel.
value = self._resolve_default(name, provided[name])
resolved[name] = self._coerce_input(
name, provided[name], input_def
)
elif "default" in input_def:
value = self._resolve_default(name, input_def["default"])
resolved[name] = input_def["default"]
elif input_def.get("required", False):
msg = f"Required input {name!r} not provided."
raise ValueError(msg)
else:
continue
# When the ``integration`` default could not be resolved against
# project state and falls back to the literal ``"auto"``
# sentinel, strip ``enum`` from the input definition before
# coercion so a workflow that lists specific integrations in
# ``enum`` does not crash at runtime on the sentinel value.
# NOTE: only enum-membership is skipped; ``_coerce_input``
# still enforces the declared ``type`` against the filtered
# definition (``string`` rejects non-strings, ``number`` rejects
# bools and uncoercible values, ``boolean`` rejects non-bools),
# so ill-typed values still fail fast here.
coerce_input_def = input_def
if (
name == "integration"
and value == "auto"
and "enum" in input_def
):
coerce_input_def = {
key: val
for key, val in input_def.items()
if key != "enum"
}
resolved[name] = self._coerce_input(name, value, coerce_input_def)
return resolved
def _resolve_default(self, name: str, default: Any) -> Any:
"""Resolve special default sentinels against project state.
For the ``integration`` input, ``"auto"`` resolves to the integration
recorded in ``.specify/integration.json`` so workflows dispatch to the
AI the project was actually initialized with, instead of a hardcoded
value baked into the workflow YAML.
"""
if name == "integration" and default == "auto":
resolved = self._load_project_integration()
if resolved is not None:
return resolved
return default
def _load_project_integration(self) -> str | None:
"""Read the default integration key from ``.specify/integration.json``.
Delegates parsing and schema validation to
:func:`try_read_integration_json` — the same low-level helper used by
the CLI — so the engine cannot drift from CLI behavior on the parse
path. Returns ``None`` when the file is missing, malformed, or
written by a newer CLI; callers fall back to the literal default.
"""
state, error = try_read_integration_json(self.project_root)
if state is None or error is not None:
return None
return default_integration_key(state)
@staticmethod
def _coerce_input(
name: str, value: Any, input_def: dict[str, Any]
@@ -834,13 +730,6 @@ class WorkflowEngine:
enum_values = input_def.get("enum")
if input_type == "number":
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
# ``float(True)`` succeeds and would silently coerce a YAML
# authoring mistake like ``type: number`` + ``default: true``
# into ``1``. Fail fast instead.
if isinstance(value, bool):
msg = f"Input {name!r} expected a number, got {value!r}."
raise ValueError(msg)
try:
value = float(value)
if value == int(value):
@@ -857,17 +746,6 @@ class WorkflowEngine:
else:
msg = f"Input {name!r} expected a boolean, got {value!r}."
raise ValueError(msg)
elif not isinstance(value, bool):
msg = f"Input {name!r} expected a boolean, got {value!r}."
raise ValueError(msg)
elif input_type == "string":
# Without this, ``type: string`` accepts any Python value
# (numbers, lists, dicts) because nothing else rejects it —
# YAML ``default: 5`` would slip through. Require an actual
# string so authoring mistakes fail at resolve time.
if not isinstance(value, str):
msg = f"Input {name!r} expected a string, got {value!r}."
raise ValueError(msg)
if enum_values is not None and value not in enum_values:
msg = (

View File

@@ -102,15 +102,6 @@ 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

View File

@@ -74,9 +74,7 @@ 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. **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:
2. **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`
@@ -108,13 +106,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 followups (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.
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
3. **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)
5. **Load feature context**: Read from FEATURE_DIR:
4. **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
@@ -125,7 +123,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
6. **Generate checklist** - Create "Unit Tests for Requirements":
5. **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`)
@@ -243,9 +241,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]?"
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.
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.
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:
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:
- Focus areas selected
- Depth level
- Actor/timing

View File

@@ -66,9 +66,7 @@ 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. **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).
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).
Functional Scope & Behavior:
- Core user goals & success criteria
@@ -124,7 +122,7 @@ Execution steps:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
3. 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 multiplechoice selection (25 distinct, mutually exclusive options), OR
@@ -135,7 +133,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.
5. Sequential questioning loop (interactive):
4. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- For multiplechoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
@@ -171,7 +169,7 @@ Execution steps:
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
6. Integration after EACH accepted answer (incremental update approach):
5. 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).
@@ -189,7 +187,7 @@ Execution steps:
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
7. Validation (performed after EACH write plus final pass):
6. 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.
@@ -197,26 +195,15 @@ Execution steps:
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
8. Write the updated spec back to `FEATURE_SPEC`.
7. Write the updated spec back to `FEATURE_SPEC`.
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").
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.
Behavior rules:
@@ -230,27 +217,17 @@ Behavior rules:
Context for prioritization: {ARGS}
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
## Post-Execution Checks
**Check for extension hooks (after clarification)**:
Check if `.specify/extensions.yml` exists in the project root.
- 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.
- 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
- 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
@@ -262,21 +239,12 @@ Check if `.specify/extensions.yml` exists in the project root.
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
## 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
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -168,49 +168,35 @@ 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.
## Mandatory Post-Execution Hooks
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
**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_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
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
**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
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -70,42 +70,36 @@ 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
## Mandatory Post-Execution Hooks
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
**You MUST complete this section before reporting completion to the user.**
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
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
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**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.
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Phases
@@ -156,9 +150,3 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
- 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

View File

@@ -91,8 +91,7 @@ Given that feature description, do this:
**Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
- Persist the resolved path to `.specify/feature.json`:
```json
@@ -108,11 +107,9 @@ Given that feature description, do this:
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook
4. Load the resolved active `spec-template` file to understand required sections.
4. Load `templates/spec-template.md` to understand required sections.
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
6. Follow this execution flow:
5. Follow this execution flow:
1. Parse user description from arguments
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
@@ -186,7 +183,7 @@ Given that feature description, do this:
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
- **If all items pass**: Mark checklist complete and proceed to step 8
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
@@ -231,46 +228,40 @@ Given that feature description, do this:
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
## Mandatory Post-Execution Hooks
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__`)
**You MUST complete this section before reporting completion to the user.**
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
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
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**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__`)
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
@@ -334,9 +325,3 @@ 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

View File

@@ -63,7 +63,6 @@ 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**:
@@ -90,48 +89,42 @@ You **MUST** consider the user input before proceeding (if not empty).
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
## Mandatory Post-Execution Hooks
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)
**You MUST complete this section before reporting completion to the user.**
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
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
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- **Optional hook** (`optional: true`):
```
## Extension Hooks
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**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)
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
Context for task generation: {ARGS}
@@ -208,9 +201,3 @@ 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

View File

@@ -51,7 +51,6 @@ 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:

View File

@@ -1,455 +0,0 @@
"""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

View File

@@ -22,26 +22,6 @@ def _normalize_cli_output(output: str) -> str:
return output.strip()
class TestCliDiagnosticFormatting:
def test_cli_error_detail_flattens_newlines(self):
import specify_cli
assert specify_cli._cli_error_detail(RuntimeError("line one\nline two")) == "line one line two"
def test_cli_error_detail_handles_empty_message(self):
import specify_cli
assert specify_cli._cli_error_detail(RuntimeError()) == "RuntimeError"
def test_cli_phase_label_includes_target(self):
import specify_cli
assert (
specify_cli._cli_phase_label("rollback", "integration", "codex")
== "rollback integration 'codex'"
)
class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
from typer.testing import CliRunner
@@ -87,14 +67,7 @@ class TestInitIntegrationFlag:
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "copilot"
# 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 opts["context_file"] == ".github/copilot-instructions.md"
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
@@ -201,42 +174,6 @@ class TestInitIntegrationFlag:
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
def test_init_optional_preset_failure_reports_target_and_continues(
self, tmp_path, monkeypatch
):
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.presets import PresetManager
def fail_install(self, path, version):
raise OSError("preset install exploded\nwith context")
monkeypatch.setattr(PresetManager, "install_from_directory", fail_install)
project = tmp_path / "init-preset-warning"
result = CliRunner().invoke(
app,
[
"init",
str(project),
"--integration",
"copilot",
"--script",
"sh",
"--no-git",
"--preset",
"lean",
],
catch_exceptions=False,
)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Failed to install preset 'lean'" in normalized
assert "preset install exploded with context" in normalized
assert "Continuing without the optional preset" in normalized
assert "Project ready" in normalized
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
@@ -330,7 +267,6 @@ class TestInitIntegrationFlag:
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
from tests.conftest import strip_ansi
project = tmp_path / "warn-test"
project.mkdir()
@@ -343,11 +279,10 @@ class TestInitIntegrationFlag:
_install_shared_infra(project, "sh", force=False)
captured = capsys.readouterr()
plain = strip_ansi(captured.out)
assert "already exist and were not updated" in plain
assert "specify init --here --force" in plain
assert "already exist and were not updated" in captured.out
assert "specify init --here --force" in captured.out
# Rich may wrap long lines; normalize whitespace for the second command
normalized = " ".join(plain.split())
normalized = " ".join(captured.out.split())
assert "specify integration upgrade --force" in normalized
def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys):
@@ -931,23 +866,7 @@ class TestGitExtensionAutoInstall:
class TestSharedInfraCommandRefs:
"""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
)
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
def test_dot_separator_in_page_templates(self, tmp_path):
"""Markdown agents get /speckit.<name> in page templates."""
@@ -992,46 +911,6 @@ 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
@@ -1059,10 +938,6 @@ 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
@@ -1090,10 +965,6 @@ 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
@@ -1123,10 +994,6 @@ 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 …`.
@@ -1188,143 +1055,6 @@ class TestIntegrationCatalogDiscoveryCLI:
finally:
os.chdir(old)
def test_integration_install_failure_reports_phase_target_and_rollback(
self, tmp_path, monkeypatch
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.base import IntegrationBase
class BrokenIntegration(IntegrationBase):
key = "broken-test"
config = {
"name": "Broken Test",
"folder": ".broken/",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".broken/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "BROKEN.md"
def setup(self, project_root, manifest, **kwargs):
raise OSError("setup exploded\nwith context")
def teardown(self, project_root, manifest, force=False):
raise OSError("rollback exploded")
project = self._make_project(tmp_path)
monkeypatch.setitem(INTEGRATION_REGISTRY, "broken-test", BrokenIntegration())
result = self._invoke(["integration", "install", "broken-test"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "Failed to rollback integration 'broken-test'" in normalized
assert "rollback exploded" in normalized
assert "Failed to install integration 'broken-test'" in normalized
assert "setup exploded with context" in normalized
def test_integration_upgrade_failure_reports_phase_and_target(
self, tmp_path, monkeypatch
):
from specify_cli.integrations import INTEGRATION_REGISTRY
from specify_cli.integrations.copilot import CopilotIntegration
class UpgradeBrokenIntegration(CopilotIntegration):
key = "upgrade-broken"
config = dict(CopilotIntegration.config)
config["name"] = "Upgrade Broken"
def setup(self, project_root, manifest, **kwargs):
raise OSError("upgrade exploded\nwith context")
project = self._make_project(tmp_path)
monkeypatch.setitem(
INTEGRATION_REGISTRY, "upgrade-broken", UpgradeBrokenIntegration()
)
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
(project / ".specify" / "integration.json").write_text(
json.dumps(
{
"version": 1,
"integration": "upgrade-broken",
"integrations": ["upgrade-broken"],
"integration_settings": {"upgrade-broken": {"script": "sh"}},
}
),
encoding="utf-8",
)
(
project / ".specify" / "integrations" / "upgrade-broken.manifest.json"
).write_text(
json.dumps(
{
"integration": "upgrade-broken",
"version": "0.0.0",
"installed_at": "2026-05-16T00:00:00+00:00",
"files": {},
}
),
encoding="utf-8",
)
result = self._invoke(["integration", "upgrade", "upgrade-broken"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 1, result.output
assert "Failed to upgrade integration 'upgrade-broken'" in normalized
assert "upgrade exploded with context" in normalized
assert "previous integration files may still be in place" in normalized
def test_integration_switch_cleanup_warning_reports_phase_and_targets(
self, tmp_path, monkeypatch
):
from specify_cli.extensions import ExtensionManager
project = self._make_project(tmp_path)
(project / ".specify" / "integrations").mkdir(parents=True, exist_ok=True)
(project / ".specify" / "integration.json").write_text(
json.dumps(
{
"version": 1,
"integration": "copilot",
"integrations": ["copilot"],
"integration_settings": {"copilot": {"script": "sh"}},
}
),
encoding="utf-8",
)
(project / ".specify" / "integrations" / "copilot.manifest.json").write_text(
json.dumps(
{
"integration": "copilot",
"version": "0.0.0",
"installed_at": "2026-05-16T00:00:00+00:00",
"files": {},
}
),
encoding="utf-8",
)
def fail_cleanup(self, integration_key):
raise OSError("cleanup exploded")
monkeypatch.setattr(ExtensionManager, "unregister_agent_artifacts", fail_cleanup)
result = self._invoke(["integration", "switch", "claude"], project)
normalized = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Failed to clean up extension artifacts for integration 'copilot'" in normalized
assert "cleanup exploded" in normalized
assert "Switched to integration" in normalized
# -- Project guard -----------------------------------------------------
def test_search_requires_specify_project(self, tmp_path):
@@ -1489,30 +1219,6 @@ class TestIntegrationCatalogDiscoveryCLI:
assert "contains invalid JSON" in normalized_output
assert "integration.json" in normalized_output
def test_search_rejects_non_utf8_integration_json_before_catalog_lookup(
self, tmp_path, monkeypatch
):
"""A non-UTF8 ``integration.json`` must surface a clear error and
avoid falling through to the catalog lookup, mirroring the malformed-JSON
case but for the ``UnicodeDecodeError`` branch in ``_read_integration_json``."""
project = self._make_project(tmp_path)
# 0xFF is invalid as the leading byte of any UTF-8 sequence, so
# ``Path.read_text(encoding="utf-8")`` raises ``UnicodeDecodeError``.
(project / ".specify" / "integration.json").write_bytes(b"\xff\xfe\x00\x00")
from specify_cli.integrations.catalog import IntegrationCatalog
def fail_search(self, **kwargs):
raise AssertionError("catalog search should not be called")
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
result = self._invoke(["integration", "search"], project)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 1
assert "not valid UTF-8" in normalized_output
assert "integration.json" in normalized_output
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
self._patch_catalog(monkeypatch)

View File

@@ -1,638 +0,0 @@
"""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",
]

View File

@@ -1,7 +1,5 @@
"""Tests for AgyIntegration (Antigravity)."""
from specify_cli.integrations import get_integration
from .test_integration_base_skills import SkillsIntegrationTests
@@ -14,21 +12,10 @@ 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."""
@@ -39,7 +26,7 @@ class TestAgyAutoPromote:
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
@@ -49,87 +36,10 @@ class TestAgyAutoPromote:
from typer.testing import CliRunner
from specify_cli import app
# Click >= 8.2 separates stdout and stderr natively
# Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed
runner = CliRunner()
target = tmp_path / "test-proj2"
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"])
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"

View File

@@ -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):
"""agent-context extension config must include context_file for the active integration."""
import yaml
"""init-options.json must include context_file for the active integration."""
import json
from typer.testing import CliRunner
from specify_cli import app
@@ -243,17 +243,15 @@ class MarkdownIntegrationTests:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
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 {}
opts = json.loads((project / ".specify" / "init-options.json").read_text())
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
assert opts.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
)
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"agent-context.update",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
@@ -293,16 +291,6 @@ 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)

View File

@@ -176,39 +176,6 @@ 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)
@@ -357,8 +324,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):
"""agent-context extension config must include context_file for the active integration."""
import yaml
"""init-options.json must include context_file for the active integration."""
import json
from typer.testing import CliRunner
from specify_cli import app
@@ -374,11 +341,10 @@ class SkillsIntegrationTests:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
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 {}
opts = json.loads((project / ".specify" / "init-options.json").read_text())
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
assert opts.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
)
# -- IntegrationOption ------------------------------------------------
@@ -403,11 +369,9 @@ class SkillsIntegrationTests:
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
files = []
# Skill files (core commands)
# Skill files
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",
@@ -446,15 +410,6 @@ 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)

View 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):
"""agent-context extension config must include context_file for the active integration."""
import yaml
"""init-options.json must include context_file for the active integration."""
import json
from typer.testing import CliRunner
from specify_cli import app
@@ -474,17 +474,15 @@ class TomlIntegrationTests:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
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 {}
opts = json.loads((project / ".specify" / "init-options.json").read_text())
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
assert opts.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
)
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"checklist",
"clarify",
@@ -545,16 +543,6 @@ 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)

View 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):
"""agent-context extension config must include context_file for the active integration."""
import yaml
"""init-options.json must include context_file for the active integration."""
import json
from typer.testing import CliRunner
from specify_cli import app
@@ -353,17 +353,15 @@ class YamlIntegrationTests:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
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 {}
opts = json.loads((project / ".specify" / "init-options.json").read_text())
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
assert opts.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
)
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"checklist",
"clarify",
@@ -424,16 +422,6 @@ 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)

View 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, SkillsIntegration
from specify_cli.integrations.base import IntegrationBase
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.commands.init._stdin_is_interactive", return_value=True),
patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
patch("specify_cli._stdin_is_interactive", return_value=True),
patch("specify_cli.select_with_arrows", return_value="claude"),
):
result = runner.invoke(
app,
@@ -487,15 +487,13 @@ class TestClaudeDisableModelInvocation:
assert "disable-model-invocation" not in fm
assert "user-invocable" not in fm
def test_skills_default_post_process_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
def test_non_claude_post_process_is_identity(self, tmp_path):
"""Non-Claude integrations should not modify skill content."""
codex = get_integration("codex")
if codex is None:
return # codex not registered in this build
content = "---\nname: test\n---\nBody"
assert agy.post_process_skill_content(content) == content
assert codex.post_process_skill_content(content) == content
class TestClaudeHookCommandNote:
@@ -505,7 +503,7 @@ class TestClaudeHookCommandNote:
"""Skills that have hook sections should get the normalization note."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
created = 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")
@@ -516,54 +514,35 @@ 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 = SkillsIntegration._inject_hook_command_note(content)
result = ClaudeIntegration._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 = SkillsIntegration._inject_hook_command_note(content)
twice = SkillsIntegration._inject_hook_command_note(once)
once = ClaudeIntegration._inject_hook_command_note(content)
twice = ClaudeIntegration._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 = SkillsIntegration._inject_hook_command_note(content)
result = ClaudeIntegration._inject_hook_command_note(content)
lines = result.splitlines()
note_line = [line for line in lines if "replace dots" in line][0]
note_line = [l for l in lines if "replace dots" in l][0]
assert note_line.startswith(" "), "Note should preserve indentation"
def test_post_process_injects_all_claude_flags(self):

View File

@@ -1,223 +0,0 @@
"""Tests for ClineIntegration."""
import os
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.cline import format_cline_command_name
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestClineCommandNameFormatter:
"""Test the Cline command name formatter."""
def test_simple_name_without_prefix(self):
"""Test formatting a simple name without 'speckit.' prefix."""
assert format_cline_command_name("plan") == "speckit-plan"
assert format_cline_command_name("tasks") == "speckit-tasks"
assert format_cline_command_name("specify") == "speckit-specify"
def test_name_with_speckit_prefix(self):
"""Test formatting a name that already has 'speckit.' prefix."""
assert format_cline_command_name("speckit.plan") == "speckit-plan"
assert format_cline_command_name("speckit.tasks") == "speckit-tasks"
def test_extension_command_name(self):
"""Test formatting extension command names with dots."""
assert (
format_cline_command_name("speckit.my-extension.example")
== "speckit-my-extension-example"
)
assert (
format_cline_command_name("my-extension.example")
== "speckit-my-extension-example"
)
def test_idempotent_already_hyphenated(self):
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
assert format_cline_command_name("speckit-plan") == "speckit-plan"
assert (
format_cline_command_name("speckit-my-extension-example")
== "speckit-my-extension-example"
)
class TestClineIntegration(MarkdownIntegrationTests):
KEY = "cline"
FOLDER = ".clinerules/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".clinerules/workflows"
CONTEXT_FILE = ".clinerules/specify-rules.md"
@pytest.mark.parametrize(
"cmd_name, expected_filename",
[
("plan", "speckit-plan.md"),
("speckit.plan", "speckit-plan.md"),
("speckit.git.commit", "speckit-git-commit.md"),
("speckit", "speckit-speckit.md"),
("speckitfoo", "speckit-speckitfoo.md"),
],
)
def test_cline_command_filename(self, cmd_name, expected_filename):
"""Verify Cline uses hyphenated filenames."""
cline = get_integration("cline")
assert cline.command_filename(cmd_name) == expected_filename
def test_cline_invoke_separator(self):
"""Verify Cline uses hyphen as invoke separator."""
cline = get_integration("cline")
assert cline.invoke_separator == "-"
assert cline.registrar_config["invoke_separator"] == "-"
def test_cline_name_injection_and_formatting(self):
"""Verify Cline has inject_name and format_name configured."""
cline = get_integration("cline")
assert cline.registrar_config["inject_name"] is True
assert cline.registrar_config["format_name"] == format_cline_command_name
def test_cline_handoff_rewrite(self):
"""Verify Cline rewrites agent: speckit.foo to agent: speckit-foo."""
cline = get_integration("cline")
content = "---\nagent: speckit.plan\n---\n"
rewritten = cline._rewrite_handoff_references(content)
assert rewritten == "---\nagent: speckit-plan\n---\n"
def test_cline_hook_instruction_injection(self):
"""Verify Cline injects the dot-to-hyphen note for hooks."""
cline = get_integration("cline")
content = "- For each executable hook, output the following:\n"
injected = cline._inject_hook_command_note(content)
assert "replace dots (`.`) with hyphens (`-`)" in injected
assert "- For each executable hook, output the following:" in injected
# -- Overrides for MarkdownIntegrationTests ---------------------------
def test_setup_creates_files(self, tmp_path):
from specify_cli.integrations.manifest import IntegrationManifest
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [
f
for f in created
if "scripts" not in f.parts
and f.suffix == ".md"
and f.name != i.context_file
]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit-")
assert f.name.endswith(".md")
specify_file = next(
(f for f in cmd_files if f.name == "speckit-specify.md"), None
)
assert specify_file is not None
specify_contents = specify_file.read_text(encoding="utf-8")
assert "/speckit-plan" in specify_contents
assert "/speckit.plan" not in specify_contents
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.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
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir()
commands = sorted(cmd_dir.glob("speckit-*"))
assert len(commands) > 0
def _expected_files(self, script_variant: str) -> list[str]:
"""Override to expect hyphenated speckit- prefix."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files
for stem in (
self.COMMANDS_SUBDIR_STEMS
if hasattr(self, "COMMANDS_SUBDIR_STEMS")
else self.COMMAND_STEMS
):
files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# Bundled workflow
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)
return sorted(files)

View File

@@ -1,8 +1,5 @@
"""Tests for CodexIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
@@ -28,117 +25,3 @@ class TestCodexAutoPromote:
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
class TestCodexHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections.
Hook commands in ``extensions.yml`` use dotted ids like
``speckit.git.commit`` but Codex skills are named with hyphens
(``speckit-git-commit``). Without this note, Codex emits
``/speckit.git.commit``, which does not resolve.
"""
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
"""Skills that have hook sections should get the normalization note."""
i = get_integration("codex")
m = IntegrationManifest("codex", tmp_path)
i.setup(tmp_path, m, script_type="sh")
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
assert specify_skill.exists()
content = specify_skill.read_text(encoding="utf-8")
assert "replace dots" in content, (
"speckit-specify should have dot-to-hyphen hook note"
)
def test_hook_note_not_in_skills_without_hooks(self):
"""Skills without hook sections should not get the note."""
from specify_cli.integrations.codex import CodexIntegration
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
result = CodexIntegration._inject_hook_command_note(content)
assert "replace dots" not in result
def test_hook_note_idempotent(self):
"""Injecting the note twice should not duplicate it."""
from specify_cli.integrations.codex import CodexIntegration
content = (
"---\nname: test\n---\n\n"
"- For each executable hook, output the following based on its flag:\n"
)
once = CodexIntegration._inject_hook_command_note(content)
twice = CodexIntegration._inject_hook_command_note(once)
assert once == twice, "Hook note injection should be idempotent"
def test_hook_note_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"
)

View File

@@ -147,21 +147,6 @@ class TestCopilotIntegration:
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content
def test_specify_agent_resolves_active_spec_template(self, tmp_path):
"""Generated specify agent must not hardcode the core spec template."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
copilot.setup(tmp_path, m)
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
content = specify_file.read_text(encoding="utf-8")
assert "specify preset resolve spec-template" in content
assert "resolved active `spec-template`" in content
assert "Copy `.specify/templates/spec-template.md`" not in content
assert "Load `.specify/templates/spec-template.md`" not in content
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference copilot's context file."""
from specify_cli.integrations.copilot import CopilotIntegration
@@ -193,7 +178,6 @@ 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",
@@ -203,7 +187,6 @@ 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",
@@ -215,14 +198,6 @@ 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",
@@ -263,7 +238,6 @@ 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",
@@ -273,7 +247,6 @@ 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",
@@ -285,14 +258,6 @@ 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",
@@ -439,20 +404,6 @@ 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()
@@ -483,14 +434,6 @@ 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):
@@ -659,20 +602,10 @@ 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 (core + extension-installed agent-context command)
# Skill files
*[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",
@@ -791,4 +724,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}"
)
)

View File

@@ -330,7 +330,7 @@ class TestForgeCommandRegistrar:
assert "speckit.my-extension.example" in registered
# Check the generated file has hyphenated name in frontmatter
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md"
assert forge_cmd.exists()
content = forge_cmd.read_text(encoding="utf-8")
@@ -378,7 +378,7 @@ class TestForgeCommandRegistrar:
)
# Check the alias file has hyphenated name in frontmatter
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md"
assert alias_file.exists()
content = alias_file.read_text(encoding="utf-8")
@@ -467,7 +467,7 @@ class TestForgeCommandRegistrar:
assert "speckit.git.feature" in registered
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-git-feature.md"
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md"
assert forge_cmd.exists(), "Expected Forge command file was not created"
content = forge_cmd.read_text(encoding="utf-8")

View File

@@ -195,39 +195,6 @@ 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):
@@ -244,8 +211,8 @@ class TestGenericIntegration:
assert result.exit_code != 0
def test_init_options_includes_context_file(self, tmp_path):
"""agent-context extension config must include context_file for the generic integration."""
import yaml
"""init-options.json must include context_file for the generic integration."""
import json
from typer.testing import CliRunner
from specify_cli import app
@@ -262,9 +229,8 @@ class TestGenericIntegration:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
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"
opts = json.loads((project / ".specify" / "init-options.json").read_text())
assert opts.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."""
@@ -299,14 +265,6 @@ 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",
@@ -363,14 +321,6 @@ 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",

View File

@@ -1,362 +0,0 @@
"""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}"

View File

@@ -7,7 +7,6 @@ import pytest
from typer.testing import CliRunner
from specify_cli import app
from tests.conftest import strip_ansi
runner = CliRunner()
@@ -50,8 +49,7 @@ def _write_invalid_manifest(project, key):
def _integration_list_row_cells(output: str, key: str) -> list[str]:
plain = strip_ansi(output)
row = next(line for line in plain.splitlines() if line.startswith(f"{key}"))
row = next(line for line in output.splitlines() if line.startswith(f"{key}"))
return [cell.strip() for cell in row.split("")[1:-1]]
@@ -162,9 +160,8 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
plain = strip_ansi(result.output)
assert "already installed" in plain
normalized = " ".join(plain.split())
assert "already installed" in result.output
normalized = " ".join(result.output.split())
assert "specify integration upgrade copilot" in normalized
assert "already the default integration" in normalized
assert "No files were changed" in normalized
@@ -185,8 +182,7 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
output = strip_ansi(result.output)
normalized = " ".join(output.split())
normalized = " ".join(result.output.split())
assert "already installed" in normalized
assert "specify integration use codex" in normalized
assert "specify integration upgrade codex" in normalized
@@ -201,10 +197,9 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
plain = strip_ansi(result.output)
assert "Installed integrations: copilot" in plain
assert "Default integration: copilot" in plain
normalized = " ".join(plain.split())
assert "Installed integrations: copilot" in result.output
assert "Default integration: copilot" in result.output
normalized = " ".join(result.output.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
@@ -235,29 +230,6 @@ class TestIntegrationInstall:
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_install_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
init_options = project / ".specify" / "init-options.json"
opts = json.loads(init_options.read_text(encoding="utf-8"))
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
updated = json.loads(init_options.read_text(encoding="utf-8"))
assert updated["speckit_version"] == "0.8.11"
assert updated["integration"] == "claude"
assert updated["ai"] == "claude"
assert "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"
@@ -314,10 +286,9 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
plain = strip_ansi(result.output)
assert "Installed integrations: copilot" in plain
assert "multi-install safe" in plain
normalized = " ".join(plain.split())
assert "Installed integrations: copilot" in result.output
assert "multi-install safe" in result.output
normalized = " ".join(result.output.split())
assert "To replace the default integration" in normalized
assert "specify integration switch claude" in normalized
assert "To install 'claude' alongside" in normalized
@@ -387,10 +358,6 @@ 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 ────────────────────────────────────────────────────────
@@ -519,9 +486,7 @@ 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:
@@ -540,7 +505,6 @@ 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."""
@@ -601,9 +565,7 @@ 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:
@@ -617,14 +579,10 @@ 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)
@@ -644,8 +602,6 @@ 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, [
@@ -660,7 +616,8 @@ class TestIntegrationUse:
assert "/speckit.plan" in updated
assert "custom template" not in updated
def test_use_does_not_persist_default_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
@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):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
init_options = project / ".specify" / "init-options.json"
@@ -676,12 +633,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
def fail_refresh(*args, **kwargs):
raise ValueError("refuse refresh")
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
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)
result = runner.invoke(app, [
"integration", "use", "codex",
@@ -691,9 +648,10 @@ class TestIntegrationUse:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Failed to refresh shared infrastructure" in result.output
assert "Failed to refresh shared templates" 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 ───────────────────────────────────────────────────────────
@@ -751,9 +709,7 @@ 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:
@@ -765,10 +721,8 @@ class TestIntegrationSwitch:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "shared infrastructure refreshed" in result.output
assert "managed shared infrastructure refreshed" not in result.output
assert "managed shared templates refreshed" 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")
@@ -797,8 +751,6 @@ 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:
@@ -817,8 +769,6 @@ 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"))
@@ -960,13 +910,12 @@ class TestIntegrationSwitch:
assert "claude" not in git_meta["registered_commands"]
assert "opencode" not in git_meta["registered_commands"]
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
"""Switching refreshes managed shared scripts to the target command style."""
def test_switch_preserves_shared_infra(self, tmp_path):
"""Switching preserves shared scripts, templates, and memory."""
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:
@@ -979,10 +928,9 @@ class TestIntegrationSwitch:
os.chdir(old_cwd)
assert result.exit_code == 0
# Shared infra untouched
assert shared_script.exists()
updated = shared_script.read_text(encoding="utf-8")
assert "/speckit.plan" in updated
assert "/speckit-plan" not in updated
assert shared_script.read_text(encoding="utf-8") == shared_content
def test_switch_refreshes_stale_managed_shared_infra(self, tmp_path):
"""Regression for #2293: stale managed shared scripts get refreshed on switch."""
@@ -990,7 +938,7 @@ class TestIntegrationSwitch:
project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
bundled_bytes = shared_script.read_bytes()
# Simulate a stale vendored script: write truncated content as bytes
# (write_text would translate \n→\r\n on Windows and break the hash)
@@ -1017,11 +965,8 @@ class TestIntegrationSwitch:
os.chdir(old_cwd)
assert result.exit_code == 0
# 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
# Stale managed file should be replaced by the bundled version
assert shared_script.read_bytes() == bundled_bytes
def test_switch_preserves_user_customized_shared_infra(self, tmp_path):
"""User customizations (hash divergence from manifest) survive switch without --refresh-shared-infra."""
@@ -1051,11 +996,10 @@ 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"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
rendered_bytes = shared_script.read_bytes()
bundled_bytes = shared_script.read_bytes()
# User customization (hash diverges from manifest)
custom_bytes = rendered_bytes + b"\n# user customization\n"
custom_bytes = bundled_bytes + b"\n# user customization\n"
shared_script.write_bytes(custom_bytes)
old_cwd = os.getcwd()
@@ -1069,11 +1013,8 @@ class TestIntegrationSwitch:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
# 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
# Customization is overwritten with the bundled version
assert shared_script.read_bytes() == bundled_bytes
def test_switch_skips_symlinked_parent_directory(self, tmp_path):
"""Regression: if .specify/scripts/bash is a symlink, switch must not write through it.
@@ -1203,57 +1144,7 @@ class TestIntegrationUpgrade:
assert "manifest" in result.output
assert "unreadable" in result.output
def test_upgrade_refreshes_init_options_speckit_version(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
init_options = project / ".specify" / "init-options.json"
opts = json.loads(init_options.read_text(encoding="utf-8"))
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--force",
])
assert result.exit_code == 0, result.output
updated = json.loads(init_options.read_text(encoding="utf-8"))
assert updated["speckit_version"] == "0.8.11"
def test_upgrade_non_default_refreshes_init_options_version_only(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "gemini")
install = _run_in_project(project, [
"integration", "install", "claude",
"--script", "sh",
])
assert install.exit_code == 0, install.output
init_options = project / ".specify" / "init-options.json"
opts = json.loads(init_options.read_text(encoding="utf-8"))
opts["speckit_version"] = "0.6.1"
init_options.write_text(json.dumps(opts), encoding="utf-8")
import specify_cli
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, result.output
updated = json.loads(init_options.read_text(encoding="utf-8"))
assert updated["speckit_version"] == "0.8.11"
assert updated["integration"] == "gemini"
assert updated["ai"] == "gemini"
assert "context_file" not in updated
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
init_options = project / ".specify" / "init-options.json"
@@ -1265,16 +1156,10 @@ class TestIntegrationUpgrade:
import specify_cli
real_install_shared_infra = specify_cli._install_shared_infra
calls = {"count": 0}
def fail_refresh(*args, **kwargs):
calls["count"] += 1
if calls["count"] == 2:
raise ValueError("refuse refresh")
return real_install_shared_infra(*args, **kwargs)
raise ValueError("refuse refresh")
monkeypatch.setattr(specify_cli, "_install_shared_infra", fail_refresh)
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
result = _run_in_project(project, [
"integration", "upgrade", "claude",
@@ -1282,40 +1167,15 @@ class TestIntegrationUpgrade:
])
assert result.exit_code != 0
assert "Failed to refresh shared infrastructure" in result.output
assert "Failed to refresh shared templates" 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:
@@ -1338,8 +1198,6 @@ 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/."""
@@ -1377,16 +1235,11 @@ 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 (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]}"
# 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]}"
)

View File

@@ -1,205 +0,0 @@
"""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

View File

@@ -7,13 +7,7 @@ Covers issue https://github.com/github/spec-kit/issues/550:
from unittest.mock import patch, MagicMock
from typer.testing import CliRunner
from specify_cli import app, check_tool
from tests.conftest import strip_ansi
runner = CliRunner()
from specify_cli import check_tool
class TestCheckToolClaude:
@@ -109,32 +103,4 @@ class TestCheckToolOther:
return "/usr/bin/kiro" if name == "kiro" else None
with patch("shutil.which", side_effect=fake_which):
assert check_tool("kiro-cli") is True
class TestCheckTip:
"""`specify check` should point users to the existing version check."""
def test_check_shows_self_check_tip(self):
with patch("specify_cli.check_tool", return_value=True):
result = runner.invoke(app, ["check"])
output = strip_ansi(result.output)
assert result.exit_code == 0
assert (
"Tip: Run 'specify self check' to verify you have the latest CLI version"
in output
)
def test_check_tip_does_not_fetch_latest_release(self):
with (
patch("specify_cli.check_tool", return_value=True),
patch(
"specify_cli._version._fetch_latest_release_tag",
side_effect=AssertionError("latest release lookup should not run"),
) as fetch_latest,
):
result = runner.invoke(app, ["check"])
assert result.exit_code == 0
fetch_latest.assert_not_called()
assert check_tool("kiro-cli") is True

Some files were not shown because too many files have changed in this diff Show More