mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48685926cf |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -1,8 +1,3 @@
|
||||
# Global code owner
|
||||
* @mnriem
|
||||
|
||||
# Community catalog files — explicit ownership for when global ownership expands
|
||||
/extensions/catalog.community.json @mnriem
|
||||
/integrations/catalog.community.json @mnriem
|
||||
/presets/catalog.community.json @mnriem
|
||||
|
||||
|
||||
22
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
22
.github/ISSUE_TEMPLATE/preset_submission.yml
vendored
@@ -95,18 +95,11 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: required-extensions
|
||||
attributes:
|
||||
label: Required Extensions (optional)
|
||||
description: Comma-separated list of required extension IDs (e.g., aide)
|
||||
placeholder: "e.g., aide, canon"
|
||||
|
||||
- type: textarea
|
||||
id: templates-provided
|
||||
attributes:
|
||||
label: Templates Provided
|
||||
description: List the template overrides your preset provides (enter "None" if command-only)
|
||||
description: List the template overrides your preset provides
|
||||
placeholder: |
|
||||
- spec-template.md — adds compliance section
|
||||
- plan-template.md — includes audit checkpoints
|
||||
@@ -117,19 +110,10 @@ body:
|
||||
- type: textarea
|
||||
id: commands-provided
|
||||
attributes:
|
||||
label: Commands Provided
|
||||
description: List the command overrides your preset provides (enter "None" if template-only)
|
||||
label: Commands Provided (optional)
|
||||
description: List any command overrides your preset provides
|
||||
placeholder: |
|
||||
- speckit.specify.md — customized for compliance workflows
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: scripts-count
|
||||
attributes:
|
||||
label: Number of Scripts (optional)
|
||||
description: How many scripts does your preset provide? (leave empty if none)
|
||||
placeholder: "e.g., 1"
|
||||
|
||||
- type: textarea
|
||||
id: tags
|
||||
|
||||
59
.github/workflows/catalog-assign.yml
vendored
59
.github/workflows/catalog-assign.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: "Catalog: Auto-assign submission"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
|
||||
jobs:
|
||||
assign:
|
||||
if: >
|
||||
(github.event.action == 'opened' && (
|
||||
contains(github.event.issue.labels.*.name, 'extension-submission') ||
|
||||
contains(github.event.issue.labels.*.name, 'preset-submission')
|
||||
)) ||
|
||||
(github.event.action == 'labeled' && (
|
||||
github.event.label.name == 'extension-submission' ||
|
||||
github.event.label.name == 'preset-submission'
|
||||
))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const assigned = (issue.assignees || []).map(a => a.login);
|
||||
const marker = '<!-- catalog-assign-bot -->';
|
||||
|
||||
// Assign mnriem if not already assigned
|
||||
if (!assigned.includes('mnriem')) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
assignees: ['mnriem'],
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Warning: could not assign mnriem: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post team notification if not already posted
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
}
|
||||
);
|
||||
if (!comments.some(c => c.body && c.body.includes(marker))) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: marker + '\ncc @github/spec-kit-maintainers — new catalog submission for review.',
|
||||
});
|
||||
}
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,14 +19,14 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
11
.github/workflows/docs.yml
vendored
11
.github/workflows/docs.yml
vendored
@@ -30,12 +30,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
@@ -48,10 +48,10 @@ jobs:
|
||||
docfx docfx.json
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: 'docs/_site'
|
||||
|
||||
@@ -66,4 +66,5 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
|
||||
|
||||
2
.github/workflows/release-trigger.yml
vendored
2
.github/workflows/release-trigger.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -86,3 +86,4 @@ jobs:
|
||||
--notes-file release_notes.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
# Days of inactivity before an issue or PR becomes stale
|
||||
days-before-stale: 150
|
||||
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -13,13 +13,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
@@ -34,13 +34,13 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
77
AGENTS.md
77
AGENTS.md
@@ -20,17 +20,23 @@ src/specify_cli/integrations/
|
||||
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
|
||||
├── manifest.py # IntegrationManifest (file tracking)
|
||||
├── claude/ # Example: SkillsIntegration subclass
|
||||
│ └── __init__.py # ClaudeIntegration class
|
||||
│ ├── __init__.py # ClaudeIntegration class
|
||||
│ └── scripts/ # Thin wrapper scripts
|
||||
│ ├── update-context.sh
|
||||
│ └── update-context.ps1
|
||||
├── gemini/ # Example: TomlIntegration subclass
|
||||
│ └── __init__.py
|
||||
│ ├── __init__.py
|
||||
│ └── scripts/
|
||||
├── windsurf/ # Example: MarkdownIntegration subclass
|
||||
│ └── __init__.py
|
||||
│ ├── __init__.py
|
||||
│ └── scripts/
|
||||
├── copilot/ # Example: IntegrationBase subclass (custom setup)
|
||||
│ └── __init__.py
|
||||
│ ├── __init__.py
|
||||
│ └── scripts/
|
||||
└── ... # One subpackage per supported agent
|
||||
```
|
||||
|
||||
The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, capabilities, and context files are derived from the integration classes for the Python integration layer.
|
||||
The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch.
|
||||
|
||||
---
|
||||
|
||||
@@ -173,11 +179,63 @@ def _register_builtins() -> None:
|
||||
# ...
|
||||
```
|
||||
|
||||
### 4. Context file behavior
|
||||
### 4. Add scripts
|
||||
|
||||
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.
|
||||
Create two thin wrapper scripts in `src/specify_cli/integrations/<package_dir>/scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate.
|
||||
|
||||
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.
|
||||
> **Note on `<package_dir>` vs `<key>`:** `<package_dir>` is the Python-safe directory name for your integration — it matches `<key>` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use.
|
||||
|
||||
**`update-context.sh`:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# update-context.sh — <Agent Name> integration: create/update <context_file>
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "$0")" && pwd)"
|
||||
_root="$_script_dir"
|
||||
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
|
||||
if [ -z "${REPO_ROOT:-}" ]; then
|
||||
if [ -d "$_root/.specify" ]; then
|
||||
REPO_ROOT="$_root"
|
||||
else
|
||||
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
|
||||
REPO_ROOT="$git_root"
|
||||
else
|
||||
REPO_ROOT="$_root"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" <key>
|
||||
```
|
||||
|
||||
**`update-context.ps1`:**
|
||||
|
||||
```powershell
|
||||
# update-context.ps1 — <Agent Name> integration: create/update <context_file>
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
|
||||
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = $scriptDir
|
||||
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
|
||||
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
|
||||
$repoRoot = Split-Path -Parent $repoRoot
|
||||
}
|
||||
}
|
||||
|
||||
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType <key>
|
||||
```
|
||||
|
||||
Replace `<key>` with your integration key and `<Agent Name>` / `<context_file>` with the appropriate values.
|
||||
|
||||
You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key:
|
||||
|
||||
- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`.
|
||||
- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`.
|
||||
|
||||
### 5. Test it
|
||||
|
||||
@@ -364,6 +422,7 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
|
||||
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
|
||||
4. Strips `handoffs` frontmatter key
|
||||
5. Injects missing `name` fields
|
||||
6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text
|
||||
|
||||
### Goose Integration
|
||||
|
||||
@@ -377,7 +436,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
|
||||
2. Extracts title and description from frontmatter
|
||||
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
|
||||
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
|
||||
5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,36 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.8.6] - 2026-05-06
|
||||
|
||||
### Changed
|
||||
|
||||
- Load constitution context in `/speckit.implement` to enforce governance during implementation (#2460)
|
||||
- feat: improve catalog submission templates and CODEOWNERS (#2401)
|
||||
- fix: validate URL scheme in build_github_request (#2449)
|
||||
- Add Architecture Guard to community catalog (#2430)
|
||||
- Add multi-model-review extension to community catalog (#2446)
|
||||
- Update Ralph Loop to v1.0.2 (#2435)
|
||||
- Pin GitHub Actions by SHA (#2441)
|
||||
- fix(workflows): require project for catalog list (#2436)
|
||||
- Add agent-parity-governance to community catalog (#2382)
|
||||
- chore: release 0.8.5, begin 0.8.6.dev0 development (#2447)
|
||||
|
||||
## [0.8.5] - 2026-05-04
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(presets): add Spec2Cloud preset for Azure deployment workflow (#2413)
|
||||
- update security-review and memory-md extensions to latest versions (#2445)
|
||||
- fix: honor template overrides for tasks-template (#2278) (#2292)
|
||||
- Add token-analyzer to community catalog (#2433)
|
||||
- docs: add April 2026 newsletter (#2434)
|
||||
- feat: emit init-time notice for git extension default change (#2165) (#2432)
|
||||
- Update DyanGalih(Memory Hub and Security Review) community extensions (#2429)
|
||||
- Support controlled multi-install for safe AI agent integrations (#2389)
|
||||
- chore(integrations): clean up docs and project guard (#2428)
|
||||
- chore: release 0.8.4, begin 0.8.5.dev0 development (#2431)
|
||||
|
||||
## [0.8.4] - 2026-05-01
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -174,7 +174,7 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
## 🧩 Community Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
|
||||
> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
|
||||
|
||||
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
||||
|
||||
@@ -198,7 +198,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
@@ -231,10 +230,9 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
|
||||
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
|
||||
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
|
||||
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
@@ -245,7 +243,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||
| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) |
|
||||
@@ -273,7 +271,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Community Presets
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets 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 preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
|
||||
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json):
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Keeps shared AI-agent instructions aligned across project-defined agent guidance surfaces and documents intentional deviations | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
@@ -22,7 +21,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| 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) |
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@ specify init [<project_name>]
|
||||
|
||||
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
|
||||
|
||||
> [!NOTE]
|
||||
> The git extension is currently enabled by default during `specify init`.
|
||||
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
|
||||
|
||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -43,8 +43,6 @@ specify integration list
|
||||
```
|
||||
|
||||
Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
|
||||
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
|
||||
The list also shows whether each built-in integration is declared multi-install safe.
|
||||
|
||||
## Install an Integration
|
||||
|
||||
@@ -55,12 +53,9 @@ specify integration install <key>
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
|
||||
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
|
||||
|
||||
Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state.
|
||||
|
||||
Installing an additional integration does not change the default integration. Use `specify integration use <key>` to change the default.
|
||||
Installs the specified integration into the current project. Fails if another integration is already installed — use `switch` instead. If the installation fails partway through, it automatically rolls back to a clean state.
|
||||
|
||||
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
|
||||
|
||||
@@ -89,22 +84,10 @@ specify integration switch <key>
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
|
||||
| `--integration-options` | Options for the target integration when it is not already installed |
|
||||
| `--force` | Force removal of modified files during uninstall |
|
||||
| `--integration-options` | Options for the target integration |
|
||||
|
||||
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
|
||||
|
||||
## Use an Installed Integration
|
||||
|
||||
```bash
|
||||
specify integration use <key>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| `--force` | Overwrite managed shared templates while changing the default |
|
||||
|
||||
Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used.
|
||||
Equivalent to running `uninstall` followed by `install` in a single step.
|
||||
|
||||
## Upgrade an Integration
|
||||
|
||||
@@ -118,7 +101,7 @@ specify integration upgrade [<key>]
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--integration-options` | Options for the integration |
|
||||
|
||||
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
|
||||
Reinstalls the current integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the currently installed integration; if a key is provided, it must match the installed one — otherwise the command fails and suggests using `switch` instead. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically.
|
||||
|
||||
## Integration-Specific Options
|
||||
|
||||
@@ -137,39 +120,9 @@ specify integration install generic --integration-options="--commands-dir .myage
|
||||
|
||||
## FAQ
|
||||
|
||||
### Can I install multiple integrations in the same project?
|
||||
### Can I use multiple integrations at the same time?
|
||||
|
||||
Yes, but it is intended for team portability rather than the default workflow. Multiple integrations are allowed automatically only when the installed integration and the new integration are declared multi-install safe by Spec Kit. For other combinations, pass `--force` to acknowledge that multiple agents may see unrelated agent-specific instructions or commands.
|
||||
|
||||
Spec Kit tracks one default integration in `.specify/integration.json` with `default_integration`, all installed integrations with `installed_integrations`, per-integration runtime settings with `integration_settings`, and a dedicated `integration_state_schema` for future state migrations. The legacy `integration` field remains as an alias for the default integration.
|
||||
|
||||
### Which integrations are multi-install safe?
|
||||
|
||||
An integration is multi-install safe when it uses isolated agent directories, a dedicated context file that does not collide with another safe integration, stable command invocation settings, and a separate install manifest. Shared Spec Kit templates remain aligned to the single default integration.
|
||||
|
||||
The currently declared multi-install safe integrations are:
|
||||
|
||||
| Key | Isolation |
|
||||
| --- | --------- |
|
||||
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
|
||||
| `claude` | `.claude/skills`, `CLAUDE.md` |
|
||||
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
|
||||
| `codex` | `.agents/skills`, `AGENTS.md` |
|
||||
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
|
||||
| `gemini` | `.gemini/commands`, `GEMINI.md` |
|
||||
| `iflow` | `.iflow/commands`, `IFLOW.md` |
|
||||
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `kimi` | `.kimi/skills`, `KIMI.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
| `qwen` | `.qwen/commands`, `QWEN.md` |
|
||||
| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` |
|
||||
| `shai` | `.shai/commands`, `SHAI.md` |
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` |
|
||||
|
||||
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
No. Only one AI coding agent integration can be installed per project. Use `specify integration switch <key>` to change to a different AI coding agent.
|
||||
|
||||
### What happens to my changes when I uninstall or switch?
|
||||
|
||||
@@ -185,4 +138,4 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins
|
||||
|
||||
### When should I use `upgrade` vs `switch`?
|
||||
|
||||
Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`.
|
||||
Use `upgrade` when you've upgraded Spec Kit and want to refresh the same integration's templates. Use `switch` when you want to change to a different AI coding agent.
|
||||
|
||||
@@ -528,9 +528,11 @@ specify extension add <extension-name> --from https://github.com/.../spec-kit-my
|
||||
|
||||
Submit to the community catalog for public discovery:
|
||||
|
||||
1. **Create a GitHub release** for your extension
|
||||
2. **File an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template
|
||||
3. **After review**, a maintainer updates the catalog and your extension becomes available:
|
||||
1. **Fork** spec-kit repository
|
||||
2. **Add entry** to `extensions/catalog.community.json`
|
||||
3. **Update** the Community Extensions table in `README.md` with your extension
|
||||
4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)
|
||||
5. **After merge**, your extension becomes available:
|
||||
- Users can browse `catalog.community.json` to discover your extension
|
||||
- Users copy the entry to their own `catalog.json`
|
||||
- Users install with: `specify extension add my-ext` (from their catalog)
|
||||
|
||||
@@ -7,8 +7,9 @@ This guide explains how to publish your extension to the Spec Kit extension cata
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Prepare Your Extension](#prepare-your-extension)
|
||||
3. [Submit to Catalog](#submit-to-catalog)
|
||||
4. [Release Workflow](#release-workflow)
|
||||
5. [Best Practices](#best-practices)
|
||||
4. [Verification Process](#verification-process)
|
||||
5. [Release Workflow](#release-workflow)
|
||||
6. [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
@@ -132,46 +133,222 @@ specify extension add <extension-name> --from https://github.com/your-org/spec-k
|
||||
|
||||
Spec Kit uses a dual-catalog system. For details about how catalogs work, see the main [Extensions README](README.md#extension-catalogs).
|
||||
|
||||
**For extension publishing**: All community extensions are listed in `extensions/catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`.
|
||||
**For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`.
|
||||
|
||||
### How to Submit
|
||||
### 1. Fork the spec-kit Repository
|
||||
|
||||
To submit your extension to the community catalog, file a new issue using the **[Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)** template. The template collects all required metadata, including:
|
||||
```bash
|
||||
# Fork on GitHub
|
||||
# https://github.com/github/spec-kit/fork
|
||||
|
||||
- Extension ID, name, and version
|
||||
- Description, author, and license
|
||||
- Repository, download URL, and documentation links
|
||||
- Required Spec Kit version and any tool dependencies
|
||||
- Number of commands and hooks
|
||||
- Tags and key features
|
||||
- Testing confirmation
|
||||
# Clone your fork
|
||||
git clone https://github.com/YOUR-USERNAME/spec-kit.git
|
||||
cd spec-kit
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Do **not** open a pull request directly to edit `extensions/catalog.community.json`. All community extension submissions must go through the issue template so a maintainer can review the entry and update the catalog.
|
||||
### 2. Add Extension to Community Catalog
|
||||
|
||||
### What Happens After You Submit
|
||||
Edit `extensions/catalog.community.json` and add your extension:
|
||||
|
||||
1. Your issue is automatically labeled and assigned to a maintainer for review
|
||||
2. A maintainer verifies that the catalog entry is complete and correctly formatted
|
||||
3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README
|
||||
4. Your extension becomes discoverable via `specify extension search`
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-28T15:54:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"your-extension": {
|
||||
"name": "Your Extension Name",
|
||||
"id": "your-extension",
|
||||
"description": "Brief description of your extension",
|
||||
"author": "Your Name",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/your-org/spec-kit-your-extension",
|
||||
"homepage": "https://github.com/your-org/spec-kit-your-extension",
|
||||
"documentation": "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/",
|
||||
"changelog": "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "required-mcp-tool",
|
||||
"version": ">=1.0.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"category",
|
||||
"tool-name",
|
||||
"feature"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-01-28T00:00:00Z",
|
||||
"updated_at": "2026-01-28T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What Maintainers Check
|
||||
**Important**:
|
||||
|
||||
- The catalog entry fields are complete and correctly formatted
|
||||
- The download URL is accessible
|
||||
- The repository exists and contains an `extension.yml` manifest
|
||||
- Set `verified: false` (maintainers will verify)
|
||||
- Set `downloads: 0` and `stars: 0` (auto-updated later)
|
||||
- Use current timestamp for `created_at` and `updated_at`
|
||||
- Update the top-level `updated_at` to current time
|
||||
|
||||
> [!NOTE]
|
||||
> Maintainers do **not** review, audit, or test the extension code itself.
|
||||
### 3. Update Community Extensions Table
|
||||
|
||||
Add your extension to the Community Extensions table in the project root `README.md`:
|
||||
|
||||
```markdown
|
||||
| Your Extension Name | Brief description of what it does | `<category>` | <effect> | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
|
||||
```
|
||||
|
||||
**(Table) Category** — pick the one that best fits your extension:
|
||||
|
||||
- `docs` — reads, validates, or generates spec artifacts
|
||||
- `code` — reviews, validates, or modifies source code
|
||||
- `process` — orchestrates workflow across phases
|
||||
- `integration` — syncs with external platforms
|
||||
- `visibility` — reports on project health or progress
|
||||
|
||||
**Effect** — choose one:
|
||||
|
||||
- Read-only — produces reports without modifying files
|
||||
- Read+Write — modifies files, creates artifacts, or updates specs
|
||||
|
||||
Insert your extension in alphabetical order in the table.
|
||||
|
||||
### 4. Submit Pull Request
|
||||
|
||||
```bash
|
||||
# Create a branch
|
||||
git checkout -b add-your-extension
|
||||
|
||||
# Commit your changes
|
||||
git add extensions/catalog.community.json README.md
|
||||
git commit -m "Add your-extension to community catalog
|
||||
|
||||
- Extension ID: your-extension
|
||||
- Version: 1.0.0
|
||||
- Author: Your Name
|
||||
- Description: Brief description
|
||||
"
|
||||
|
||||
# Push to your fork
|
||||
git push origin add-your-extension
|
||||
|
||||
# Create Pull Request on GitHub
|
||||
# https://github.com/github/spec-kit/compare
|
||||
```
|
||||
|
||||
**Pull Request Template**:
|
||||
|
||||
```markdown
|
||||
## Extension Submission
|
||||
|
||||
**Extension Name**: Your Extension Name
|
||||
**Extension ID**: your-extension
|
||||
**Version**: 1.0.0
|
||||
**Author**: Your Name
|
||||
**Repository**: https://github.com/your-org/spec-kit-your-extension
|
||||
|
||||
### Description
|
||||
Brief description of what your extension does.
|
||||
|
||||
### Checklist
|
||||
- [x] Valid extension.yml manifest
|
||||
- [x] README.md with installation and usage docs
|
||||
- [x] LICENSE file included
|
||||
- [x] GitHub release created (v1.0.0)
|
||||
- [x] Extension tested on real project
|
||||
- [x] All commands working
|
||||
- [x] No security vulnerabilities
|
||||
- [x] Added to extensions/catalog.community.json
|
||||
- [x] Added to Community Extensions table in README.md
|
||||
|
||||
### Testing
|
||||
Tested on:
|
||||
- macOS 13.0+ with spec-kit 0.1.0
|
||||
- Project: [Your test project]
|
||||
|
||||
### Additional Notes
|
||||
Any additional context or notes for reviewers.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Process
|
||||
|
||||
### What Happens After Submission
|
||||
|
||||
1. **Automated Checks** (if available):
|
||||
- Manifest validation
|
||||
- Download URL accessibility
|
||||
- Repository existence
|
||||
- License file presence
|
||||
|
||||
2. **Manual Review**:
|
||||
- Code quality review
|
||||
- Security audit
|
||||
- Functionality testing
|
||||
- Documentation review
|
||||
|
||||
3. **Verification**:
|
||||
- If approved, `verified: true` is set
|
||||
- Extension appears in `specify extension search --verified`
|
||||
|
||||
### Verification Criteria
|
||||
|
||||
To be verified, your extension must:
|
||||
|
||||
✅ **Functionality**:
|
||||
|
||||
- Works as described in documentation
|
||||
- All commands execute without errors
|
||||
- No breaking changes to user workflows
|
||||
|
||||
✅ **Security**:
|
||||
|
||||
- No known vulnerabilities
|
||||
- No malicious code
|
||||
- Safe handling of user data
|
||||
- Proper validation of inputs
|
||||
|
||||
✅ **Code Quality**:
|
||||
|
||||
- Clean, readable code
|
||||
- Follows extension best practices
|
||||
- Proper error handling
|
||||
- Helpful error messages
|
||||
|
||||
✅ **Documentation**:
|
||||
|
||||
- Clear installation instructions
|
||||
- Usage examples
|
||||
- Troubleshooting section
|
||||
- Accurate description
|
||||
|
||||
✅ **Maintenance**:
|
||||
|
||||
- Active repository
|
||||
- Responsive to issues
|
||||
- Regular updates
|
||||
- Semantic versioning followed
|
||||
|
||||
### Typical Review Timeline
|
||||
|
||||
- **Review**: 3-7 business days
|
||||
|
||||
### Updating an Existing Extension
|
||||
|
||||
To update an extension that is already in the catalog (e.g., for a new version), file a new **[Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)** issue with the updated version, download URL, and any other changed fields. Mention in the issue that this is an update to an existing entry.
|
||||
- **Automated checks**: Immediate (if implemented)
|
||||
- **Manual review**: 3-7 business days
|
||||
- **Verification**: After successful review
|
||||
|
||||
---
|
||||
|
||||
@@ -208,7 +385,26 @@ When releasing a new version:
|
||||
# Create release on GitHub
|
||||
```
|
||||
|
||||
4. **File an update submission** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template with the new version and download URL. Mention in the issue that this is an update to an existing entry.
|
||||
4. **Update catalog**:
|
||||
|
||||
```bash
|
||||
# Fork spec-kit repo (or update existing fork)
|
||||
cd spec-kit
|
||||
|
||||
# Update extensions/catalog.json
|
||||
jq '.extensions["your-extension"].version = "1.1.0"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
|
||||
jq '.extensions["your-extension"].download_url = "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
|
||||
jq '.extensions["your-extension"].updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
|
||||
jq '.updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json
|
||||
|
||||
# Submit PR
|
||||
git checkout -b update-your-extension-v1.1.0
|
||||
git add extensions/catalog.json
|
||||
git commit -m "Update your-extension to v1.1.0"
|
||||
git push origin update-your-extension-v1.1.0
|
||||
```
|
||||
|
||||
5. **Submit update PR** with changelog in description
|
||||
|
||||
---
|
||||
|
||||
@@ -277,9 +473,9 @@ A: The main catalog is for public extensions only. For private extensions:
|
||||
- Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json`
|
||||
- Not yet implemented - coming in Phase 4
|
||||
|
||||
### Q: How long does review take?
|
||||
### Q: How long does verification take?
|
||||
|
||||
A: Typically 3-7 business days. Updates to existing extensions are usually faster.
|
||||
A: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster.
|
||||
|
||||
### Q: What if my extension is rejected?
|
||||
|
||||
@@ -287,11 +483,11 @@ A: You'll receive feedback on what needs to be fixed. Make the changes and resub
|
||||
|
||||
### Q: Can I update my extension anytime?
|
||||
|
||||
A: Yes, file a new [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) issue with the updated version and download URL. Mention that it is an update to an existing entry.
|
||||
A: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes.
|
||||
|
||||
### Q: Do I need to be verified to be in the catalog?
|
||||
|
||||
A: No. All community extensions are listed in the catalog once their submission is reviewed and accepted.
|
||||
A: No, unverified extensions are still searchable. Verification just adds trust and visibility.
|
||||
|
||||
### Q: Can extensions have paid features?
|
||||
|
||||
@@ -340,7 +536,7 @@ A: Extensions should be free and open-source. Commercial support/services are al
|
||||
"hooks": "integer (optional)"
|
||||
},
|
||||
"tags": ["array of strings (2-10 tags)"],
|
||||
"verified": "boolean (default: false, set by maintainers)",
|
||||
"verified": "boolean (default: false)",
|
||||
"downloads": "integer (auto-updated)",
|
||||
"stars": "integer (auto-updated)",
|
||||
"created_at": "string (ISO 8601 datetime)",
|
||||
|
||||
@@ -25,13 +25,13 @@ specify extension search # Now uses your organization's catalog instead of the
|
||||
### Community Reference Catalog (`catalog.community.json`)
|
||||
|
||||
> [!NOTE]
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
|
||||
> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion.
|
||||
|
||||
- **Purpose**: Browse available community-contributed extensions
|
||||
- **Status**: Active - contains extensions submitted by the community
|
||||
- **Location**: `extensions/catalog.community.json`
|
||||
- **Usage**: Reference catalog for discovering available extensions
|
||||
- **Submission**: Open to community contributions via [issue template](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)
|
||||
- **Submission**: Open to community contributions via Pull Request
|
||||
|
||||
**How It Works:**
|
||||
|
||||
@@ -72,7 +72,7 @@ specify extension add <extension-name> --from https://github.com/org/spec-kit-ex
|
||||
## Available Community Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
|
||||
> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
|
||||
|
||||
🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**
|
||||
|
||||
@@ -89,8 +89,10 @@ To add your extension to the community catalog:
|
||||
|
||||
1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md)
|
||||
2. **Create a GitHub release** for your extension
|
||||
3. **File an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template with all required metadata
|
||||
4. **Wait for review** — a maintainer will review the submission, update the catalog, and close the issue
|
||||
3. **Submit a Pull Request** that:
|
||||
- Adds your extension to `extensions/catalog.community.json`
|
||||
- Updates this README with your extension in the Available Extensions table
|
||||
4. **Wait for review** - maintainers will review and merge if criteria are met
|
||||
|
||||
See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-04-30T09:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -100,39 +100,6 @@
|
||||
"created_at": "2026-04-14T00:00:00Z",
|
||||
"updated_at": "2026-04-14T00:00:00Z"
|
||||
},
|
||||
"architecture-guard": {
|
||||
"name": "Architecture Guard",
|
||||
"id": "architecture-guard",
|
||||
"description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.4.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.4.0.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",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"drift-detection",
|
||||
"refactor",
|
||||
"monolithic",
|
||||
"microservices"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-05-05T07:26:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
"id": "archive",
|
||||
@@ -1311,20 +1278,20 @@
|
||||
"memory-md": {
|
||||
"name": "Memory MD",
|
||||
"id": "memory-md",
|
||||
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
|
||||
"description": "Repository-native durable memory for Spec Kit projects",
|
||||
"author": "DyanGalih",
|
||||
"version": "0.7.5",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip",
|
||||
"version": "0.6.2",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/CHANGELOG.md",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"commands": 5,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -1332,14 +1299,13 @@
|
||||
"workflow",
|
||||
"docs",
|
||||
"copilot",
|
||||
"markdown",
|
||||
"ai-context"
|
||||
"markdown"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-23T00:00:00Z",
|
||||
"updated_at": "2026-05-03T00:00:00Z"
|
||||
"updated_at": "2026-04-23T00:00:00Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
@@ -1373,56 +1339,6 @@
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-16T13:10:26Z"
|
||||
},
|
||||
"multi-model-review": {
|
||||
"name": "Multi-Model Review",
|
||||
"id": "multi-model-review",
|
||||
"description": "Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review.",
|
||||
"author": "formin",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/formin/multi-model-review",
|
||||
"homepage": "https://github.com/formin/multi-model-review",
|
||||
"documentation": "https://github.com/formin/multi-model-review/blob/main/README.md",
|
||||
"changelog": "https://github.com/formin/multi-model-review/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "codex",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "gemini",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"review",
|
||||
"workflow",
|
||||
"multi-model",
|
||||
"spec-driven-development",
|
||||
"code"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-04T02:51:52Z",
|
||||
"updated_at": "2026-05-04T02:51:52Z"
|
||||
},
|
||||
"onboard": {
|
||||
"name": "Onboard",
|
||||
"id": "onboard",
|
||||
@@ -1681,12 +1597,12 @@
|
||||
"id": "ralph",
|
||||
"description": "Autonomous implementation loop using AI agent CLI.",
|
||||
"author": "Rubiss",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.0.2.zip",
|
||||
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
|
||||
"changelog": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/CHANGELOG.md",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/Rubiss/spec-kit-ralph",
|
||||
"homepage": "https://github.com/Rubiss/spec-kit-ralph",
|
||||
"documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md",
|
||||
"changelog": "https://github.com/Rubiss/spec-kit-ralph/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
@@ -1715,7 +1631,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-09T00:00:00Z",
|
||||
"updated_at": "2026-05-04T17:02:08Z"
|
||||
"updated_at": "2026-04-12T19:00:00Z"
|
||||
},
|
||||
"reconcile": {
|
||||
"name": "Reconcile Extension",
|
||||
@@ -2015,8 +1931,8 @@
|
||||
"id": "security-review",
|
||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.4.2",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.3.0.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||
@@ -2026,7 +1942,7 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 7,
|
||||
"commands": 6,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -2040,7 +1956,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-05-03T00:00:00Z"
|
||||
"updated_at": "2026-04-29T00:00:00Z"
|
||||
},
|
||||
"sf": {
|
||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||
@@ -2579,37 +2495,6 @@
|
||||
"created_at": "2026-04-25T00:00:00Z",
|
||||
"updated_at": "2026-04-25T00:00:00Z"
|
||||
},
|
||||
"token-analyzer": {
|
||||
"name": "Token Consumption Analyzer",
|
||||
"id": "token-analyzer",
|
||||
"description": "Captures, analyzes, and compares token consumption across SDD workflows",
|
||||
"author": "Chris Roberts | coderandhiker",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/coderandhiker/spec-kit-token-analyzer/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/coderandhiker/spec-kit-token-analyzer",
|
||||
"homepage": "https://github.com/coderandhiker/spec-kit-token-analyzer",
|
||||
"documentation": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/README.md",
|
||||
"changelog": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 4
|
||||
},
|
||||
"tags": [
|
||||
"tokens",
|
||||
"measurement",
|
||||
"optimization",
|
||||
"analysis"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
"updated_at": "2026-05-01T00:00:00Z"
|
||||
},
|
||||
"v-model": {
|
||||
"name": "V-Model Extension Pack",
|
||||
"id": "v-model",
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# Spec Kit - April 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in April 2026. Seventeen releases shipped (v0.4.4 through v0.8.3), delivering a full integration plugin architecture, a workflow engine, preset composition strategies, an integration catalog, and comprehensive documentation. The community extension catalog tripled from 26 to 83 entries, community presets grew from 2 to 12, and Spec Kit appeared on the Thoughtworks Technology Radar. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
|
||||
|
||||
***
|
||||
|
||||
> **Important:** April's release pace outran external coverage. Most analyses published during the month (Rickard on April 1, Thoughtworks Radar on April 15, XB Software on April 17, Torber on April 23) were evaluating versions that predated the workflow engine (v0.7.0), integration catalog (v0.7.2), preset composition (v0.8.0), and catalog discovery CLI (v0.8.3). The ceremony and flexibility concerns they raised are precisely what these features address — the lean preset, pluggable workflows, composable presets, and community extensions like Conduct, MAQA, and Fleet Orchestrator already deliver alternative workflows beyond the default SDD process. We look forward to seeing how upcoming reviews account for these capabilities.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
### Releases Overview
|
||||
|
||||
**v0.4.4** (April 1) delivered the first stage of the **integration plugin architecture** — base classes, a manifest system, and a registry that replaced the hard-coded agent scaffolding. It also added the Product Forge, Superpowers Bridge, MAQA suite (7 extensions), Spec Kit Onboard, and Plan Review Gate to the community catalog, fixed Claude Code CLI detection for npm-local installs, and added `--allow-existing-branch` to `create-new-feature`. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.4)
|
||||
|
||||
**v0.4.5** (April 2) completed the integration migration in five stages: standard markdown integrations for 19 agents, TOML integrations (Gemini, Tabnine), skills and generic integrations, and removal of the legacy scaffold path. It also installed Claude Code as native skills, added a `--dry-run` flag for `create-new-feature`, support for 4+ digit feature branch numbers, the Fix Findings extension, and five lifecycle extensions to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.5)
|
||||
|
||||
**v0.5.0** (April 2) was a significant packaging change: **template zip bundles were removed from releases**, with the CLI itself now handling all scaffolding. This ensured CLI and templates stay in sync. It also introduced `DEVELOPMENT.md` for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.0)
|
||||
|
||||
**v0.5.1** (April 8) was a large patch release. It added the **bundled Git extension** (stages 1 and 2) with hooks on all core commands and `GIT_BRANCH_NAME` override support, **Forgecode** agent support, and the `specify integration` subcommand for post-init integration management. Argument hints were added to Claude Code commands. Numerous community extensions joined the catalog (Confluence, Canon, Spec Diagram, Branch Convention, Spec Refine, FixIt, Optimize, Security Review) along with presets (explicit-task-dependencies, toc-navigation, VS Code Ask Questions). Bug fixes included pinning typer≥0.24.0/click≥8.2.1 to fix an import crash, BSD-portable sed escaping, Trae agent fix, TOML frontmatter stripping, and preventing ambiguous TOML closing quotes. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.1)
|
||||
|
||||
**v0.6.0** (April 9) rewrote **AGENTS.md for the new integration architecture**, added the SpecKit Companion to Community Friends, and brought Bugfix Workflow, Worktree Isolation, and MemoryLint to the community catalog. A new multi-repo-branching preset arrived. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.0)
|
||||
|
||||
**v0.6.1** (April 10) added the **bundled lean preset** with a minimal workflow command set — a lighter-weight alternative to the full SDD ceremony. It also migrated **Cursor** from `.cursor/commands` to `.cursor/skills` and added Brownfield Bootstrap, CI Guard, SpecTest, PR Bridge, TinySpec, and Status Report to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.1)
|
||||
|
||||
**v0.6.2** (April 13) added **Goose AI agent** support (YAML-based recipe format), the GitHub Issues Integration extension, and the What-if Analysis extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.2)
|
||||
|
||||
**v0.7.0** (April 14) delivered the **workflow engine with catalog system**, enabling pluggable, multi-step workflow definitions. It added SFSpeckit (Salesforce SDD), the Worktrees extension, optional single-segment branch prefix for gitflow compatibility, and the claude-ask-questions and fiction-book-writing presets. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.0)
|
||||
|
||||
**v0.7.1** (April 15) deprecated the `--ai` flag in favor of `--integration` on `specify init`, added Windows to the CI test matrix, fixed Claude skill chaining for hook execution, merged TESTING.md into CONTRIBUTING.md, and added the Agent Assign and Architect Preview extensions. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.1)
|
||||
|
||||
**v0.7.2** (April 16) delivered the **integration catalog** for discovery, versioning, and community distribution of agent integrations. It also produced a major **documentation overhaul**: reference pages for core commands, extensions, presets, workflows, and integrations were added to `docs/reference/`, and the README CLI section was simplified. The Issues extension and Catalog CI extension joined the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.2)
|
||||
|
||||
**v0.7.3** (April 17) replaced shell-based context updates with a **marker-based upsert** mechanism, eliminating accidental context file bloat. It added a **Community Friends page** to the docs site, the Spec Scope and Blueprint extensions, and a Claude Code/Copilot CLI plugin marketplace reference in the README. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.3)
|
||||
|
||||
**v0.7.4** (April 21) added **CITATION.cff and .zenodo.json** for academic citation support. It introduced Ripple (side-effect detection), Spec Validate, Version Guard, Spec Reference Loader, and Memory Loader extensions. A fix stripped UTF-8 BOM from agent context files, and the Antigravity (agy) agent layout was migrated to `.agents/` with `--skills` deprecated. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.4)
|
||||
|
||||
**v0.7.5** (April 22) added `specify self check` and `self upgrade` stubs, the **preset wrap strategy** (completing the composition trifecta alongside prepend and append), the Red Team adversarial review extension, the Wireframe extension, and a **directory traversal security fix** in command write paths. Skill placeholder resolution was expanded to all SKILL.md agents. Community content (walkthroughs and presets) was moved from the README to the docs site. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.5)
|
||||
|
||||
**v0.8.0** (April 23) delivered **preset composition strategies** (prepend, append, wrap) for templates, commands, and scripts — enabling presets to layer content around existing artifacts. It also added Copilot `--integration-options="--skills"` for skills-based scaffolding, `pipx` as an alternative installation method, and the Memory MD extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.0)
|
||||
|
||||
**v0.8.1** (April 24) fixed `/speckit.plan` on custom git branches via `.specify/feature.json`, migrated the **Mistral Vibe** integration to SkillsIntegration, added the **Screenwriting** and **Jira** presets, and resolved command reference formats per integration type (dot vs. hyphen notation). [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.1)
|
||||
|
||||
**v0.8.2** (April 28) introduced **GITHUB_TOKEN/GH_TOKEN authentication** for private catalog and extension downloads, deprecated the `--no-git` flag (removal gated at v0.10.0), replaced all deprecated `--ai` references with `--integration` in documentation, and added MarkItDown Document Converter, Microsoft 365 Integration, Spec Orchestrator, and the Fiction Book Writing v1.7 preset with RAG (Chroma DB) offline semantic search. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.2)
|
||||
|
||||
**v0.8.3** (April 29) closed the month with **catalog discovery CLI commands** (search, info, catalog list/add/remove), support for **Devin for Terminal** as a skills-based integration, a fix for the opencode command dispatch, and the OWASP LLM Threat Model, iSAQB Architecture Governance, and Work IQ extensions. A fix was also added to the upgrade hint to prevent users from accidentally installing a PyPI squat package. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.3)
|
||||
|
||||
### Architecture & Infrastructure Highlights
|
||||
|
||||
The most significant architectural change in April was the **integration plugin architecture** (v0.4.4–v0.4.5), which replaced hard-coded agent scaffolding with a registry of self-describing integration classes. Each agent is now a self-contained subpackage under `src/specify_cli/integrations/<key>/` with base classes for Markdown, TOML, YAML, and Skills formats. This six-stage migration touched all 28 supported agents and laid the groundwork for the integration catalog (v0.7.2) and community-distributed integrations.
|
||||
|
||||
The **workflow engine** (v0.7.0) introduced a catalog-based system for pluggable, multi-step workflow definitions — moving beyond the fixed seven-step SDD sequence.
|
||||
|
||||
**Preset composition strategies** (v0.7.5/v0.8.0) completed the preset system with prepend, append, and wrap modes. Presets can now layer content around existing templates, commands, and scripts rather than only replacing them.
|
||||
|
||||
The **marker-based context upsert** (v0.7.3) replaced fragile shell-based sed operations for updating agent context files, eliminating a class of bugs around context bloat and encoding issues.
|
||||
|
||||
**Template zip bundles were removed** (v0.5.0), coupling the CLI and templates into a single distributable artifact.
|
||||
|
||||
### Bug Fixes and Security
|
||||
|
||||
The most critical fix was **blocking directory traversal in command write paths** (#2229, v0.7.5), which prevented a potential path traversal vulnerability in the CommandRegistrar. Other security-adjacent fixes included hardening against a **PyPI squat package** in upgrade hints (v0.8.3) and adding **GITHUB_TOKEN authentication** for private catalog downloads (v0.8.2).
|
||||
|
||||
Notable bug fixes: typer/click import crash (v0.5.1), BSD-portable sed escaping (v0.5.1), UTF-8 BOM stripping from context files (v0.7.4), CRLF warning suppression in PowerShell auto-commit (v0.7.3), Claude skill chaining for hooks (v0.7.1), TOML ambiguous closing quotes (v0.5.1), and custom branch support for `/speckit.plan` (v0.8.1). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Extension & Preset Ecosystem
|
||||
|
||||
The community extension catalog **tripled** during April, growing from 26 to **83 entries**. 59 new extensions were added and 2 were removed (Cognitive Squad and Understanding, whose repositories were no longer available). Community presets grew from 2 to **12 entries**, with 10 new presets added.
|
||||
|
||||
Notable new extensions by category:
|
||||
|
||||
- **Project management**: GitHub Issues Integration (Fatima367, aaronrsun), Spec Orchestrator (Quratulain-bilal), Agent Assign (xuyang), Status Report (Open-Agent-Tools)
|
||||
- **Quality & security**: Red Team adversarial review (Ash Brener), Security Review (DyanGalih), Ripple side-effect detection (chordpli), Spec Validate (Ahmed Eltayeb), CI Guard (Quratulain-bilal), OWASP LLM Threat Model (NaviaSamal)
|
||||
- **Multi-agent & orchestration**: MAQA suite with 7 extensions covering multi-agent QA, Jira, Azure DevOps, GitHub Projects, Linear, and Trello integrations (GenieRobot), Product Forge (VaiYav)
|
||||
- **Spec lifecycle**: Spec Refine (Quratulain-bilal), Bugfix Workflow (Quratulain-bilal), Fix Findings (Quratulain-bilal), Brownfield Bootstrap (Quratulain-bilal), TinySpec (Quratulain-bilal)
|
||||
- **Developer experience**: Blueprint code review (chordpli), Confluence (aaronrsun), MarkItDown Document Converter (BenBtg), Microsoft 365 Integration (BenBtg), Memory MD (DyanGalih), Memory Loader (KevinBrown5280), MemoryLint (RbBtSn0w)
|
||||
- **Domain-specific**: SFSpeckit for Salesforce (Sumanth Yanamala), iSAQB Architecture Governance preset (Thorsten Hindermann), Canon baseline-driven workflows (Maxim Stupakov)
|
||||
- **Creative**: Fiction Book Writing preset v1.7 with RAG/Chroma DB support (Andreas Daumann), Screenwriting preset (Andreas Daumann)
|
||||
|
||||
Notable contributor **Quratulain-bilal** contributed 15 extensions during the month, spanning spec lifecycle, workflow management, and CI/CD integration. **GenieRobot** contributed the 7-extension MAQA suite. **BenBtg** contributed both MarkItDown and Microsoft 365 integrations. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Documentation Overhaul
|
||||
|
||||
April saw a comprehensive documentation effort. Reference pages for **core commands, extensions, presets, workflows, and integrations** were created under `docs/reference/`. Community content — **walkthroughs, presets, and a Community Friends page** — was moved from the README to `docs/community/`, reducing README length while improving discoverability. The deprecated `--ai` flag references were replaced with `--integration` across all documentation. TESTING.md was merged into CONTRIBUTING.md, and `DEVELOPMENT.md` was introduced for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
## Community & Content
|
||||
|
||||
### Thoughtworks Technology Radar
|
||||
|
||||
On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub Spec Kit in the **"Assess" ring** under Languages & Frameworks. The blip noted that teams report value in brownfield projects, that the constitution captures project scope and architecture, but flagged potential **instruction bloat, context rot, and verbose markdown output** as concerns to watch. This is the first appearance of any SDD-specific tool on the Radar. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit)
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
|
||||
|
||||
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
|
||||
|
||||
**Fabián Silva** published *"I Built a Visual Spec-Driven Development Extension for VS Code That Works With Any LLM"* on April 3 on DEV Community. His **Caramelo** VS Code extension adds a visual UI, approval gates, Jira integration, and multi-LLM support on top of Spec Kit's workflow, reading and writing the standard `specs/` directory. [\[dev.to\]](https://dev.to/fabian_silva_/i-built-a-visual-spec-driven-development-extension-for-vs-code-that-works-with-any-llm-36ok)
|
||||
|
||||
**James M** published *"GitHub Spec Kit in 2026: SDD Goes Mainstream"* on April 4, calling the transition "from framework to platform" and highlighting Claude Code native skills, multi-agent support, and the massive ecosystem growth. [\[jamesm.blog\]](https://jamesm.blog/ai/github-spec-kit-2026-update/)
|
||||
|
||||
**Peter Saktor** published a detailed tutorial on DEV Community on April 6: *"GitHub Spec-Kit: From Vibe Coding to Spec-Driven Development,"* walking through a full 7-step SDD workflow refactoring an Azure Container App with 33 tasks across 6 phases. [\[dev.to\]](https://dev.to/petersaktor/github-spec-kit-from-vibe-coding-to-spec-driven-development-1pgd)
|
||||
|
||||
**Codexplorer** published *"Spec Kit: GitHub's Answer to 'The AI Built the Wrong Thing Again'"* on Medium (April 11), framing Spec Kit as flipping the spec-code relationship, with Go code examples covering the seven slash commands. [\[medium.com\]](https://codexplorer.medium.com/spec-kit-githubs-answer-to-the-ai-built-the-wrong-thing-again-22f122f142fb)
|
||||
|
||||
**XB Software** published *"Spec Kit on a Real Project: Implementation Experience in Large Legacy Code"* on April 17 — a field report from applying SDD to legacy systems. A week-long task was completed in half the time. The AI surfaced hidden requirements gaps. They noted API integration weakness, that SDD is overkill for small tasks, and that an experienced reviewer is still essential. [\[xbsoftware.com\]](https://xbsoftware.com/blog/ai-in-legacy-systems-spec-driven-development/)
|
||||
|
||||
**What IT Is** published *"Perspectives in Spec Driven Development"* on April 21, surveying the SDD landscape (Spec Kit, Kiro, Tessl) and calling Spec Kit "a good entry point." [\[theitsolutionist.com\]](https://theitsolutionist.com/2026/04/21/perspectives-in-spec-driven-development/)
|
||||
|
||||
**Will Torber** published *"Spec Kit vs BMAD vs OpenSpec: Choosing an SDD Framework in 2026"* on DEV Community on April 23. He recommended Spec Kit for greenfield but flagged brownfield friction and the branch-per-spec limitation, ultimately **recommending OpenSpec for most teams**. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j)
|
||||
|
||||
**Truong Phung** published *"Spec Kit vs. Superpowers: A Comprehensive Comparison & Practical Guide to Combining Both"* on DEV Community on April 25 — an 11-section comparison proposing a hybrid workflow: "Spec Kit plans WHAT, Superpowers controls HOW," with a step-by-step playbook. [\[dev.to\]](https://dev.to/truongpx396/spec-kit-vs-superpowers-a-comprehensive-comparison-practical-guide-to-combining-both-52jj)
|
||||
|
||||
**Markus Wondrak** published *"Re-evaluating GitHub's Spec Kit: Structured SDLC Automation"* on LinkedIn on April 26, examining Spec Kit as a structured SDLC automation approach requiring human review at phase boundaries. [\[linkedin.com\]](https://www.linkedin.com/pulse/re-evaluating-githubs-spec-kit-structured-sdlc-markus-wondrak-eewqf/)
|
||||
|
||||
**FintechExtra** published a factual release-notes summary of v0.8.2 on April 28, highlighting authenticated catalog downloads, the UTF-8 manifest fix, and the Chroma DB semantic search in the fiction writing preset. [\[fintechextra.com\]](https://www.fintechextra.com/news/github-spec-kit-v082-expands-catalog-support-and-tightens-cli-behavior-331)
|
||||
|
||||
### Community Friends and Tools
|
||||
|
||||
The **SpecKit Companion** VS Code extension was added to the Community Friends section (v0.6.0). A community-maintained plugin for **Claude Code and GitHub Copilot CLI** that installs Spec Kit skills via the plugin marketplace was referenced in the README (v0.7.3). Fabián Silva's **Caramelo** VS Code extension demonstrated a visual UI approach to SDD. [\[github.com\]](https://github.com/github/spec-kit)
|
||||
|
||||
## SDD Ecosystem & Industry Trends
|
||||
|
||||
### The "Spec Layer" Debate
|
||||
|
||||
Matt Rickard's "The Spec Layer" essay established a new framing for SDD: specifications as **constraint surfaces** that reduce execution freedom for AI agents. His comparison of six SDD tools argued for smaller, more focused specs with harder verification checks — a departure from comprehensive specification documents. This framing resonated across the community, with the Thoughtworks Radar entry and multiple comparison articles echoing the tension between spec depth and practical overhead.
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
**Will Torber's** three-framework comparison (Spec Kit, BMAD, OpenSpec) recommended **OpenSpec for most teams**, citing lower ceremony and better brownfield support. **Truong Phung** proposed combining Spec Kit with **Superpowers** (Jesse Vincent) for a "plan WHAT + control HOW" hybrid. These comparisons reflected a maturing market where practitioners combine tools rather than picking one.
|
||||
|
||||
The **Thoughtworks Radar** placement validated SDD as a category worth tracking but flagged instruction bloat and context rot as open concerns — the same issues the Augment Code comparison raised in March. XB Software's field report confirmed these in practice: SDD adds value for complex legacy work but creates unnecessary overhead for small tasks.
|
||||
|
||||
Spec Kit continued to lead in **GitHub popularity** (92k stars) and **agent breadth** (29 integrations). The market continued to differentiate along several axes: Spec Kit on portability and ecosystem breadth, Intent on living specs and drift detection, BMAD-METHOD on multi-agent orchestration, and OpenSpec on simplicity. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **Spec lifecycle management** — context rot and spec drift remained the most cited concern across articles (Thoughtworks Radar, XB Software, Will Torber). The marker-based upsert (v0.7.3) addressed context file drift; spec-level drift detection remains an open area. The Reconcile and Archive extensions are community steps toward this. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit)
|
||||
- **Workflow customization** — the workflow engine (v0.7.0) and preset composition strategies (v0.8.0) provide the foundation. Community presets for fiction writing, screenwriting, Jira tracking, and architecture governance demonstrate the breadth of possible workflows beyond standard SDD. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Catalog discovery and distribution** — the integration catalog (v0.7.2) and catalog discovery CLI (v0.8.3) bring `specify` closer to a package-manager experience for extensions, presets, and integrations. Private catalog authentication (v0.8.2) supports enterprise distribution. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Experience simplification** — the bundled lean preset (v0.6.1), `specify self check` (v0.7.5), and the deprecation of `--ai` in favor of `--integration` (v0.7.1) reflect ongoing work to reduce ceremony and improve the onboarding experience. Multiple external articles (Torber, XB Software) noted SDD overhead as a barrier. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j)
|
||||
- **Cross-platform and enterprise** — Windows CI (v0.7.1), GITHUB_TOKEN authentication (v0.8.2), Salesforce-specific extensions, and the iSAQB architecture governance preset indicate growing enterprise adoption. [\[github.com\]](https://github.com/github/spec-kit)
|
||||
@@ -98,7 +98,7 @@ Multiple composing presets chain recursively. For example, a security preset wit
|
||||
Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs:
|
||||
|
||||
> [!NOTE]
|
||||
> Community presets 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 preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion.
|
||||
|
||||
```bash
|
||||
# List active catalogs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-05T10:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -31,34 +31,6 @@
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 6,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"agents",
|
||||
"governance",
|
||||
"parity",
|
||||
"agent-guidance",
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
"id": "aide-in-place",
|
||||
@@ -429,33 +401,6 @@
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-04-27T00:00:00Z"
|
||||
},
|
||||
"spec2cloud": {
|
||||
"name": "Spec2Cloud",
|
||||
"id": "spec2cloud",
|
||||
"version": "1.1.0",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
|
||||
"author": "Azure Samples",
|
||||
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
|
||||
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip",
|
||||
"homepage": "https://aka.ms/spec2cloud",
|
||||
"documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 5,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"azure",
|
||||
"spec2cloud",
|
||||
"workflow",
|
||||
"deployment"
|
||||
],
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.6"
|
||||
version = "0.8.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_MODE=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
|
||||
# Validate branch
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build available docs list
|
||||
docs=()
|
||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
fi
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
|
||||
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
[Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||
[Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build available docs list
|
||||
$docs = @()
|
||||
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
|
||||
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
|
||||
exit 1
|
||||
}
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
TASKS_TEMPLATE = $tasksTemplate
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ third-party hosts on redirects.
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from typing import Dict
|
||||
from urllib.parse import urlparse
|
||||
from typing import Dict
|
||||
|
||||
# GitHub-owned hostnames that should receive the Authorization header.
|
||||
# Includes codeload.github.com because GitHub archive URL downloads
|
||||
@@ -30,25 +30,12 @@ def build_github_request(url: str) -> urllib.request.Request:
|
||||
``Authorization: Bearer <value>`` header when the target hostname is one
|
||||
of the known GitHub-owned domains. Non-GitHub URLs are returned as plain
|
||||
requests so credentials are never leaked to third-party hosts.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``url`` is empty or whitespace-only.
|
||||
ValueError: If ``url`` does not use the ``http`` or ``https`` scheme.
|
||||
ValueError: If ``url`` does not include a hostname.
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise ValueError("url must not be empty")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError(f"url must start with http:// or https://, got: {url!r}")
|
||||
if not parsed.hostname:
|
||||
raise ValueError(f"url must include a hostname, got: {url!r}")
|
||||
github_token = (os.environ.get("GITHUB_TOKEN") or "").strip()
|
||||
gh_token = (os.environ.get("GH_TOKEN") or "").strip()
|
||||
token = github_token or gh_token or None
|
||||
hostname = parsed.hostname.lower()
|
||||
hostname = (urlparse(url).hostname or "").lower()
|
||||
if token and hostname in GITHUB_HOSTS:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Runtime helpers for integration commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from .integration_state import integration_setting, integration_settings
|
||||
|
||||
|
||||
ParseOptions = Callable[[Any, str], dict[str, Any] | None]
|
||||
|
||||
|
||||
def resolve_integration_options(
|
||||
integration: Any,
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
raw_options: str | None,
|
||||
*,
|
||||
parse_options: ParseOptions,
|
||||
) -> tuple[str | None, dict[str, Any] | None]:
|
||||
"""Resolve raw and parsed options for an integration operation."""
|
||||
if raw_options is not None:
|
||||
return raw_options, parse_options(integration, raw_options)
|
||||
|
||||
setting = integration_setting(state, key)
|
||||
stored_raw = setting.get("raw_options")
|
||||
if not isinstance(stored_raw, str):
|
||||
stored_raw = None
|
||||
|
||||
stored_parsed = setting.get("parsed_options")
|
||||
if isinstance(stored_parsed, dict):
|
||||
return stored_raw, stored_parsed or None
|
||||
|
||||
if stored_raw:
|
||||
return stored_raw, parse_options(integration, stored_raw)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def with_integration_setting(
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
integration: Any,
|
||||
*,
|
||||
script_type: str | None = None,
|
||||
raw_options: str | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return integration settings with *key* updated."""
|
||||
settings = integration_settings(state)
|
||||
current = dict(settings.get(key, {}))
|
||||
|
||||
if script_type:
|
||||
current["script"] = script_type
|
||||
if raw_options is not None:
|
||||
current["raw_options"] = raw_options
|
||||
elif "raw_options" in current and not current.get("raw_options"):
|
||||
current.pop("raw_options", None)
|
||||
|
||||
if parsed_options is not None:
|
||||
current["parsed_options"] = parsed_options
|
||||
elif raw_options is not None:
|
||||
current.pop("parsed_options", None)
|
||||
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
|
||||
settings[key] = current
|
||||
return settings
|
||||
|
||||
|
||||
def invoke_separator_for_integration(
|
||||
integration: Any,
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Resolve the invocation separator for stored/default integration state."""
|
||||
if parsed_options is not None:
|
||||
return integration.effective_invoke_separator(parsed_options)
|
||||
|
||||
setting = integration_setting(state, key)
|
||||
stored_separator = setting.get("invoke_separator")
|
||||
if isinstance(stored_separator, str) and stored_separator:
|
||||
return stored_separator
|
||||
|
||||
stored_parsed = setting.get("parsed_options")
|
||||
if isinstance(stored_parsed, dict):
|
||||
return integration.effective_invoke_separator(stored_parsed)
|
||||
|
||||
return integration.effective_invoke_separator(None)
|
||||
@@ -1,161 +0,0 @@
|
||||
"""State helpers for installed AI agent integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
INTEGRATION_JSON = ".specify/integration.json"
|
||||
INTEGRATION_STATE_SCHEMA = 1
|
||||
|
||||
|
||||
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():
|
||||
return None
|
||||
return key.strip()
|
||||
|
||||
|
||||
def dedupe_integration_keys(keys: list[Any]) -> list[str]:
|
||||
"""Return a de-duplicated list of non-empty integration keys."""
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for key in keys:
|
||||
clean = clean_integration_key(key)
|
||||
if clean is None:
|
||||
continue
|
||||
if clean in seen:
|
||||
continue
|
||||
seen.add(clean)
|
||||
deduped.append(clean)
|
||||
return deduped
|
||||
|
||||
|
||||
def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]:
|
||||
"""Return JSON-safe per-integration runtime settings."""
|
||||
if not isinstance(settings, dict):
|
||||
return {}
|
||||
|
||||
normalized: dict[str, dict[str, Any]] = {}
|
||||
for key, value in settings.items():
|
||||
if not isinstance(key, str) or not key.strip() or not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
clean: dict[str, Any] = {}
|
||||
script = value.get("script")
|
||||
if isinstance(script, str) and script.strip():
|
||||
clean["script"] = script.strip()
|
||||
|
||||
raw_options = value.get("raw_options")
|
||||
if isinstance(raw_options, str):
|
||||
clean["raw_options"] = raw_options
|
||||
|
||||
parsed_options = value.get("parsed_options")
|
||||
if isinstance(parsed_options, dict):
|
||||
clean["parsed_options"] = parsed_options
|
||||
|
||||
invoke_separator = value.get("invoke_separator")
|
||||
if isinstance(invoke_separator, str) and invoke_separator.strip():
|
||||
clean["invoke_separator"] = invoke_separator.strip()
|
||||
|
||||
if clean:
|
||||
normalized[key.strip()] = clean
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_integration_state_schema(value: Any) -> int:
|
||||
if isinstance(value, int) and not isinstance(value, bool) and value > INTEGRATION_STATE_SCHEMA:
|
||||
return value
|
||||
return INTEGRATION_STATE_SCHEMA
|
||||
|
||||
|
||||
def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize legacy and multi-install integration metadata."""
|
||||
legacy_key = clean_integration_key(data.get("integration"))
|
||||
default_key = clean_integration_key(data.get("default_integration")) or legacy_key
|
||||
|
||||
installed = data.get("installed_integrations")
|
||||
installed_keys = dedupe_integration_keys(installed if isinstance(installed, list) else [])
|
||||
if not default_key and installed_keys:
|
||||
default_key = installed_keys[0]
|
||||
if default_key and default_key not in installed_keys:
|
||||
installed_keys.insert(0, default_key)
|
||||
|
||||
settings = normalize_integration_settings(data.get("integration_settings"))
|
||||
|
||||
normalized = dict(data)
|
||||
normalized["integration_state_schema"] = _normalized_integration_state_schema(
|
||||
data.get("integration_state_schema")
|
||||
)
|
||||
if default_key:
|
||||
normalized["integration"] = default_key
|
||||
normalized["default_integration"] = default_key
|
||||
else:
|
||||
normalized.pop("integration", None)
|
||||
normalized.pop("default_integration", None)
|
||||
normalized["installed_integrations"] = installed_keys
|
||||
normalized["integration_settings"] = {
|
||||
key: settings[key] for key in installed_keys if key in settings
|
||||
}
|
||||
return normalized
|
||||
|
||||
|
||||
def default_integration_key(state: dict[str, Any]) -> str | None:
|
||||
"""Return the default integration key from normalized state."""
|
||||
key = state.get("default_integration") or state.get("integration")
|
||||
return clean_integration_key(key)
|
||||
|
||||
|
||||
def installed_integration_keys(state: dict[str, Any]) -> list[str]:
|
||||
"""Return installed integration keys from normalized state."""
|
||||
return dedupe_integration_keys(state.get("installed_integrations", []))
|
||||
|
||||
|
||||
def integration_settings(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Return normalized per-integration settings from state."""
|
||||
return normalize_integration_settings(state.get("integration_settings"))
|
||||
|
||||
|
||||
def integration_setting(state: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
"""Return stored runtime settings for *key*."""
|
||||
return dict(integration_settings(state).get(key, {}))
|
||||
|
||||
|
||||
def write_integration_json(
|
||||
project_root: Path,
|
||||
*,
|
||||
version: str,
|
||||
integration_key: str | None,
|
||||
installed_integrations: list[str] | None = None,
|
||||
settings: dict[str, dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Write ``.specify/integration.json`` with legacy-compatible state."""
|
||||
dest = project_root / INTEGRATION_JSON
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
integration_key = clean_integration_key(integration_key)
|
||||
installed = dedupe_integration_keys(installed_integrations or [])
|
||||
if integration_key and integration_key not in installed:
|
||||
installed.insert(0, integration_key)
|
||||
if not integration_key and installed:
|
||||
integration_key = installed[0]
|
||||
|
||||
normalized_settings = normalize_integration_settings(settings or {})
|
||||
normalized_settings = {
|
||||
key: normalized_settings[key] for key in installed if key in normalized_settings
|
||||
}
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"version": version,
|
||||
"integration_state_schema": INTEGRATION_STATE_SCHEMA,
|
||||
"installed_integrations": installed,
|
||||
"integration_settings": normalized_settings,
|
||||
}
|
||||
if integration_key:
|
||||
data["integration"] = integration_key
|
||||
data["default_integration"] = integration_key
|
||||
|
||||
dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -19,4 +19,3 @@ class AuggieIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = ".augment/rules/specify-rules.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -87,14 +87,6 @@ class IntegrationBase(ABC):
|
||||
invoke_separator: str = "."
|
||||
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
|
||||
|
||||
multi_install_safe: bool = False
|
||||
"""Whether this integration is declared safe to install alongside others.
|
||||
|
||||
Safe integrations must use a static, unique agent root, command directory,
|
||||
and context file. Registry tests enforce those invariants for every
|
||||
integration that sets this flag.
|
||||
"""
|
||||
|
||||
# -- Markers for managed context section ------------------------------
|
||||
|
||||
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
||||
|
||||
@@ -53,7 +53,6 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "CLAUDE.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
|
||||
@@ -19,4 +19,3 @@ class CodebuddyIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "CODEBUDDY.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -27,7 +27,6 @@ class CodexIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "AGENTS.md"
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -26,7 +26,6 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
}
|
||||
|
||||
context_file = ".cursor/rules/specify-rules.mdc"
|
||||
multi_install_safe = True
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
|
||||
@@ -19,4 +19,3 @@ class GeminiIntegration(TomlIntegration):
|
||||
"extension": ".toml",
|
||||
}
|
||||
context_file = "GEMINI.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class IflowIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "IFLOW.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class JunieIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = ".junie/AGENTS.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class KilocodeIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = ".kilocode/rules/specify-rules.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -36,7 +36,6 @@ class KimiIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "KIMI.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -48,59 +47,6 @@ def _validate_rel_path(rel: Path, root: Path) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def _manifest_path_label(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None:
|
||||
"""Create a manifest directory without following symlinked parents."""
|
||||
root_resolved = root.resolve()
|
||||
try:
|
||||
rel = directory.relative_to(root)
|
||||
except ValueError:
|
||||
label = _manifest_path_label(root, directory)
|
||||
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
|
||||
|
||||
current = root
|
||||
for part in rel.parts:
|
||||
current = current / part
|
||||
label = _manifest_path_label(root, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked integration manifest directory: {label}")
|
||||
if current.exists():
|
||||
if not current.is_dir():
|
||||
raise ValueError(f"Integration manifest directory path is not a directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root_resolved)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
|
||||
continue
|
||||
current.mkdir()
|
||||
try:
|
||||
current.resolve().relative_to(root_resolved)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
|
||||
|
||||
|
||||
def _ensure_safe_manifest_destination(root: Path, path: Path) -> None:
|
||||
"""Refuse manifest writes that would escape the project or follow symlinks."""
|
||||
root_resolved = root.resolve()
|
||||
_ensure_safe_manifest_directory(root, path.parent)
|
||||
label = _manifest_path_label(root, path)
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"Refusing to overwrite symlinked integration manifest path: {label}")
|
||||
if path.exists():
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Integration manifest path is not a file: {label}")
|
||||
try:
|
||||
path.resolve().relative_to(root_resolved)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Integration manifest path escapes project root: {label}") from None
|
||||
|
||||
|
||||
class IntegrationManifest:
|
||||
"""Tracks files installed by a single integration.
|
||||
|
||||
@@ -271,19 +217,8 @@ class IntegrationManifest:
|
||||
"files": self._files,
|
||||
}
|
||||
path = self.manifest_path
|
||||
content = json.dumps(data, indent=2) + "\n"
|
||||
_ensure_safe_manifest_destination(self.project_root, path)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
temp_path.chmod(0o644)
|
||||
_ensure_safe_manifest_destination(self.project_root, path)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -19,4 +19,3 @@ class QodercliIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "QODER.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class QwenIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "QWEN.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class RooIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = ".roo/rules/specify-rules.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class ShaiIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "SHAI.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -19,4 +19,3 @@ class TabnineIntegration(TomlIntegration):
|
||||
"extension": ".toml",
|
||||
}
|
||||
context_file = "TABNINE.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -27,7 +27,6 @@ class TraeIntegration(SkillsIntegration):
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = ".trae/rules/project_rules.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
|
||||
@@ -19,4 +19,3 @@ class WindsurfIntegration(MarkdownIntegration):
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = ".windsurf/rules/specify-rules.md"
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
"""Shared Spec Kit infrastructure installation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .integrations.base import IntegrationBase
|
||||
from .integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
def load_speckit_manifest(
|
||||
project_path: Path,
|
||||
*,
|
||||
version: str,
|
||||
console: Any | None = None,
|
||||
) -> IntegrationManifest:
|
||||
"""Load the shared infrastructure manifest, preserving existing entries."""
|
||||
manifest_path = project_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest = IntegrationManifest.load("speckit", project_path)
|
||||
manifest.version = version
|
||||
return manifest
|
||||
except (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) as exc:
|
||||
if console is not None:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Could not read shared infrastructure "
|
||||
f"manifest at {manifest_path}: {exc}"
|
||||
)
|
||||
console.print(
|
||||
"A new shared manifest will be created; previously tracked "
|
||||
"shared files may be treated as untracked."
|
||||
)
|
||||
return IntegrationManifest("speckit", project_path, version=version)
|
||||
|
||||
|
||||
def shared_templates_source(
|
||||
*,
|
||||
core_pack: Path | None,
|
||||
repo_root: Path,
|
||||
) -> Path:
|
||||
"""Return the bundled/source shared templates directory."""
|
||||
if core_pack and (core_pack / "templates").is_dir():
|
||||
return core_pack / "templates"
|
||||
return repo_root / "templates"
|
||||
|
||||
|
||||
def shared_scripts_source(
|
||||
*,
|
||||
core_pack: Path | None,
|
||||
repo_root: Path,
|
||||
) -> Path:
|
||||
"""Return the bundled/source shared scripts directory."""
|
||||
if core_pack and (core_pack / "scripts").is_dir():
|
||||
return core_pack / "scripts"
|
||||
return repo_root / "scripts"
|
||||
|
||||
|
||||
def _shared_destination_label(project_path: Path, dest: Path) -> str:
|
||||
try:
|
||||
return dest.relative_to(project_path).as_posix()
|
||||
except ValueError:
|
||||
return str(dest)
|
||||
|
||||
|
||||
def _shared_relative_path(project_path: Path, dest: Path) -> Path:
|
||||
try:
|
||||
rel = dest.relative_to(project_path)
|
||||
except ValueError:
|
||||
label = _shared_destination_label(project_path, dest)
|
||||
raise ValueError(f"Shared infrastructure path escapes project root: {label}") from None
|
||||
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
label = _shared_destination_label(project_path, dest)
|
||||
raise ValueError(f"Shared infrastructure path escapes project root: {label}")
|
||||
return rel
|
||||
|
||||
|
||||
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)
|
||||
current = project_path
|
||||
|
||||
for part in rel.parts:
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
if current.exists():
|
||||
if not current.is_dir():
|
||||
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
|
||||
continue
|
||||
if not create:
|
||||
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
|
||||
current.mkdir()
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
|
||||
|
||||
|
||||
def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None:
|
||||
"""Validate existing directory parents while allowing missing directories."""
|
||||
root = project_path.resolve()
|
||||
rel = _shared_relative_path(project_path, directory)
|
||||
current = project_path
|
||||
|
||||
for part in rel.parts:
|
||||
current = current / part
|
||||
label = _shared_destination_label(project_path, current)
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
|
||||
if not current.exists():
|
||||
continue
|
||||
if not current.is_dir():
|
||||
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
|
||||
try:
|
||||
current.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
|
||||
|
||||
|
||||
def _ensure_safe_shared_destination(
|
||||
project_path: Path,
|
||||
dest: Path,
|
||||
*,
|
||||
parent_must_exist: bool = True,
|
||||
) -> None:
|
||||
"""Refuse shared infra writes that would escape or follow symlinks."""
|
||||
root = project_path.resolve()
|
||||
_shared_relative_path(project_path, dest)
|
||||
if parent_must_exist:
|
||||
_ensure_safe_shared_directory(project_path, dest.parent, create=False)
|
||||
else:
|
||||
_validate_safe_shared_directory(project_path, dest.parent)
|
||||
label = _shared_destination_label(project_path, dest)
|
||||
if dest.is_symlink():
|
||||
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
|
||||
|
||||
if dest.exists():
|
||||
try:
|
||||
dest.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None
|
||||
|
||||
|
||||
def _write_shared_text(project_path: Path, dest: Path, content: str) -> None:
|
||||
_write_shared_bytes(project_path, dest, content.encode("utf-8"))
|
||||
|
||||
|
||||
def _write_shared_bytes(
|
||||
project_path: Path,
|
||||
dest: Path,
|
||||
content: bytes,
|
||||
*,
|
||||
mode: int = 0o644,
|
||||
) -> None:
|
||||
_ensure_safe_shared_destination(project_path, dest)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{dest.name}.", dir=dest.parent)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(content)
|
||||
temp_path.chmod(mode)
|
||||
_ensure_safe_shared_destination(project_path, dest)
|
||||
os.replace(temp_path, dest)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def refresh_shared_templates(
|
||||
project_path: Path,
|
||||
*,
|
||||
version: str,
|
||||
core_pack: Path | None,
|
||||
repo_root: Path,
|
||||
console: Any,
|
||||
invoke_separator: str,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Refresh default-sensitive shared templates without touching scripts."""
|
||||
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if not templates_src.is_dir():
|
||||
return
|
||||
|
||||
manifest = load_speckit_manifest(project_path, version=version, console=console)
|
||||
tracked_files = manifest.files
|
||||
modified = set(manifest.check_modified())
|
||||
skipped_files: list[str] = []
|
||||
planned_updates: list[tuple[Path, str, str]] = []
|
||||
|
||||
dest_templates = project_path / ".specify" / "templates"
|
||||
_ensure_safe_shared_directory(project_path, dest_templates)
|
||||
for src in templates_src.iterdir():
|
||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||
continue
|
||||
|
||||
dst = dest_templates / src.name
|
||||
_ensure_safe_shared_destination(project_path, dst)
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
if dst.exists() and not force:
|
||||
if rel not in tracked_files or rel in modified:
|
||||
skipped_files.append(rel)
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
planned_updates.append((dst, rel, content))
|
||||
|
||||
for dst, rel, content in planned_updates:
|
||||
_write_shared_text(project_path, dst, content)
|
||||
manifest.record_existing(rel)
|
||||
|
||||
manifest.save()
|
||||
|
||||
if skipped_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:"
|
||||
)
|
||||
for rel in skipped_files:
|
||||
console.print(f" {rel}")
|
||||
|
||||
|
||||
def install_shared_infra(
|
||||
project_path: Path,
|
||||
script_type: str,
|
||||
*,
|
||||
version: str,
|
||||
core_pack: Path | None,
|
||||
repo_root: Path,
|
||||
console: Any,
|
||||
force: bool = False,
|
||||
invoke_separator: str = ".",
|
||||
) -> bool:
|
||||
"""Install shared scripts and templates into *project_path*."""
|
||||
manifest = load_speckit_manifest(project_path, version=version, console=console)
|
||||
skipped_files: list[str] = []
|
||||
planned_copies: list[tuple[Path, str, bytes, int]] = []
|
||||
planned_templates: list[tuple[Path, str, str]] = []
|
||||
|
||||
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
|
||||
if scripts_src.is_dir():
|
||||
dest_scripts = project_path / ".specify" / "scripts"
|
||||
_ensure_safe_shared_directory(project_path, dest_scripts)
|
||||
variant_dir = "bash" if script_type == "sh" else "powershell"
|
||||
variant_src = scripts_src / variant_dir
|
||||
if variant_src.is_dir():
|
||||
dest_variant = dest_scripts / variant_dir
|
||||
_ensure_safe_shared_directory(project_path, dest_variant)
|
||||
for src_path in variant_src.rglob("*"):
|
||||
if not src_path.is_file():
|
||||
continue
|
||||
|
||||
rel_path = src_path.relative_to(variant_src)
|
||||
dst_path = dest_variant / rel_path
|
||||
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
|
||||
if dst_path.exists() and not force:
|
||||
skipped_files.append(dst_path.relative_to(project_path).as_posix())
|
||||
continue
|
||||
|
||||
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||
rel = dst_path.relative_to(project_path).as_posix()
|
||||
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():
|
||||
dest_templates = project_path / ".specify" / "templates"
|
||||
_ensure_safe_shared_directory(project_path, dest_templates)
|
||||
for src in templates_src.iterdir():
|
||||
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
|
||||
continue
|
||||
|
||||
dst = dest_templates / src.name
|
||||
_ensure_safe_shared_destination(project_path, dst)
|
||||
if dst.exists() and not force:
|
||||
skipped_files.append(dst.relative_to(project_path).as_posix())
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
rel = dst.relative_to(project_path).as_posix()
|
||||
planned_templates.append((dst, rel, content))
|
||||
|
||||
for dst_path, rel, content, mode in planned_copies:
|
||||
_ensure_safe_shared_directory(project_path, dst_path.parent)
|
||||
_write_shared_bytes(project_path, dst_path, content, mode=mode)
|
||||
manifest.record_existing(rel)
|
||||
|
||||
for dst, rel, content in planned_templates:
|
||||
_write_shared_text(project_path, dst, content)
|
||||
manifest.record_existing(rel)
|
||||
|
||||
if skipped_files:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
|
||||
)
|
||||
for path in skipped_files:
|
||||
console.print(f" {path}")
|
||||
console.print(
|
||||
"To refresh shared infrastructure, run "
|
||||
"[cyan]specify init --here --force[/cyan] or "
|
||||
"[cyan]specify integration upgrade --force[/cyan]."
|
||||
)
|
||||
|
||||
manifest.save()
|
||||
return True
|
||||
@@ -88,7 +88,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read /memory/constitution.md for governance constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Project Setup Verification**:
|
||||
|
||||
@@ -10,8 +10,8 @@ handoffs:
|
||||
prompt: Start the implementation in phases
|
||||
send: true
|
||||
scripts:
|
||||
sh: scripts/bash/setup-tasks.sh --json
|
||||
ps: scripts/powershell/setup-tasks.ps1 -Json
|
||||
sh: scripts/bash/check-prerequisites.sh --json
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json
|
||||
---
|
||||
|
||||
## User Input
|
||||
@@ -58,7 +58,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). 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. **Setup**: 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").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
@@ -76,7 +76,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
|
||||
4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
"""Tests for --integration flag on specify init (CLI-level)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from rich.console import Console
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
class _NoopConsole:
|
||||
def print(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_cli_output(output: str) -> str:
|
||||
output = strip_ansi(output)
|
||||
output = " ".join(output.split())
|
||||
@@ -262,310 +254,6 @@ class TestInitIntegrationFlag:
|
||||
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):
|
||||
"""Invalid shared manifests warn before falling back to a new manifest."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bad-shared-manifest-test"
|
||||
project.mkdir()
|
||||
integrations_dir = project / ".specify" / "integrations"
|
||||
integrations_dir.mkdir(parents=True)
|
||||
manifest_path = integrations_dir / "speckit.manifest.json"
|
||||
manifest_path.write_text("{not json", encoding="utf-8")
|
||||
|
||||
_install_shared_infra(project, "sh")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not read shared infrastructure manifest" in captured.out
|
||||
assert "A new shared manifest will be created" in captured.out
|
||||
|
||||
def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys):
|
||||
"""Non-UTF-8 shared manifests warn before falling back to a new manifest."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bad-shared-manifest-encoding-test"
|
||||
project.mkdir()
|
||||
integrations_dir = project / ".specify" / "integrations"
|
||||
integrations_dir.mkdir(parents=True)
|
||||
manifest_path = integrations_dir / "speckit.manifest.json"
|
||||
manifest_path.write_bytes(b"\xff\xfe\x00")
|
||||
|
||||
_install_shared_infra(project, "sh")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not read shared infrastructure manifest" in captured.out
|
||||
assert "A new shared manifest will be created" in captured.out
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
|
||||
"""Shared script refreshes must not follow destination symlinks."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-script-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
outside = tmp_path / "outside-script.sh"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
os.symlink(outside, scripts_dir / "common.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
|
||||
"""Shared template installs must not follow destination symlinks."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-template-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
outside = tmp_path / "outside-template.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
os.symlink(outside, templates_dir / "plan-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
|
||||
"""Template-only refreshes must not follow destination symlinks."""
|
||||
from specify_cli import _refresh_shared_templates
|
||||
|
||||
project = tmp_path / "symlink-refresh-test"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
outside = tmp_path / "outside-refresh.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
os.symlink(outside, templates_dir / "plan-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
_refresh_shared_templates(project, invoke_separator=".", force=True)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
|
||||
"""Shared infra directory creation must not follow a symlinked .specify."""
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-dir-test"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside-specify"
|
||||
outside.mkdir()
|
||||
os.symlink(outside, project / ".specify")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
|
||||
_install_shared_infra(project, "sh", force=True)
|
||||
|
||||
assert not (outside / "scripts").exists()
|
||||
assert not (outside / "templates").exists()
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path):
|
||||
"""Shared infra manifest saves must not follow destination symlinks."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "symlink-shared-manifest-test"
|
||||
project.mkdir()
|
||||
integrations_dir = project / ".specify" / "integrations"
|
||||
integrations_dir.mkdir(parents=True)
|
||||
|
||||
outside = tmp_path / "outside-manifest.json"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, integrations_dir / "speckit.manifest.json")
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked integration manifest"):
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
|
||||
"""Template refresh validates all destinations before writing any file."""
|
||||
from specify_cli.shared_infra import refresh_shared_templates
|
||||
|
||||
project = tmp_path / "preflight-refresh-test"
|
||||
project.mkdir()
|
||||
templates_dir = project / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8")
|
||||
(templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8")
|
||||
|
||||
existing = templates_dir / "a-template.md"
|
||||
existing.write_text("# old a\n", encoding="utf-8")
|
||||
outside = tmp_path / "outside-z.md"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, templates_dir / "z-template.md")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
refresh_shared_templates(
|
||||
project,
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
invoke_separator=".",
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
|
||||
"""Full shared infra installs validate destinations before writing any file."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "preflight-install-test"
|
||||
project.mkdir()
|
||||
scripts_dir = project / ".specify" / "scripts" / "bash"
|
||||
scripts_dir.mkdir(parents=True)
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
scripts_src = core_pack / "scripts" / "bash"
|
||||
scripts_src.mkdir(parents=True)
|
||||
(scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8")
|
||||
(scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8")
|
||||
|
||||
existing = scripts_dir / "a.sh"
|
||||
existing.write_text("# old a\n", encoding="utf-8")
|
||||
outside = tmp_path / "outside-z.sh"
|
||||
outside.write_text("# outside\n", encoding="utf-8")
|
||||
os.symlink(outside, scripts_dir / "z.sh")
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert existing.read_text(encoding="utf-8") == "# old a\n"
|
||||
assert outside.read_text(encoding="utf-8") == "# outside\n"
|
||||
|
||||
def test_shared_infra_install_supports_nested_script_sources(self, tmp_path):
|
||||
"""Nested script source files create safe destination parents at write time."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "nested-script-install-test"
|
||||
project.mkdir()
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
nested_src = core_pack / "scripts" / "bash" / "nested"
|
||||
nested_src.mkdir(parents=True)
|
||||
(nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8")
|
||||
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh"
|
||||
assert nested_dest.read_text(encoding="utf-8") == "# nested\n"
|
||||
|
||||
def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path):
|
||||
"""Skipped shared infra paths are reported consistently across platforms."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "posix-skip-warning-test"
|
||||
project.mkdir()
|
||||
nested_dest = project / ".specify" / "scripts" / "bash" / "nested"
|
||||
nested_dest.mkdir(parents=True)
|
||||
(nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8")
|
||||
|
||||
templates_dest = project / ".specify" / "templates"
|
||||
templates_dest.mkdir(parents=True)
|
||||
(templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8")
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
nested_src = core_pack / "scripts" / "bash" / "nested"
|
||||
nested_src.mkdir(parents=True)
|
||||
(nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8")
|
||||
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8")
|
||||
|
||||
buffer = io.StringIO()
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=Console(file=buffer, force_terminal=False, width=120),
|
||||
force=False,
|
||||
)
|
||||
|
||||
output = buffer.getvalue()
|
||||
assert ".specify/scripts/bash/nested/deep.sh" in output
|
||||
assert ".specify/templates/plan-template.md" in output
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows")
|
||||
def test_shared_template_writes_are_not_world_writable(self, tmp_path):
|
||||
"""Shared template writes use a safe default mode instead of chmod 666."""
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
project = tmp_path / "template-mode-test"
|
||||
project.mkdir()
|
||||
|
||||
core_pack = tmp_path / "core-pack"
|
||||
templates_src = core_pack / "templates"
|
||||
templates_src.mkdir(parents=True)
|
||||
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
install_shared_infra(
|
||||
project,
|
||||
"sh",
|
||||
version="test",
|
||||
core_pack=core_pack,
|
||||
repo_root=tmp_path / "unused",
|
||||
console=_NoopConsole(),
|
||||
force=True,
|
||||
)
|
||||
|
||||
written = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert written.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
|
||||
"""No skip warning when force=True (all files overwritten)."""
|
||||
from specify_cli import _install_shared_infra
|
||||
@@ -785,32 +473,6 @@ class TestGitExtensionAutoInstall:
|
||||
assert "will be removed" in normalized_output
|
||||
assert "git extension will no longer be enabled by default" in normalized_output
|
||||
|
||||
def test_default_git_auto_enable_emits_notice(self, tmp_path):
|
||||
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "git-default-notice"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
# Check for key message components (notice may have box-drawing chars)
|
||||
assert "git extension is currently enabled by default" in normalized_output
|
||||
assert "v0.10.0" in normalized_output
|
||||
assert "explicit opt-in" in normalized_output
|
||||
assert "specify extension add git" in normalized_output
|
||||
|
||||
def test_git_extension_commands_registered(self, tmp_path):
|
||||
"""Git extension commands are registered with the agent during init."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -1044,119 +706,6 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_primary_integration_commands_require_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
commands = [
|
||||
["integration", "list"],
|
||||
["integration", "install", "codex"],
|
||||
["integration", "use", "codex"],
|
||||
["integration", "uninstall"],
|
||||
["integration", "switch", "codex"],
|
||||
["integration", "upgrade"],
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
failure_context = (
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
|
||||
def test_integration_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad"
|
||||
project.mkdir()
|
||||
(project / ".specify").write_text("not a directory")
|
||||
|
||||
commands = [
|
||||
["integration", "list"],
|
||||
["integration", "use", "codex"],
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_project_scoped_commands_require_specify_directory(self, tmp_path):
|
||||
project = tmp_path / "bad-feature-commands"
|
||||
project.mkdir()
|
||||
(project / ".specify").write_text("not a directory")
|
||||
|
||||
commands = [
|
||||
["preset", "list"],
|
||||
["preset", "add", "demo"],
|
||||
["preset", "remove", "demo"],
|
||||
["preset", "search"],
|
||||
["preset", "resolve", "spec-template"],
|
||||
["preset", "info", "demo"],
|
||||
["preset", "set-priority", "demo", "5"],
|
||||
["preset", "enable", "demo"],
|
||||
["preset", "disable", "demo"],
|
||||
["preset", "catalog", "list"],
|
||||
["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
|
||||
["preset", "catalog", "remove", "demo"],
|
||||
["extension", "list"],
|
||||
["extension", "add", "demo"],
|
||||
["extension", "remove", "demo"],
|
||||
["extension", "search"],
|
||||
["extension", "info", "demo"],
|
||||
["extension", "update", "demo"],
|
||||
["extension", "enable", "demo"],
|
||||
["extension", "disable", "demo"],
|
||||
["extension", "set-priority", "demo", "5"],
|
||||
["extension", "catalog", "list"],
|
||||
["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"],
|
||||
["extension", "catalog", "remove", "demo"],
|
||||
["workflow", "run", "demo"],
|
||||
["workflow", "resume", "demo"],
|
||||
["workflow", "status"],
|
||||
["workflow", "list"],
|
||||
["workflow", "add", "demo"],
|
||||
["workflow", "remove", "demo"],
|
||||
["workflow", "search"],
|
||||
["workflow", "info", "demo"],
|
||||
["workflow", "catalog", "list"],
|
||||
["workflow", "catalog", "add", "https://example.com/catalog.yml"],
|
||||
["workflow", "catalog", "remove", "0"],
|
||||
]
|
||||
|
||||
for command in commands:
|
||||
result = self._invoke(command, project)
|
||||
failure_context = (
|
||||
f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}"
|
||||
)
|
||||
assert result.exit_code == 1, failure_context
|
||||
assert "Not a spec-kit project" in result.output, failure_context
|
||||
|
||||
def test_catalog_config_output_uses_posix_paths(self, tmp_path):
|
||||
project = self._make_project(tmp_path)
|
||||
|
||||
preset_add = self._invoke([
|
||||
"preset", "catalog", "add",
|
||||
"https://example.com/preset-catalog.yml",
|
||||
"--name", "demo-presets",
|
||||
], project)
|
||||
assert preset_add.exit_code == 0, preset_add.output
|
||||
assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output
|
||||
|
||||
preset_list = self._invoke(["preset", "catalog", "list"], project)
|
||||
assert preset_list.exit_code == 0, preset_list.output
|
||||
assert "Config: .specify/preset-catalogs.yml" in preset_list.output
|
||||
|
||||
extension_add = self._invoke([
|
||||
"extension", "catalog", "add",
|
||||
"https://example.com/extension-catalog.yml",
|
||||
"--name", "demo-extensions",
|
||||
], project)
|
||||
assert extension_add.exit_code == 0, extension_add.output
|
||||
assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output
|
||||
|
||||
extension_list = self._invoke(["extension", "catalog", "list"], project)
|
||||
assert extension_list.exit_code == 0, extension_list.output
|
||||
assert "Config: .specify/extension-catalogs.yml" in extension_list.output
|
||||
|
||||
# -- search ------------------------------------------------------------
|
||||
|
||||
def test_search_lists_all(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -274,11 +274,11 @@ class MarkdownIntegrationTests:
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
"setup-plan.sh", "setup-tasks.sh"]:
|
||||
"setup-plan.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"]:
|
||||
"setup-plan.ps1"]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in ["checklist-template.md",
|
||||
|
||||
@@ -387,7 +387,6 @@ class SkillsIntegrationTests:
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/setup-tasks.sh",
|
||||
]
|
||||
else:
|
||||
files += [
|
||||
@@ -395,7 +394,6 @@ class SkillsIntegrationTests:
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
".specify/scripts/powershell/setup-plan.ps1",
|
||||
".specify/scripts/powershell/setup-tasks.ps1",
|
||||
]
|
||||
# Templates
|
||||
files += [
|
||||
|
||||
@@ -516,7 +516,6 @@ class TomlIntegrationTests:
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
@@ -525,7 +524,6 @@ class TomlIntegrationTests:
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
|
||||
@@ -395,7 +395,6 @@ class YamlIntegrationTests:
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
@@ -404,7 +403,6 @@ class YamlIntegrationTests:
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
|
||||
@@ -670,7 +670,7 @@ class TestIntegrationUpgrade:
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code != 0
|
||||
assert "not installed" in result.output
|
||||
assert "not the currently installed integration" in result.output
|
||||
|
||||
def test_upgrade_no_manifest(self, tmp_path):
|
||||
"""Upgrade with missing manifest suggests fresh install."""
|
||||
|
||||
@@ -206,7 +206,6 @@ class TestCopilotIntegration:
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/setup-tasks.sh",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
@@ -266,7 +265,6 @@ class TestCopilotIntegration:
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
".specify/scripts/powershell/setup-plan.ps1",
|
||||
".specify/scripts/powershell/setup-tasks.ps1",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
@@ -616,7 +614,6 @@ class TestCopilotSkillsMode:
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/setup-tasks.sh",
|
||||
# Templates
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
|
||||
@@ -185,16 +185,6 @@ class TestGenericIntegration:
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_implement_loads_constitution_context(self, tmp_path):
|
||||
"""The generated implement command should load constitution governance context."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
implement_file = tmp_path / ".custom" / "cmds" / "speckit.implement.md"
|
||||
assert implement_file.exists()
|
||||
content = implement_file.read_text(encoding="utf-8")
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
@@ -274,7 +264,6 @@ class TestGenericIntegration:
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/setup-tasks.sh",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
@@ -330,7 +319,6 @@ class TestGenericIntegration:
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
".specify/scripts/powershell/setup-plan.ps1",
|
||||
".specify/scripts/powershell/setup-tasks.ps1",
|
||||
".specify/templates/checklist-template.md",
|
||||
".specify/templates/constitution-template.md",
|
||||
".specify/templates/plan-template.md",
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Tests for integration state normalization helpers."""
|
||||
|
||||
import json
|
||||
|
||||
from specify_cli.integration_state import (
|
||||
INTEGRATION_JSON,
|
||||
default_integration_key,
|
||||
integration_setting,
|
||||
normalize_integration_state,
|
||||
write_integration_json,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_integration_state_strips_default_key_without_duplicates():
|
||||
state = normalize_integration_state(
|
||||
{
|
||||
"default_integration": " claude ",
|
||||
"integration": " claude ",
|
||||
"installed_integrations": ["claude"],
|
||||
}
|
||||
)
|
||||
|
||||
assert state["integration"] == "claude"
|
||||
assert state["default_integration"] == "claude"
|
||||
assert state["installed_integrations"] == ["claude"]
|
||||
|
||||
|
||||
def test_normalize_integration_state_strips_legacy_key_fallback():
|
||||
state = normalize_integration_state(
|
||||
{
|
||||
"integration": " codex ",
|
||||
"installed_integrations": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert state["integration"] == "codex"
|
||||
assert state["default_integration"] == "codex"
|
||||
assert state["installed_integrations"] == ["codex"]
|
||||
|
||||
|
||||
def test_normalize_integration_state_preserves_newer_schema():
|
||||
state = normalize_integration_state(
|
||||
{
|
||||
"integration_state_schema": 99,
|
||||
"integration": "claude",
|
||||
"installed_integrations": ["claude"],
|
||||
"future_field": {"keep": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert state["integration_state_schema"] == 99
|
||||
assert state["future_field"] == {"keep": True}
|
||||
|
||||
|
||||
def test_default_integration_key_strips_raw_state_values():
|
||||
assert default_integration_key({"default_integration": " claude "}) == "claude"
|
||||
assert default_integration_key({"integration": " codex "}) == "codex"
|
||||
|
||||
|
||||
def test_integration_settings_strip_invoke_separator():
|
||||
setting = integration_setting(
|
||||
{
|
||||
"integration_settings": {
|
||||
"claude": {
|
||||
"invoke_separator": " - ",
|
||||
}
|
||||
}
|
||||
},
|
||||
"claude",
|
||||
)
|
||||
|
||||
assert setting["invoke_separator"] == "-"
|
||||
|
||||
|
||||
def test_write_integration_json_strips_integration_key(tmp_path):
|
||||
write_integration_json(
|
||||
tmp_path,
|
||||
version="1.2.3",
|
||||
integration_key=" claude ",
|
||||
installed_integrations=["claude"],
|
||||
)
|
||||
|
||||
state = json.loads((tmp_path / INTEGRATION_JSON).read_text(encoding="utf-8"))
|
||||
assert state["integration"] == "claude"
|
||||
assert state["default_integration"] == "claude"
|
||||
assert state["installed_integrations"] == ["claude"]
|
||||
@@ -3,7 +3,6 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
@@ -42,17 +41,6 @@ def _run_in_project(project, args):
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
def _write_invalid_manifest(project, key):
|
||||
manifest = project / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||
manifest.write_bytes(b"\xff\xfe\x00")
|
||||
return manifest
|
||||
|
||||
|
||||
def _integration_list_row_cells(output: str, key: str) -> list[str]:
|
||||
row = next(line for line in output.splitlines() if line.startswith(f"│ {key}"))
|
||||
return [cell.strip() for cell in row.split("│")[1:-1]]
|
||||
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -92,39 +80,6 @@ class TestIntegrationList:
|
||||
assert "claude" in result.output
|
||||
assert "gemini" in result.output
|
||||
|
||||
def test_list_shows_multi_install_safe_status(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "Multi-install" in result.output
|
||||
assert "Safe" in result.output
|
||||
assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
|
||||
assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"
|
||||
|
||||
def test_list_rejects_newer_integration_state_schema(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
data = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
data["integration_state_schema"] = 99
|
||||
int_json.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "schema 99" in normalized
|
||||
assert "only supports schema 1" in normalized
|
||||
|
||||
|
||||
# ── install ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,9 +116,7 @@ class TestIntegrationInstall:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already installed" in result.output
|
||||
normalized = " ".join(result.output.split())
|
||||
assert "specify integration upgrade copilot" in normalized
|
||||
assert "specify integration uninstall copilot" in normalized
|
||||
assert "uninstall" in result.output
|
||||
|
||||
def test_install_different_when_one_exists(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
@@ -174,112 +127,8 @@ class TestIntegrationInstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "Default integration: copilot" in result.output
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_safe_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "installed successfully" in result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert data["default_integration"] == "claude"
|
||||
assert data["integration_state_schema"] == 1
|
||||
assert data["installed_integrations"] == ["claude", "codex"]
|
||||
assert data["integration_settings"]["claude"]["invoke_separator"] == "-"
|
||||
assert data["integration_settings"]["codex"]["invoke_separator"] == "-"
|
||||
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
|
||||
assert before
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
|
||||
assert before <= after
|
||||
|
||||
def test_install_multi_safe_migrates_legacy_state(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
int_json.write_text(json.dumps({
|
||||
"integration": "claude",
|
||||
"version": "0.0.0",
|
||||
}), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert data["default_integration"] == "claude"
|
||||
assert data["installed_integrations"] == ["claude", "codex"]
|
||||
|
||||
def test_install_multi_unsafe_requires_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Installed integrations: copilot" in result.output
|
||||
assert "multi-install safe" in result.output
|
||||
assert "--force" in result.output
|
||||
|
||||
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "copilot"
|
||||
assert data["installed_integrations"] == ["copilot", "claude"]
|
||||
assert "already installed" in result.output
|
||||
assert "uninstall" in result.output
|
||||
|
||||
def test_install_into_bare_project(self, tmp_path):
|
||||
"""Install into a project with .specify/ but no integration."""
|
||||
@@ -397,7 +246,6 @@ class TestIntegrationUninstall:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "preserved" in result.output
|
||||
assert ".claude/skills/speckit-plan/SKILL.md" in result.output
|
||||
|
||||
# Modified file kept
|
||||
assert plan_file.exists()
|
||||
@@ -412,68 +260,7 @@ class TestIntegrationUninstall:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "not installed" in result.output
|
||||
|
||||
def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
_write_invalid_manifest(project, "claude")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "uninstall", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "manifest" in result.output
|
||||
assert "unreadable" in result.output
|
||||
|
||||
def test_uninstall_non_default_preserves_default(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "uninstall", "codex",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
assert data["installed_integrations"] == ["claude"]
|
||||
|
||||
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
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 "not the currently installed" in result.output
|
||||
|
||||
def test_uninstall_preserves_shared_infra(self, tmp_path):
|
||||
"""Shared scripts and templates are not removed by integration uninstall."""
|
||||
@@ -494,135 +281,6 @@ class TestIntegrationUninstall:
|
||||
assert (project / ".specify" / "templates").is_dir()
|
||||
|
||||
|
||||
class TestIntegrationUse:
|
||||
def test_use_installed_integration_sets_default(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "codex"
|
||||
assert data["default_integration"] == "codex"
|
||||
assert data["installed_integrations"] == ["claude", "codex"]
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "codex"
|
||||
assert opts["ai"] == "codex"
|
||||
|
||||
def test_use_requires_installed_integration(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "use", "codex"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "not installed" in result.output
|
||||
|
||||
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"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "gemini",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
def test_use_preserves_modified_templates_unless_forced(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
template.write_text("custom template with /speckit-plan\n", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "gemini",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
|
||||
assert use_gemini.exit_code == 0, use_gemini.output
|
||||
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
|
||||
|
||||
force_use = runner.invoke(app, [
|
||||
"integration", "use", "gemini",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
assert force_use.exit_code == 0, force_use.output
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
updated = template.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in updated
|
||||
assert "custom template" not in updated
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
|
||||
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",
|
||||
"--force",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -648,22 +306,6 @@ class TestIntegrationSwitch:
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown integration" in result.output
|
||||
|
||||
def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
_write_invalid_manifest(project, "claude")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "codex",
|
||||
"--script", "sh",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Could not read integration manifest" in result.output
|
||||
|
||||
def test_switch_same_noop(self, tmp_path):
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
old_cwd = os.getcwd()
|
||||
@@ -673,48 +315,7 @@ class TestIntegrationSwitch:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert "already the default integration" in result.output
|
||||
|
||||
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
template.write_text("# custom shared template\n", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "claude",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "managed shared templates refreshed" in result.output
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "codex",
|
||||
"--integration-options", "--bogus",
|
||||
])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "--integration-options cannot be used" in result.output
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["default_integration"] == "claude"
|
||||
assert "already installed" in result.output
|
||||
|
||||
def test_switch_between_integrations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -921,107 +522,6 @@ class TestIntegrationSwitch:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "claude"
|
||||
|
||||
def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "codex",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "generic",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "codex"
|
||||
assert data["installed_integrations"] == ["codex"]
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "codex"
|
||||
assert opts["ai"] == "codex"
|
||||
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestIntegrationUpgrade:
|
||||
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
_write_invalid_manifest(project, "claude")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade", "claude"])
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "manifest" in result.output
|
||||
assert "unreadable" in result.output
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
int_json = project / ".specify" / "integration.json"
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"
|
||||
|
||||
before_state = json.loads(int_json.read_text(encoding="utf-8"))
|
||||
before_options = json.loads(init_options.read_text(encoding="utf-8"))
|
||||
before_manifest = manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
import specify_cli
|
||||
|
||||
def fail_refresh(*args, **kwargs):
|
||||
raise ValueError("refuse refresh")
|
||||
|
||||
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--force",
|
||||
])
|
||||
|
||||
assert result.exit_code != 0
|
||||
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_non_default_keeps_default_template_invocations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit.plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
install = runner.invoke(app, [
|
||||
"integration", "install", "claude",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "upgrade", "claude",
|
||||
"--script", "sh",
|
||||
"--force",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# ── Full lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"""Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.integrations import (
|
||||
INTEGRATION_REGISTRY,
|
||||
_register,
|
||||
@@ -31,72 +25,6 @@ ALL_INTEGRATION_KEYS = [
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_keys() -> list[str]:
|
||||
return sorted(
|
||||
key
|
||||
for key, integration in INTEGRATION_REGISTRY.items()
|
||||
if integration.multi_install_safe
|
||||
)
|
||||
|
||||
|
||||
def _multi_install_safe_pairs() -> list[tuple[str, str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
return [
|
||||
(safe_keys[left], safe_keys[right])
|
||||
for left in range(len(safe_keys))
|
||||
for right in range(left + 1, len(safe_keys))
|
||||
]
|
||||
|
||||
|
||||
def _posix_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return PurePosixPath(value).as_posix()
|
||||
|
||||
|
||||
def _integration_root_dir(key: str) -> str | None:
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
cfg = integration.config if isinstance(integration.config, dict) else {}
|
||||
return _posix_path(cfg.get("folder"))
|
||||
|
||||
|
||||
def _integration_commands_dir(key: str) -> str | None:
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
cfg = integration.config if isinstance(integration.config, dict) else {}
|
||||
folder = cfg.get("folder")
|
||||
if not folder:
|
||||
return None
|
||||
subdir = cfg.get("commands_subdir", "commands")
|
||||
return (PurePosixPath(folder) / subdir).as_posix()
|
||||
|
||||
|
||||
def _paths_overlap(first: str | None, second: str | None) -> bool:
|
||||
if not first or not second:
|
||||
return False
|
||||
left = PurePosixPath(first)
|
||||
right = PurePosixPath(second)
|
||||
try:
|
||||
left.relative_to(right)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
right.relative_to(left)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _path_is_inside(path: str | None, directory: str | None) -> bool:
|
||||
if not path or not directory:
|
||||
return False
|
||||
try:
|
||||
PurePosixPath(path).relative_to(PurePosixPath(directory))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_registry_is_dict(self):
|
||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||
@@ -157,134 +85,3 @@ class TestRegistrarKeyAlignment:
|
||||
"""The old 'cursor' shorthand must not appear in AGENT_CONFIGS."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
assert "cursor" not in CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
|
||||
class TestMultiInstallSafeContracts:
|
||||
"""Declared safe integrations must stay isolated from each other."""
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
|
||||
assert _integration_root_dir(key), (
|
||||
f"{key} is declared multi-install safe but has no static root directory"
|
||||
)
|
||||
assert _integration_commands_dir(key), (
|
||||
f"{key} is declared multi-install safe but has no static commands directory"
|
||||
)
|
||||
assert integration.context_file, (
|
||||
f"{key} is declared multi-install safe but has no context file"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_agent_roots(self, first, second):
|
||||
assert not _paths_overlap(_integration_root_dir(first), _integration_root_dir(second)), (
|
||||
f"{first} and {second} are declared multi-install safe but have "
|
||||
f"overlapping agent roots {_integration_root_dir(first)!r} and "
|
||||
f"{_integration_root_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_command_dirs(self, first, second):
|
||||
assert not _paths_overlap(_integration_commands_dir(first), _integration_commands_dir(second)), (
|
||||
f"{first} and {second} are declared multi-install safe but have "
|
||||
f"overlapping command directories {_integration_commands_dir(first)!r} and "
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_context_files(self, first, second):
|
||||
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
|
||||
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
|
||||
|
||||
assert first_context != second_context, (
|
||||
f"{first} and {second} are declared multi-install safe but share "
|
||||
f"context file {first_context!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second):
|
||||
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
|
||||
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
|
||||
|
||||
assert not _path_is_inside(first_context, _integration_root_dir(second)), (
|
||||
f"{first} context file {first_context!r} lives under {second} "
|
||||
f"agent root {_integration_root_dir(second)!r}"
|
||||
)
|
||||
assert not _path_is_inside(second_context, _integration_root_dir(first)), (
|
||||
f"{second} context file {second_context!r} lives under {first} "
|
||||
f"agent root {_integration_root_dir(first)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second):
|
||||
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
|
||||
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
|
||||
|
||||
assert not _path_is_inside(first_context, _integration_commands_dir(second)), (
|
||||
f"{first} context file {first_context!r} lives under {second} "
|
||||
f"commands directory {_integration_commands_dir(second)!r}"
|
||||
)
|
||||
assert not _path_is_inside(second_context, _integration_commands_dir(first)), (
|
||||
f"{second} context file {second_context!r} lives under {first} "
|
||||
f"commands directory {_integration_commands_dir(first)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
tmp_path,
|
||||
first,
|
||||
second,
|
||||
):
|
||||
for initial, additional in ((first, second), (second, first)):
|
||||
project_root = tmp_path / f"project-{initial}-{additional}"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
install_result = runner.invoke(
|
||||
app,
|
||||
["integration", "install", additional, "--script", "sh"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
initial_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
additional_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
initial_files = set(initial_manifest.get("files", {}))
|
||||
additional_files = set(additional_manifest.get("files", {}))
|
||||
|
||||
assert initial_files.isdisjoint(additional_files), (
|
||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(initial_files & additional_files)}"
|
||||
)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Tests for GitHub-authenticated HTTP request helpers."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._github_http import (
|
||||
build_github_request,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildGitHubRequest:
|
||||
"""Tests for build_github_request() URL validation and auth handling."""
|
||||
|
||||
# --- URL Validation Tests ---
|
||||
|
||||
def test_empty_url_raises_value_error(self):
|
||||
"""build_github_request() must reject an empty string URL."""
|
||||
with pytest.raises(ValueError, match="url must not be empty"):
|
||||
build_github_request("")
|
||||
|
||||
def test_whitespace_url_raises_value_error(self):
|
||||
"""build_github_request() must reject a whitespace-only URL."""
|
||||
with pytest.raises(ValueError, match="url must not be empty"):
|
||||
build_github_request(" ")
|
||||
|
||||
def test_non_http_url_raises_value_error(self):
|
||||
"""build_github_request() must reject URLs without http/https scheme."""
|
||||
with pytest.raises(ValueError, match="url must start with http"):
|
||||
build_github_request("not-a-url")
|
||||
|
||||
def test_ftp_url_raises_value_error(self):
|
||||
"""build_github_request() must reject ftp:// URLs."""
|
||||
with pytest.raises(ValueError, match="url must start with http"):
|
||||
build_github_request("ftp://github.com/file.zip")
|
||||
|
||||
# --- Valid URL Tests ---
|
||||
|
||||
def test_valid_https_url_returns_request(self):
|
||||
"""build_github_request() must return a Request for a valid https URL."""
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.full_url == "https://github.com/github/spec-kit"
|
||||
|
||||
def test_valid_http_url_returns_request(self):
|
||||
"""build_github_request() must accept http:// URLs."""
|
||||
req = build_github_request("http://example.com/file")
|
||||
assert req.full_url == "http://example.com/file"
|
||||
|
||||
# --- Auth Header Tests ---
|
||||
|
||||
def test_github_token_added_for_github_host(self):
|
||||
"""Authorization header is set when GITHUB_TOKEN is present."""
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}):
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.get_header("Authorization") == "Bearer test-token"
|
||||
|
||||
def test_gh_token_used_as_fallback(self):
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}):
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.get_header("Authorization") == "Bearer fallback-token"
|
||||
|
||||
def test_no_auth_header_for_non_github_host(self):
|
||||
"""Authorization header must NOT be set for non-GitHub URLs."""
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}):
|
||||
req = build_github_request("https://example.com/file")
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_no_auth_header_when_no_token(self):
|
||||
"""No Authorization header when no token is set in environment."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_missing_hostname_raises_value_error(self):
|
||||
"""build_github_request() must reject URLs with valid scheme but no hostname."""
|
||||
with pytest.raises(ValueError, match="url must include a hostname"):
|
||||
build_github_request("http://")
|
||||
@@ -14,7 +14,6 @@ import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
@@ -1922,10 +1921,6 @@ class TestPresetCatalogMultiCatalog:
|
||||
|
||||
|
||||
SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
SELF_TEST_WRAP_WARNING = (
|
||||
r"Cannot compose command 'speckit\.wrap-test': no base layer\. "
|
||||
r"Stale command files may remain\."
|
||||
)
|
||||
|
||||
CORE_TEMPLATE_NAMES = [
|
||||
"spec-template",
|
||||
@@ -1936,18 +1931,6 @@ CORE_TEMPLATE_NAMES = [
|
||||
]
|
||||
|
||||
|
||||
def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest:
|
||||
"""Install self-test while filtering its intentionally missing wrap base."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=SELF_TEST_WRAP_WARNING,
|
||||
category=UserWarning,
|
||||
module=r"specify_cli\.presets",
|
||||
)
|
||||
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
|
||||
|
||||
|
||||
class TestSelfTestPreset:
|
||||
"""Tests using the self-test preset that ships with the repo."""
|
||||
|
||||
@@ -1988,7 +1971,7 @@ class TestSelfTestPreset:
|
||||
def test_install_self_test_preset(self, project_dir):
|
||||
"""Test installing the self-test preset from its directory."""
|
||||
manager = PresetManager(project_dir)
|
||||
manifest = install_self_test_preset(manager)
|
||||
manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
assert manifest.id == "self-test"
|
||||
assert manager.registry.is_installed("self-test")
|
||||
|
||||
@@ -2001,7 +1984,7 @@ class TestSelfTestPreset:
|
||||
|
||||
# Install self-test preset
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
# Every core template should now resolve from the preset
|
||||
resolver = PresetResolver(project_dir)
|
||||
@@ -2020,7 +2003,7 @@ class TestSelfTestPreset:
|
||||
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
for name in CORE_TEMPLATE_NAMES:
|
||||
@@ -2037,7 +2020,7 @@ class TestSelfTestPreset:
|
||||
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
manager.remove("self-test")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
@@ -2073,7 +2056,7 @@ class TestSelfTestPreset:
|
||||
claude_dir.mkdir(parents=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
# Check the skill was registered
|
||||
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
|
||||
@@ -2089,7 +2072,7 @@ class TestSelfTestPreset:
|
||||
gemini_dir.mkdir(parents=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
# Check the command was registered in TOML format
|
||||
cmd_file = gemini_dir / "speckit.specify.toml"
|
||||
@@ -2104,7 +2087,7 @@ class TestSelfTestPreset:
|
||||
claude_dir.mkdir(parents=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
|
||||
assert cmd_file.exists()
|
||||
@@ -2115,7 +2098,7 @@ class TestSelfTestPreset:
|
||||
def test_self_test_no_commands_without_agent_dirs(self, project_dir):
|
||||
"""Test that no commands are registered when no agent dirs exist."""
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata["registered_commands"] == {}
|
||||
@@ -2264,7 +2247,8 @@ class TestPresetSkills:
|
||||
|
||||
# Install self-test preset (has a command override for speckit.specify)
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
@@ -2283,7 +2267,8 @@ class TestPresetSkills:
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
@@ -2315,7 +2300,8 @@ class TestPresetSkills:
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
file_content = skill_file.read_text()
|
||||
@@ -2335,7 +2321,8 @@ class TestPresetSkills:
|
||||
(core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
# Verify preset content is in the skill
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
@@ -2371,7 +2358,8 @@ class TestPresetSkills:
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
manager.remove("self-test")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
@@ -2387,7 +2375,8 @@ class TestPresetSkills:
|
||||
(skills_dir / "speckit-specify").write_text("not-a-directory")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
assert (skills_dir / "speckit-specify").is_file()
|
||||
metadata = manager.registry.get("self-test")
|
||||
@@ -2399,7 +2388,8 @@ class TestPresetSkills:
|
||||
# Don't create skills dir — simulate --ai-skills never created them
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata.get("registered_skills", []) == []
|
||||
@@ -2600,7 +2590,8 @@ class TestPresetSkills:
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
@@ -2620,7 +2611,8 @@ class TestPresetSkills:
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
@@ -2799,7 +2791,8 @@ class TestPresetSkills:
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "untouched" in skill_content
|
||||
@@ -3458,7 +3451,7 @@ class TestWrapStrategy:
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
written = (skill_subdir / "SKILL.md").read_text()
|
||||
assert "{CORE_TEMPLATE}" not in written
|
||||
@@ -3510,7 +3503,7 @@ class TestWrapStrategy:
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
|
||||
|
||||
written = (skill_subdir / "SKILL.md").read_text()
|
||||
# {SCRIPT} should have been resolved (not left as a literal placeholder)
|
||||
|
||||
@@ -1,584 +0,0 @@
|
||||
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation."""
|
||||
|
||||
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"
|
||||
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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(SETUP_TASKS_SH, d / "setup-tasks.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(SETUP_TASKS_PS, d / "setup-tasks.ps1")
|
||||
|
||||
|
||||
def _install_core_tasks_template(repo: Path) -> None:
|
||||
"""Copy the real tasks-template.md into the core template location."""
|
||||
tdir = repo / ".specify" / "templates"
|
||||
tdir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
|
||||
|
||||
|
||||
def _minimal_feature(repo: Path) -> Path:
|
||||
"""
|
||||
Create a numbered branch-style feature directory with spec.md and plan.md
|
||||
so all prerequisite checks in setup-tasks pass.
|
||||
Returns the feature directory path.
|
||||
"""
|
||||
feat = repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
return feat
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""
|
||||
Return os.environ with all SPECIFY_* variables stripped so the scripts
|
||||
rely purely on git branch + feature.json state set up by each fixture.
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def tasks_repo(tmp_path: Path) -> Path:
|
||||
"""
|
||||
A minimal repo with:
|
||||
- git initialised on a numbered branch (001-my-feature)
|
||||
- core tasks-template.md in place
|
||||
- both bash and PowerShell scripts installed
|
||||
"""
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
|
||||
# Switch to a numbered branch so branch validation passes without feature.json
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
(repo / ".specify").mkdir()
|
||||
_install_core_tasks_template(repo)
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BASH TESTS
|
||||
# ===========================================================================
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When the core tasks-template.md is present and all prerequisites are met,
|
||||
setup-tasks.sh --json should exit 0 and return an absolute, existing
|
||||
TASKS_TEMPLATE path pointing to the core template.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl.name == "tasks-template.md"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
setup-tasks.sh --json must return the override path, not the core path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# Create the override
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
override_file = overrides_dir / "tasks-template.md"
|
||||
override_file.write_text("# override tasks template\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
# The resolved path must be inside the overrides directory
|
||||
assert "overrides" in tasks_tmpl.parts, (
|
||||
f"Expected override path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When an extension template exists, setup-tasks.sh --json must resolve
|
||||
tasks-template.md from the extension before falling back to the core path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
|
||||
)
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
extension_file = extension_dir / "tasks-template.md"
|
||||
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl == extension_file.resolve(), (
|
||||
f"Expected extension path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When both preset and extension templates exist, setup-tasks.sh --json must
|
||||
resolve the preset path because presets outrank extensions.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
|
||||
)
|
||||
extension_dir.mkdir(parents=True, exist_ok=True)
|
||||
extension_file = extension_dir / "tasks-template.md"
|
||||
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
|
||||
|
||||
# FIX: real preset layout is .specify/presets/<id>/templates/<name>.md
|
||||
preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates"
|
||||
preset_dir.mkdir(parents=True, exist_ok=True)
|
||||
preset_file = preset_dir / "tasks-template.md"
|
||||
preset_file.write_text("# preset tasks template\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl == preset_file.resolve(), (
|
||||
f"Expected preset path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When two presets both provide tasks-template.md, the one listed first in
|
||||
.specify/presets/.registry wins.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# resolve_template reads .specify/presets/.registry as a JSON object with a
|
||||
# "presets" map where each entry has a numeric "priority" (lower = higher
|
||||
# precedence). Create two presets; priority-1-preset wins over priority-2-preset.
|
||||
high_priority_dir = (
|
||||
tasks_repo / ".specify" / "presets" / "priority-1-preset" / "templates"
|
||||
)
|
||||
high_priority_dir.mkdir(parents=True, exist_ok=True)
|
||||
high_priority_file = high_priority_dir / "tasks-template.md"
|
||||
high_priority_file.write_text("# high priority preset tasks template\n", encoding="utf-8")
|
||||
low_priority_dir = (
|
||||
tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates"
|
||||
)
|
||||
|
||||
low_priority_dir.mkdir(parents=True, exist_ok=True)
|
||||
low_priority_file = low_priority_dir / "tasks-template.md"
|
||||
low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8")
|
||||
|
||||
# Write .registry JSON using the correct schema: object with "presets" map,
|
||||
# each preset has a numeric "priority" (lower number = higher precedence).
|
||||
registry_json = tasks_repo / ".specify" / "presets" / ".registry"
|
||||
registry_json.write_text(
|
||||
json.dumps({
|
||||
"presets": {
|
||||
"priority-1-preset": {"priority": 1, "enabled": True},
|
||||
"priority-2-preset": {"priority": 2, "enabled": True},
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl == high_priority_file.resolve(), (
|
||||
f"Expected high-priority preset path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When tasks-template.md is absent from all locations, setup-tasks.sh must
|
||||
exit non-zero and print a helpful ERROR message to stderr.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# Remove the core template so no template exists anywhere
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
core.unlink()
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "ERROR" in result.stderr
|
||||
assert "tasks-template" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch, setup-tasks.sh must succeed when feature.json
|
||||
pins a valid FEATURE_DIR (branch validation should be skipped).
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch with no feature.json, setup-tasks.sh must fail
|
||||
and report that we are not on a feature branch.
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_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_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When the core tasks-template.md is present and all prerequisites are met,
|
||||
setup-tasks.ps1 -Json should exit 0 and return an absolute, existing
|
||||
TASKS_TEMPLATE path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert tasks_tmpl.name == "tasks-template.md"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
setup-tasks.ps1 -Json must return the override path, not the core path.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
override_file = overrides_dir / "tasks-template.md"
|
||||
override_file.write_text("# override tasks template\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
|
||||
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
|
||||
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
|
||||
assert "overrides" in tasks_tmpl.parts, (
|
||||
f"Expected override path but got: {tasks_tmpl}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
"""
|
||||
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
|
||||
exit non-zero and write a helpful error to stderr.
|
||||
"""
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
core.unlink()
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch, setup-tasks.ps1 must succeed when feature.json
|
||||
pins a valid FEATURE_DIR (branch validation should be skipped).
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
(tasks_repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": "specs/001-my-feature"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
"""
|
||||
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail
|
||||
and report that we are not on a feature branch.
|
||||
"""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "feature/custom-branch"],
|
||||
cwd=tasks_repo,
|
||||
check=True,
|
||||
)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
Reference in New Issue
Block a user