Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
4fafe71bf5 chore: bump version to 0.4.5 2026-04-02 17:35:32 +00:00
137 changed files with 1405 additions and 20342 deletions

View File

@@ -26,7 +26,6 @@ concurrency:
jobs:
# Build job
build:
if: github.repository == 'github/spec-kit'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -48,7 +47,7 @@ jobs:
docfx docfx.json
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
@@ -57,7 +56,6 @@ jobs:
# Deploy job
deploy:
if: github.repository == 'github/spec-kit'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

View File

@@ -6,7 +6,6 @@ on:
workflow_dispatch: # Allow manual triggering
permissions:
actions: write
issues: write
pull-requests: write

View File

@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- name: Set up Python
uses: actions/setup-python@v6
@@ -27,17 +27,16 @@ jobs:
run: uvx ruff check src/
pytest:
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
@@ -47,9 +46,5 @@ jobs:
- name: Install dependencies
run: uv sync --extra test
# On windows-latest, bash tests auto-skip unless Git-for-Windows
# bash (MSYS2/MINGW) is detected. The WSL launcher is rejected
# because it cannot handle native Windows paths in test fixtures.
# See tests/conftest.py::_has_working_bash() for details.
- name: Run tests
run: uv run pytest

615
AGENTS.md
View File

@@ -10,282 +10,276 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
---
## Integration Architecture
## Adding New Agent Support
Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations/<key>/`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`.
This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow.
```
src/specify_cli/integrations/
├── __init__.py # INTEGRATION_REGISTRY + _register_builtins()
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
├── manifest.py # IntegrationManifest (file tracking)
├── claude/ # Example: SkillsIntegration subclass
│ ├── __init__.py # ClaudeIntegration class
│ └── scripts/ # Thin wrapper scripts
│ ├── update-context.sh
│ └── update-context.ps1
├── gemini/ # Example: TomlIntegration subclass
│ ├── __init__.py
│ └── scripts/
├── windsurf/ # Example: MarkdownIntegration subclass
│ ├── __init__.py
│ └── scripts/
├── copilot/ # Example: IntegrationBase subclass (custom setup)
│ ├── __init__.py
│ └── scripts/
└── ... # One subpackage per supported agent
```
### Overview
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.
Specify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for:
---
- **Command file formats** (Markdown, TOML, etc.)
- **Directory structures** (`.claude/commands/`, `.windsurf/workflows/`, etc.)
- **Command invocation patterns** (slash commands, CLI tools, etc.)
- **Argument passing conventions** (`$ARGUMENTS`, `{{args}}`, etc.)
## Adding a New Integration
### Current Supported Agents
### 1. Choose a base class
| Agent | Directory | Format | CLI Tool | Description |
| -------------------------- | ---------------------- | -------- | --------------- | --------------------------- |
| **Claude Code** | `.claude/commands/` | Markdown | `claude` | Anthropic's Claude Code CLI |
| **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI |
| **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code |
| **Cursor** | `.cursor/commands/` | Markdown | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) |
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) |
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains |
| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI |
| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE |
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI |
| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI |
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent |
| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) |
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) |
| **Mistral Vibe** | `.vibe/prompts/` | Markdown | `vibe` | Mistral Vibe CLI |
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
| Your agent needs… | Subclass |
|---|---|
| Standard markdown commands (`.md`) | `MarkdownIntegration` |
| TOML-format commands (`.toml`) | `TomlIntegration` |
| YAML recipe files (`.yaml`) | `YamlIntegration` |
| Skill directories (`speckit-<name>/SKILL.md`) | `SkillsIntegration` |
| Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly |
### Step-by-Step Integration Guide
Most agents only need `MarkdownIntegration` — a minimal subclass with zero method overrides.
Follow these steps to add a new agent (using a hypothetical new agent as an example):
### 2. Create the subpackage
#### 1. Add to AGENT_CONFIG
Create `src/specify_cli/integrations/<package_dir>/__init__.py`, where `<package_dir>` is the Python-safe directory name derived from `<key>`: use the key as-is when it contains no hyphens (e.g., key `"gemini"``gemini/`), or replace hyphens with underscores when it does (e.g., key `"kiro-cli"``kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value, since that is what the CLI and registry use. For CLI-based integrations (`requires_cli: True`), the `key` should match the actual CLI tool name (the executable users install and run) so CLI checks can resolve it correctly. For IDE-based integrations (`requires_cli: False`), use the canonical integration identifier instead.
**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version.
**Minimal example — Markdown agent (Windsurf):**
Add the new agent to the `AGENT_CONFIG` dictionary in `src/specify_cli/__init__.py`. This is the **single source of truth** for all agent metadata:
```python
"""Windsurf IDE integration."""
from ..base import MarkdownIntegration
class WindsurfIntegration(MarkdownIntegration):
key = "windsurf"
config = {
"name": "Windsurf",
"folder": ".windsurf/",
"commands_subdir": "workflows",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".windsurf/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
AGENT_CONFIG = {
# ... existing agents ...
"new-agent-cli": { # Use the ACTUAL CLI tool name (what users type in terminal)
"name": "New Agent Display Name",
"folder": ".newagent/", # Directory for agent files
"commands_subdir": "commands", # Subdirectory name for command files (default: "commands")
"install_url": "https://example.com/install", # URL for installation docs (or None if IDE-based)
"requires_cli": True, # True if CLI tool required, False for IDE-based agents
},
}
```
**TOML agent (Gemini):**
**Key Design Principle**: The dictionary key should match the actual executable name that users install. For example:
- ✅ Use `"cursor-agent"` because the CLI tool is literally called `cursor-agent`
- ❌ Don't use `"cursor"` as a shortcut if the tool is `cursor-agent`
This eliminates the need for special-case mappings throughout the codebase.
**Field Explanations**:
- `name`: Human-readable display name shown to users
- `folder`: Directory where agent-specific files are stored (relative to project root)
- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`)
- Most agents use `"commands"` (e.g., `.claude/commands/`)
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular)
- This field enables `--ai-skills` to locate command templates correctly for skill generation
- `install_url`: Installation documentation URL (set to `None` for IDE-based agents)
- `requires_cli`: Whether the agent requires a CLI tool check during initialization
#### 2. Update CLI Help Text
Update the `--ai` parameter help text in the `init()` command to include the new agent:
```python
"""Gemini CLI integration."""
from ..base import TomlIntegration
class GeminiIntegration(TomlIntegration):
key = "gemini"
config = {
"name": "Gemini CLI",
"folder": ".gemini/",
"commands_subdir": "commands",
"install_url": "https://github.com/google-gemini/gemini-cli",
"requires_cli": True,
}
registrar_config = {
"dir": ".gemini/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"
ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, new-agent-cli, or kiro-cli"),
```
**Skills agent (Codex):**
Also update any function docstrings, examples, and error messages that list available agents.
```python
"""Codex CLI integration — skills-based agent."""
#### 3. Update README Documentation
from __future__ import annotations
Update the **Supported AI Agents** section in `README.md` to include the new agent:
from ..base import IntegrationOption, SkillsIntegration
- Add the new agent to the table with appropriate support level (Full/Partial)
- Include the agent's official website link
- Add any relevant notes about the agent's implementation
- Ensure the table formatting remains aligned and consistent
#### 4. Update Release Package Script
class CodexIntegration(SkillsIntegration):
key = "codex"
config = {
"name": "Codex CLI",
"folder": ".agents/",
"commands_subdir": "skills",
"install_url": "https://github.com/openai/codex",
"requires_cli": True,
}
registrar_config = {
"dir": ".agents/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
Modify `.github/workflows/scripts/create-release-packages.sh`:
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Codex)",
),
]
```
#### Required fields
| Field | Location | Purpose |
|---|---|---|
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
### 3. Register it
In `src/specify_cli/integrations/__init__.py`, add one import and one `_register()` call inside `_register_builtins()`. Both lists are alphabetical:
```python
def _register_builtins() -> None:
# -- Imports (alphabetical) -------------------------------------------
from .claude import ClaudeIntegration
# ...
from .newagent import NewAgentIntegration # ← add import
# ...
# -- Registration (alphabetical) --------------------------------------
_register(ClaudeIntegration())
# ...
_register(NewAgentIntegration()) # ← add registration
# ...
```
### 4. Add scripts
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.
> **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`:**
##### Add to ALL_AGENTS array
```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>
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli)
```
**`update-context.ps1`:**
##### Add case statement for directory structure
```bash
case $agent in
# ... existing cases ...
windsurf)
mkdir -p "$base_dir/.windsurf/workflows"
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
esac
```
#### 4. Update GitHub Release Script
Modify `.github/workflows/scripts/create-github-release.sh` to include the new agent's packages:
```bash
gh release create "$VERSION" \
# ... existing packages ...
.genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \
.genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \
# Add new agent packages here
```
#### 5. Update Agent Context Scripts
##### Bash script (`scripts/bash/update-agent-context.sh`)
Add file variable:
```bash
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
```
Add to case statement:
```bash
case "$AGENT_TYPE" in
# ... existing cases ...
windsurf) update_agent_file "$WINDSURF_FILE" "Windsurf" ;;
"")
# ... existing checks ...
[ -f "$WINDSURF_FILE" ] && update_agent_file "$WINDSURF_FILE" "Windsurf";
# Update default creation condition
;;
esac
```
##### PowerShell script (`scripts/powershell/update-agent-context.ps1`)
Add file variable:
```powershell
# update-context.ps1 — <Agent Name> integration: create/update <context_file>
$ErrorActionPreference = 'Stop'
$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md'
```
$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
Add to switch statement:
```powershell
switch ($AgentType) {
# ... existing cases ...
'windsurf' { Update-AgentFile $windsurfFile 'Windsurf' }
'' {
foreach ($pair in @(
# ... existing pairs ...
@{file=$windsurfFile; name='Windsurf'}
)) {
if (Test-Path $pair.file) { Update-AgentFile $pair.file $pair.name }
}
# Update default creation condition
}
}
```
#### 6. Update CLI Tool Checks (Optional)
For agents that require CLI tools, add checks in the `check()` command and agent validation:
```python
# In check() command
tracker.add("windsurf", "Windsurf IDE (optional)")
windsurf_ok = check_tool_for_tracker("windsurf", "https://windsurf.com/", tracker)
# In init validation (only if CLI tool required)
elif selected_ai == "windsurf":
if not check_tool("windsurf", "Install from: https://windsurf.com/"):
console.print("[red]Error:[/red] Windsurf CLI is required for Windsurf projects")
agent_tool_missing = True
```
**Note**: CLI tool checks are now handled automatically based on the `requires_cli` field in AGENT_CONFIG. No additional code changes needed in the `check()` or `init()` commands - they automatically loop through AGENT_CONFIG and check tools as needed.
## Important Design Decisions
### Using Actual CLI Tool Names as Keys
**CRITICAL**: When adding a new agent to AGENT_CONFIG, always use the **actual executable name** as the dictionary key, not a shortened or convenient version.
**Why this matters:**
- The `check_tool()` function uses `shutil.which(tool)` to find executables in the system PATH
- If the key doesn't match the actual CLI tool name, you'll need special-case mappings throughout the codebase
- This creates unnecessary complexity and maintenance burden
**Example - The Cursor Lesson:**
**Wrong approach** (requires special-case mapping):
```python
AGENT_CONFIG = {
"cursor": { # Shorthand that doesn't match the actual tool
"name": "Cursor",
# ...
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType <key>
# Then you need special cases everywhere:
cli_tool = agent_key
if agent_key == "cursor":
cli_tool = "cursor-agent" # Map to the real tool name
```
Replace `<key>` with your integration key and `<Agent Name>` / `<context_file>` with the appropriate values.
**Correct approach** (no mapping needed):
You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key:
```python
AGENT_CONFIG = {
"cursor-agent": { # Matches the actual executable name
"name": "Cursor",
# ...
}
}
- **`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
```bash
# Install into a test project
specify init my-project --integration <key>
# Verify files were created in the commands directory configured by
# config["folder"] + config["commands_subdir"] (for example, .windsurf/workflows/)
ls -R my-project/.windsurf/workflows/
# Uninstall cleanly
cd my-project && specify integration uninstall <key>
# No special cases needed - just use agent_key directly!
```
Each integration also has a dedicated test file at `tests/integrations/test_integration_<key>.py`. Note that hyphens in the key are replaced with underscores in the filename (e.g., key `cursor-agent``test_integration_cursor_agent.py`, key `kiro-cli``test_integration_kiro_cli.py`). Run it with:
**Benefits of this approach:**
```bash
pytest tests/integrations/test_integration_<key_with_underscores>.py -v
```
- Eliminates special-case logic scattered throughout the codebase
- Makes the code more maintainable and easier to understand
- Reduces the chance of bugs when adding new agents
- Tool checking "just works" without additional mappings
### 6. Optional overrides
The base classes handle most work automatically. Override only when the agent deviates from standard patterns:
| Override | When to use | Example |
|---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
**Example — Copilot (fully custom `setup`):**
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
### 7. Update Devcontainer files (Optional)
#### 7. Update Devcontainer files (Optional)
For agents that have VS Code extensions or require CLI installation, update the devcontainer configuration files:
#### VS Code Extension-based Agents
##### VS Code Extension-based Agents
For agents available as VS Code extensions, add them to `.devcontainer/devcontainer.json`:
```jsonc
```json
{
"customizations": {
"vscode": {
"extensions": [
// ... existing extensions ...
// [New Agent Name]
"[New Agent Extension ID]"
]
}
@@ -293,7 +287,7 @@ For agents available as VS Code extensions, add them to `.devcontainer/devcontai
}
```
#### CLI-based Agents
##### CLI-based Agents
For agents that require CLI tools, add installation commands to `.devcontainer/post-create.sh`:
@@ -303,16 +297,62 @@ For agents that require CLI tools, add installation commands to `.devcontainer/p
# Existing installations...
echo -e "\n🤖 Installing [New Agent Name] CLI..."
# run_command "npm install -g [agent-cli-package]@latest"
# run_command "npm install -g [agent-cli-package]@latest" # Example for node-based CLI
# or other installation instructions (must be non-interactive and compatible with Linux Debian "Trixie" or later)...
echo "✅ Done"
```
---
**Quick Tips:**
- **Extension-based agents**: Add to the `extensions` array in `devcontainer.json`
- **CLI-based agents**: Add installation scripts to `post-create.sh`
- **Hybrid agents**: May require both extension and CLI installation
- **Test thoroughly**: Ensure installations work in the devcontainer environment
## Agent Categories
### CLI-Based Agents
Require a command-line tool to be installed:
- **Claude Code**: `claude` CLI
- **Gemini CLI**: `gemini` CLI
- **Qwen Code**: `qwen` CLI
- **opencode**: `opencode` CLI
- **Codex CLI**: `codex` CLI (requires `--ai-skills`)
- **Junie**: `junie` CLI
- **Auggie CLI**: `auggie` CLI
- **CodeBuddy CLI**: `codebuddy` CLI
- **Qoder CLI**: `qodercli` CLI
- **Kiro CLI**: `kiro-cli` CLI
- **Amp**: `amp` CLI
- **SHAI**: `shai` CLI
- **Tabnine CLI**: `tabnine` CLI
- **Kimi Code**: `kimi` CLI
- **Mistral Vibe**: `vibe` CLI
- **Pi Coding Agent**: `pi` CLI
- **iFlow CLI**: `iflow` CLI
### IDE-Based Agents
Work within integrated development environments:
- **GitHub Copilot**: Built into VS Code/compatible editors
- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`)
- **Windsurf**: Built into Windsurf IDE
- **Kilo Code**: Built into Kilo Code IDE
- **Roo Code**: Built into Roo Code IDE
- **IBM Bob**: Built into IBM Bob IDE
- **Trae**: Built into Trae IDE
- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`)
## Command File Formats
### Markdown Format
Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow
**Standard format:**
```markdown
@@ -336,6 +376,8 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders.
### TOML Format
Used by: Gemini, Tabnine
```toml
description = "Command description"
@@ -344,90 +386,69 @@ Command content with {SCRIPT} and {{args}} placeholders.
"""
```
### YAML Format
## Directory Conventions
Used by: Goose
```yaml
version: 1.0.0
title: "Command Title"
description: "Command description"
author:
contact: spec-kit
extensions:
- type: builtin
name: developer
activities:
- Spec-Driven Development
prompt: |
Command content with {SCRIPT} and {{args}} placeholders.
```
- **CLI agents**: Usually `.<agent-name>/commands/`
- **Singular command exception**:
- opencode: `.opencode/command/` (singular `command`, not `commands`)
- **Nested path exception**:
- Tabnine: `.tabnine/agent/commands/` (extra `agent/` segment)
- **Shared `.agents/` folder**:
- Amp: `.agents/commands/` (shared folder, not `.amp/`)
- Codex: `.agents/skills/` (shared folder; requires `--ai-skills`; invoked as `$speckit-<command>`)
- **Skills-based exceptions**:
- Kimi Code: `.kimi/skills/` (skills, invoked as `/skill:speckit-<command>`)
- **Prompt-based exceptions**:
- Kiro CLI: `.kiro/prompts/`
- Pi: `.pi/prompts/`
- Mistral Vibe: `.vibe/prompts/`
- **Rules-based exceptions**:
- Trae: `.trae/rules/`
- **IDE agents**: Follow IDE-specific patterns:
- Copilot: `.github/agents/`
- Cursor: `.cursor/commands/`
- Windsurf: `.windsurf/workflows/`
- Kilo Code: `.kilocode/workflows/`
- Roo Code: `.roo/commands/`
- IBM Bob: `.bob/commands/`
- Antigravity: `.agent/skills/` (`--ai-skills` required; `.agent/commands/` is deprecated)
## Argument Patterns
Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt:
Different agents use different argument placeholders:
- **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents)
- **TOML-based**: `{{args}}` (e.g., Gemini)
- **YAML-based**: `{{args}}` (e.g., Goose)
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
- **Markdown/prompt-based**: `$ARGUMENTS`
- **TOML-based**: `{{args}}`
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
## Special Processing Requirements
## Testing New Agent Integration
Some agents require custom processing beyond the standard template transformations:
### Copilot Integration
GitHub Copilot has unique requirements:
- Commands use `.agent.md` extension (not `.md`)
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
- Installs `.vscode/settings.json` with prompt file recommendations
- Context file lives at `.github/copilot-instructions.md`
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
### Forge Integration
Forge has special frontmatter and argument requirements:
- Uses `{{parameters}}` instead of `$ARGUMENTS`
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
- Injects `name` field into frontmatter when missing
Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
1. Inherits standard template processing from `MarkdownIntegration`
2. Adds extra `$ARGUMENTS``{{parameters}}` replacement after template processing
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
Goose is a YAML-format agent using Block's recipe system:
- Uses `.goose/recipes/` directory for YAML recipe files
- Uses `{{args}}` argument placeholder
- Produces YAML with `prompt: |` block scalar for command content
Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
1. Processes templates through the standard placeholder pipeline
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. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
1. **Build test**: Run package creation script locally
2. **CLI test**: Test `specify init --ai <agent>` command
3. **File generation**: Verify correct directory structure and files
4. **Command validation**: Ensure generated commands work with the agent
5. **Context update**: Test agent context update scripts
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated.
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.
1. **Using shorthand keys instead of actual CLI tool names**: Always use the actual executable name as the AGENT_CONFIG key (e.g., `"cursor-agent"` not `"cursor"`). This prevents the need for special-case mappings throughout the codebase.
2. **Forgetting update scripts**: Both bash and PowerShell scripts must be updated when adding new agents.
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that actually have CLI tools to check; set to `False` for IDE-based agents.
4. **Wrong argument format**: Use correct placeholder format for each agent type (`$ARGUMENTS` for Markdown, `{{args}}` for TOML).
5. **Directory naming**: Follow agent-specific conventions exactly (check existing agents for patterns).
6. **Help text inconsistency**: Update all user-facing text consistently (help strings, docstrings, README, error messages).
## Future Considerations
When adding new agents:
- Consider the agent's native command/workflow patterns
- Ensure compatibility with the Spec-Driven Development process
- Document any special requirements or limitations
- Update this guide with lessons learned
- Verify the actual CLI tool name before adding to AGENT_CONFIG
---
*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.*
*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.*

View File

@@ -2,139 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.7.2] - 2026-04-16
### Changed
- docs: add core commands reference and simplify README CLI section (#2245)
- docs: add workflows reference, reorganize into docs/reference/, and add --version flag (#2244)
- docs: add presets reference page and rename pack_id to preset_id (#2243)
- docs: add extensions reference page and integrations FAQ (#2242)
- docs: consolidate integration documentation into docs/integrations.md (#2241)
- feat: update memorylint and superpowers-bridge versions to 1.3.0 with new download URLs (#2240)
- feat: Integration catalog — discovery, versioning, and community distribution (#2130)
- Add Catalog CI extension to community catalog (#2239)
- Added issues extension (#2194)
- chore: release 0.7.1, begin 0.7.2.dev0 development (#2235)
## [0.7.1] - 2026-04-15
### Changed
- ci: add windows-latest to test matrix (#2233)
- docs: remove deprecated --skip-tls references from local-development guide (#2231)
- fix: allow Claude to chain skills for hook execution (#2227)
- docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md (#2228)
- Add agent-assign extension to community catalog (#2030)
- fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027)
- feat: register architect-preview in community catalog (#2214)
- chore: deprecate --ai flag in favor of --integration on specify init (#2218)
- chore: release 0.7.0, begin 0.7.1.dev0 development (#2217)
## [0.7.0] - 2026-04-14
### Changed
- Add workflow engine with catalog system (#2158)
- docs(catalog): add claude-ask-questions to community preset catalog (#2191)
- Add SFSpeckit — Salesforce SDD Extension (#2208)
- feat(scripts): optional single-segment branch prefix for gitflow (#2202)
- chore: release 0.6.2, begin 0.6.3.dev0 development (#2205)
- Add Worktrees extension to community catalog (#2207)
- feat: Update catalog.community.json for preset-fiction-book-writing (#2199)
## [0.6.2] - 2026-04-13
### Changed
- feat: Register "What-if Analysis" community extension (#2182)
- feat: add GitHub Issues Integration to community catalog (#2188)
- feat(agents): add Goose AI agent support (#2015)
- Update ralph extension to v1.0.1 in community catalog (#2192)
- fix: skip docs deployment workflow on forks (#2171)
- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162)
## [0.6.1] - 2026-04-10
### Changed
- feat: add bundled lean preset with minimal workflow commands (#2161)
- Add Brownfield Bootstrap extension to community catalog (#2145)
- Add CI Guard extension to community catalog (#2157)
- Add SpecTest extension to community catalog (#2159)
- fix: bundled extensions should not have download URLs (#2155)
- Add PR Bridge extension to community catalog (#2148)
- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
- Add TinySpec extension to community catalog (#2147)
- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146)
- Add Status Report extension to community catalog (#2123)
- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144)
## [0.6.0] - 2026-04-09
### Changed
- Add Bugfix Workflow community extension to catalog and README (#2135)
- Add Worktree Isolation extension to community catalog (#2143)
- Add multi-repo-branching preset to community catalog (#2139)
- Readme clarity (#2013)
- Rewrite AGENTS.md for integration architecture (#2119)
- docs: add SpecKit Companion to Community Friends section (#2140)
- feat: add memorylint extension to community catalog (#2138)
- chore: release 0.5.1, begin 0.5.2.dev0 development (#2137)
## [0.5.1] - 2026-04-08
### Changed
- fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136)
- feat: update fleet extension to v1.1.0 (#2029)
- fix(forge): use hyphen notation in frontmatter name field (#2075)
- fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090)
- Add Spec Diagram community extension to catalog and README (#2129)
- feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117)
- fix(git): surface checkout errors for existing branches (#2122)
- Add Branch Convention community extension to catalog and README (#2128)
- docs: lighten March 2026 newsletter for readability (#2127)
- fix: restore alias compatibility for community extensions (#2110) (#2125)
- Added March 2026 newsletter (#2124)
- Add Spec Refine community extension to catalog and README (#2118)
- Add explicit-task-dependencies community preset to catalog and README (#2091)
- Add toc-navigation community preset to catalog and README (#2080)
- fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) (#2115)
- fix speckit issue for trae (#2112)
- feat: Git extension stage 1 — bundled `extensions/git` with hooks on all core commands (#1941)
- Upgraded confluence extension to v.1.1.1 (#2109)
- Update V-Model Extension Pack to v0.5.0 (#2108)
- Add canon extension and canon-core preset. (#2022)
- [stage2] fix: serialize multiline descriptions in legacy TOML renderer (#2097)
- [stage1] fix: strip YAML frontmatter from TOML integration prompts (#2096)
- Add Confluence extension (#2028)
- fix: accept 4+ digit spec numbers in tests and docs (#2094)
- fix(scripts): improve git branch creation error handling (#2089)
- Add optimize extension to community catalog (#2088)
- feat: add "VS Code Ask Questions" preset (#2086)
- Add security-review v1.1.1 to community extensions catalog (#2073)
- Add `specify integration` subcommand for post-init integration management (#2083)
- Remove template version info from CLI, fix Claude user-invocable, cleanup dead code (#2081)
- fix: add user-invocable: true to skill frontmatter (#2077)
- fix: add actions:write permission to stale workflow (#2079)
- feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059)
- Update conduct extension to v1.0.1 (#2078)
- chore(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#2072)
- chore(deps): bump actions/configure-pages from 5 to 6 (#2071)
- feat: add spec-kit-fixit extension to community catalog (#2024)
- chore: release 0.5.0, begin 0.5.1.dev0 development (#2070)
- feat: add Forgecode agent support (#2034)
## [0.5.0] - 2026-04-02
### Changed
- Introduces DEVELOPMENT.md (#2069)
- Update cc-sdd reference to cc-spex in Community Friends (#2007)
- chore: release 0.4.5, begin 0.4.6.dev0 development (#2064)
## [0.4.5] - 2026-04-02
### Changed

View File

@@ -11,7 +11,7 @@ These are one time installations required to be able to test your changes locall
1. Install [Python 3.11+](https://www.python.org/downloads/)
1. Install [uv](https://docs.astral.sh/uv/) for package management
1. Install [Git](https://git-scm.com/downloads)
1. Have an [AI coding agent available](README.md#-supported-ai-coding-agent-integrations)
1. Have an [AI coding agent available](README.md#-supported-ai-agents)
<details>
<summary><b>💡 Hint if you are using <code>VSCode</code> or <code>GitHub Codespaces</code> as your IDE</b></summary>
@@ -44,7 +44,8 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler
1. Push to your fork and submit a pull request
1. Wait for your pull request to be reviewed and merged.
Activate the project virtual environment (see [Testing setup](#testing-setup) below), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below.
For the detailed test workflow, command-selection prompt, and PR reporting template, see [`TESTING.md`](./TESTING.md).
Activate the project virtual environment (see the Setup block in [`TESTING.md`](./TESTING.md)), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
@@ -68,99 +69,34 @@ When working on spec-kit:
For the smoothest review experience, validate changes in this order:
1. **Run focused automated checks first** — use the quick verification commands [below](#automated-checks) to catch scaffolding and configuration regressions early.
2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow the [manual testing](#manual-testing) section to choose the right commands, run them in an agent, and capture results for your PR.
1. **Run focused automated checks first** — use the quick verification commands in [`TESTING.md`](./TESTING.md) to catch packaging, scaffolding, and configuration regressions early.
2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow [`TESTING.md`](./TESTING.md) to choose the right commands, run them in an agent, and capture results for your PR.
3. **Use local release packages when debugging packaged output** — if you need to inspect the exact files CI-style packaging produces, generate local release packages as described below.
### Automated checks
### Testing template and command changes locally
#### Agent configuration and wiring consistency
Running `uv run specify init` pulls released packages, which wont include your local changes.
To test your templates, commands, and other changes locally, follow these steps:
```bash
uv run python -m pytest tests/test_agent_config_consistency.py -q
```
1. **Create release packages**
Run this when you change agent metadata, context update scripts, or integration wiring.
Run the following command to generate the local packages:
### Manual testing
```bash
./.github/workflows/scripts/create-release-packages.sh v1.0.0
```
#### Testing setup
2. **Copy the relevant package to your test project**
```bash
# Install the project and test dependencies from your local branch
cd <spec-kit-repo>
uv sync --extra test
source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1
uv pip install -e .
# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing.
```bash
cp -r .genreleases/sdd-copilot-package-sh/. <path-to-test-project>/
```
# Initialize a test project using your local changes
uv run specify init <temp-dir>/speckit-test --ai <agent> --offline
cd <temp-dir>/speckit-test
3. **Open and test the agent**
# Open in your agent
```
Navigate to your test project folder and open the agent to verify your implementation.
#### Manual testing process
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)).
3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated).
4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order.
5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested.
#### Reporting results
Paste this into your PR:
~~~markdown
## Manual test results
**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh]
| Command tested | Notes |
|----------------|-------|
| `/speckit.command` | |
~~~
#### Determining which tests to run
Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR.
~~~text
Read CONTRIBUTING.md, then run `git diff --name-only main` to get my changed files.
For each changed file, determine which slash commands it affects by reading
the command templates in templates/commands/ to understand what each command
invokes. Use these mapping rules:
- templates/commands/X.md → the command it defines
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
- templates/Z-template.md → every command that consumes that template during execution
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
- extensions/X/commands/* → the extension command it defines
- extensions/X/scripts/* → every extension command that invokes that script
- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected
- presets/*/* → test preset scaffolding via `specify init` with the preset
- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets
Include prerequisite tests (e.g., T5 requires T3 requires T1).
Output in this format:
### Test selection reasoning
| Changed file | Affects | Test | Why |
|---|---|---|---|
| (path) | (command) | T# | (reason) |
### Required tests
Number each test sequentially (T1, T2, ...). List prerequisite tests first.
- T1: /speckit.command — (reason)
- T2: /speckit.command — (reason)
~~~
If you only need to validate generated file structure and content before doing manual agent testing, start with the focused automated checks in [`TESTING.md`](./TESTING.md). Keep this section for the cases where you need to inspect the exact packaged output locally.
## AI contributions in Spec Kit

View File

@@ -1,24 +0,0 @@
# Development Notes
Spec Kit is a toolkit for spec-driven development. At its core, it is a coordinated set of prompts, templates, scripts, and CLI/integration assets that define and deliver a spec-driven workflow for AI coding agents. This document is a starting point for people modifying Spec Kit itself, with a compact orientation to the key project documents and repository organization.
**Essential project documents:**
| Document | Role |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| [README.md](README.md) | Primary user-facing overview of Spec Kit and its workflow. |
| [DEVELOPMENT.md](DEVELOPMENT.md) | This document. |
| [spec-driven.md](spec-driven.md) | End-to-end explanation of the Spec-Driven Development workflow supported by Spec Kit. |
| [RELEASE-PROCESS.md](.github/workflows/RELEASE-PROCESS.md) | Release workflow, versioning rules, and changelog generation process. |
| [docs/index.md](docs/index.md) | Entry point to the `docs/` documentation set. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, testing, and required development practices. |
**Main repository components:**
| Directory | Role |
| ------------------ | ------------------------------------------------------------------------------------------- |
| `templates/` | Prompt assets and templates that define the core workflow behavior and generated artifacts. |
| `scripts/` | Supporting scripts used by the workflow, setup, and repository tooling. |
| `src/specify_cli/` | Python source for the `specify` CLI, including agent-specific assets. |
| `extensions/` | Extension-related docs, catalogs, and supporting assets. |
| `presets/` | Preset-related docs, catalogs, and supporting assets. |

284
README.md
View File

@@ -26,7 +26,7 @@
- [🎨 Community Presets](#-community-presets)
- [🚶 Community Walkthroughs](#-community-walkthroughs)
- [🛠️ Community Friends](#-community-friends)
- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations)
- [🤖 Supported AI Agents](#-supported-ai-agents)
- [🔧 Specify CLI Reference](#-specify-cli-reference)
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
- [📚 Core Philosophy](#-core-philosophy)
@@ -50,8 +50,6 @@ Spec-Driven Development **flips the script** on traditional software development
Choose your preferred installation method:
> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below.
#### Option 1: Persistent Installation (Recommended)
Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
@@ -64,22 +62,16 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX
uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
```
Then verify the correct version is installed:
```bash
specify version
```
And use the tool directly:
Then use the tool directly:
```bash
# Create new project
specify init <PROJECT_NAME>
# Or initialize in existing project
specify init . --ai copilot
specify init . --ai claude
# or
specify init --here --ai copilot
specify init --here --ai claude
# Check installed tools
specify check
@@ -100,9 +92,9 @@ Run directly without installing:
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <PROJECT_NAME>
# Or initialize in existing project
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai claude
# or
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude
```
**Benefits of persistent installation:**
@@ -190,28 +182,16 @@ The following community-contributed extensions are available in [`catalog.commun
| Extension | Purpose | Category | Effect | URL |
|-----------|---------|----------|--------|-----|
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
| 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) |
| 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) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
@@ -222,11 +202,8 @@ The following community-contributed extensions are available in [`catalog.commun
| MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) |
| MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) |
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| 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) |
| 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) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
@@ -239,24 +216,14 @@ The following community-contributed extensions are available in [`catalog.commun
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| 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) |
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| 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) |
| 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) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
@@ -270,13 +237,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| 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) |
| 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) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. | 21 templates, 17 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| 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) |
To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
@@ -308,65 +269,208 @@ See Spec-Driven Development in action across different scenarios with these comm
Community projects that extend, visualize, or build on Spec Kit:
- **[cc-spex](https://github.com/rhuss/cc-spex)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
- **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH.
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.
## 🤖 Supported AI Agents
## 🤖 Supported AI Coding Agent Integrations
| Agent | Support | Notes |
| ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [Qoder CLI](https://qoder.com/cli) | ✅ | |
| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) |
| [Amp](https://ampcode.com/) | ✅ | |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-<command>`. |
| [Cursor](https://cursor.sh/) | ✅ | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
| [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support |
| [Jules](https://jules.google.com/) | ✅ | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | |
| [opencode](https://opencode.ai/) | ✅ | |
| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | |
| [Roo Code](https://roocode.com/) | ✅ | |
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | |
| [Kimi Code](https://code.kimi.com/) | ✅ | |
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | |
| [Windsurf](https://windsurf.com/) | ✅ | |
| [Junie](https://junie.jetbrains.com/) | ✅ | |
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
| [Trae](https://www.trae.ai/) | ✅ | |
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide.
## 🔧 Specify CLI Reference
Run `specify integration list` to see all available integrations in your installed version.
The `specify` command supports the following options:
## Available Slash Commands
### Commands
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai <agent> --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`.
| Command | Description |
| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `init` | Initialize a new Specify project from the latest template |
| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) |
### `specify init` Arguments & Options
| Argument/Option | Type | Description |
| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) |
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
| `--no-git` | Flag | Skip git repository initialization |
| `--here` | Flag | Initialize project in the current directory instead of creating a new one |
| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) |
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. |
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |
### Examples
```bash
# Basic project initialization
specify init my-project
# Initialize with specific AI assistant
specify init my-project --ai claude
# Initialize with Cursor support
specify init my-project --ai cursor-agent
# Initialize with Qoder support
specify init my-project --ai qodercli
# Initialize with Windsurf support
specify init my-project --ai windsurf
# Initialize with Kiro CLI support
specify init my-project --ai kiro-cli
# Initialize with Amp support
specify init my-project --ai amp
# Initialize with SHAI support
specify init my-project --ai shai
# Initialize with Mistral Vibe support
specify init my-project --ai vibe
# Initialize with IBM Bob support
specify init my-project --ai bob
# Initialize with Pi Coding Agent support
specify init my-project --ai pi
# Initialize with Codex CLI support
specify init my-project --ai codex --ai-skills
# Initialize with Antigravity support
specify init my-project --ai agy --ai-skills
# Initialize with an unsupported agent (generic / bring your own agent)
specify init my-project --ai generic --ai-commands-dir .myagent/commands/
# Initialize with PowerShell scripts (Windows/cross-platform)
specify init my-project --ai copilot --script ps
# Initialize in current directory
specify init . --ai copilot
# or use the --here flag
specify init --here --ai copilot
# Force merge into current (non-empty) directory without confirmation
specify init . --force --ai copilot
# or
specify init --here --force --ai copilot
# Skip git initialization
specify init my-project --ai gemini --no-git
# Enable debug output for troubleshooting
specify init my-project --ai claude --debug
# Use GitHub token for API requests (helpful for corporate environments)
specify init my-project --ai claude --github-token ghp_your_token_here
# Claude Code installs skills with the project by default
specify init my-project --ai claude
# Initialize in current directory with agent skills
specify init --here --ai gemini --ai-skills
# Use timestamp-based branch numbering (useful for distributed teams)
specify init my-project --ai claude --branch-numbering timestamp
# Check system requirements
specify check
```
### Available Slash Commands
After running `specify init`, your AI coding agent will have access to these structured development commands.
Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`.
Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`.
For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.
#### Core Commands
Essential commands for the Spec-Driven Development workflow:
| Command | Agent Skill | Description |
| ------------------------ | ---------------------- | -------------------------------------------------------------------------- |
| `/speckit.constitution` | `speckit-constitution` | Create or update project governing principles and development guidelines |
| `/speckit.specify` | `speckit-specify` | Define what you want to build (requirements and user stories) |
| `/speckit.plan` | `speckit-plan` | Create technical implementation plans with your chosen tech stack |
| `/speckit.tasks` | `speckit-tasks` | Generate actionable task lists for implementation |
| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution |
| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan |
| Command | Description |
| ----------------------- | ------------------------------------------------------------------------ |
| `/speckit.constitution` | Create or update project governing principles and development guidelines |
| `/speckit.specify` | Define what you want to build (requirements and user stories) |
| `/speckit.plan` | Create technical implementation plans with your chosen tech stack |
| `/speckit.tasks` | Generate actionable task lists for implementation |
| `/speckit.implement` | Execute all tasks to build the feature according to the plan |
#### Optional Commands
Additional commands for enhanced quality and validation:
| Command | Agent Skill | Description |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `/speckit.clarify` | `speckit-clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) |
| `/speckit.analyze` | `speckit-analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) |
| `/speckit.checklist` | `speckit-checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") |
| Command | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `/speckit.clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) |
| `/speckit.analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) |
| `/speckit.checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") |
## 🔧 Specify CLI Reference
### Environment Variables
For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html).
| Variable | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.<br/>\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. |
## 🧩 Making Spec Kit Your Own: Extensions & Presets
Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments:
| Priority | Component Type | Location |
| -------: | ------------------------------------------------- | -------------------------------- |
| ⬆ 1 | Project-Local Overrides | `.specify/templates/overrides/` |
| 2 | Presets — Customize core & extensions | `.specify/presets/templates/` |
| 3 | Extensions — Add new capabilities | `.specify/extensions/templates/` |
| ⬇ 4 | Spec Kit Core — Built-in SDD commands & templates | `.specify/templates/` |
```mermaid
block-beta
columns 1
overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"]
presets["Presets — Customize core & extensions\n.specify/presets/<preset-id>/templates/"]
extensions["Extensions — Add new capabilities\n.specify/extensions/<ext-id>/templates/"]
core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"]
- **Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match.
- Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset.
- **Extension/preset commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`).
- If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically.
- If no overrides or customizations exist, Spec Kit uses its core defaults.
style overrides fill:transparent,stroke:#999
style presets fill:transparent,stroke:#4a9eda
style extensions fill:transparent,stroke:#4a9e4a
style core fill:transparent,stroke:#e6a817
```
**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults.
### Extensions — Add New Capabilities
@@ -382,7 +486,7 @@ specify extension add <extension-name>
For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.
See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available.
See the [Extensions README](./extensions/README.md) for the full guide and how to build and publish your own. Browse the [community extensions](#-community-extensions) above for what's available.
### Presets — Customize Existing Workflows
@@ -398,7 +502,7 @@ specify preset add <preset-name>
For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering.
See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking.
See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own.
### When to Use Which
@@ -456,7 +560,7 @@ Our research and experimentation focus on:
## 🔧 Prerequisites
- **Linux/macOS/Windows**
- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent.
- [Supported](#-supported-ai-agents) AI coding agent.
- [uv](https://docs.astral.sh/uv/) for package management
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads)
@@ -498,29 +602,29 @@ specify init --here --force
You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal:
```bash
specify init <project_name> --ai copilot
specify init <project_name> --ai claude
specify init <project_name> --ai gemini
specify init <project_name> --ai copilot
# Or in current directory:
specify init . --ai copilot
specify init . --ai claude
specify init . --ai codex --ai-skills
# or use --here flag
specify init --here --ai copilot
specify init --here --ai claude
specify init --here --ai codex --ai-skills
# Force merge into a non-empty current directory
specify init . --force --ai copilot
specify init . --force --ai claude
# or
specify init --here --force --ai copilot
specify init --here --force --ai claude
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --ai copilot --ignore-agent-tools
specify init <project_name> --ai claude --ignore-agent-tools
```
### **STEP 1:** Establish project principles

133
TESTING.md Normal file
View File

@@ -0,0 +1,133 @@
# Testing Guide
This document is the detailed testing companion to [`CONTRIBUTING.md`](./CONTRIBUTING.md).
Use it for three things:
1. running quick automated checks before manual testing,
2. manually testing affected slash commands through an AI agent, and
3. capturing the results in a PR-friendly format.
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
## Recommended order
1. **Sync your environment** — install the project and test dependencies.
2. **Run focused automated checks** — especially for packaging, scaffolding, agent config, and generated-file changes.
3. **Run manual agent tests** — for any affected slash commands.
4. **Paste results into your PR** — include both command-selection reasoning and manual test results.
## Quick automated checks
Run these before manual testing when your change affects packaging, scaffolding, templates, release artifacts, or agent wiring.
### Environment setup
```bash
cd <spec-kit-repo>
uv sync --extra test
source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1
```
### Generated package structure and content
```bash
uv run python -m pytest tests/test_core_pack_scaffold.py -q
```
This validates the generated files that CI-style packaging depends on, including directory layout, file names, frontmatter/TOML validity, placeholder replacement, `.specify/` path rewrites, and parity with `create-release-packages.sh`.
### Agent configuration and release wiring consistency
```bash
uv run python -m pytest tests/test_agent_config_consistency.py -q
```
Run this when you change agent metadata, release scripts, context update scripts, or artifact naming.
### Optional single-agent packaging spot check
```bash
AGENTS=copilot SCRIPTS=sh ./.github/workflows/scripts/create-release-packages.sh v1.0.0
```
Inspect `.genreleases/sdd-copilot-package-sh/` and the matching ZIP in `.genreleases/` when you want to review the exact packaged output for one agent/script combination.
## Manual testing process
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)).
3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated).
4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order.
5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested.
## Setup
```bash
# Install the project and test dependencies from your local branch
cd <spec-kit-repo>
uv sync --extra test
source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1
uv pip install -e .
# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing.
# Initialize a test project using your local changes
uv run specify init /tmp/speckit-test --ai <agent> --offline
cd /tmp/speckit-test
# Open in your agent
```
If you are testing the packaged output rather than the live source tree, create a local release package first as described in [`CONTRIBUTING.md`](./CONTRIBUTING.md).
## Reporting results
Paste this into your PR:
~~~markdown
## Manual test results
**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh]
| Command tested | Notes |
|----------------|-------|
| `/speckit.command` | |
~~~
## Determining which tests to run
Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR.
~~~text
Read TESTING.md, then run `git diff --name-only main` to get my changed files.
For each changed file, determine which slash commands it affects by reading
the command templates in templates/commands/ to understand what each command
invokes. Use these mapping rules:
- templates/commands/X.md → the command it defines
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
- templates/Z-template.md → every command that consumes that template during execution
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
- extensions/X/commands/* → the extension command it defines
- extensions/X/scripts/* → every extension command that invokes that script
- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected
- presets/*/* → test preset scaffolding via `specify init` with the preset
- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets
Include prerequisite tests (e.g., T5 requires T3 requires T1).
Output in this format:
### Test selection reasoning
| Changed file | Affects | Test | Why |
|---|---|---|---|
| (path) | (command) | T# | (reason) |
### Required tests
Number each test sequentially (T1, T2, ...). List prerequisite tests first.
- T1: /speckit.command — (reason)
- T2: /speckit.command — (reason)
~~~

View File

@@ -10,8 +10,6 @@
## Installation
> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid.
### Initialize a New Project
The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest):
@@ -71,14 +69,6 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje
## Verification
After installation, run the following command to confirm the correct version is installed:
```bash
specify version
```
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
After initialization, you should see the following commands available in your AI agent:
- `/speckit.specify` - Create specifications

View File

@@ -128,14 +128,16 @@ python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script
Or copy only the modified CLI portion if you want a lighter sandbox.
## 9. Debug Network / TLS Issues
## 9. Debug Network / TLS Skips
> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect.
> It was previously used to bypass TLS validation during local testing.
> If you encounter TLS errors (e.g., on a corporate network), configure your
> environment's certificate store or proxy instead.
>
> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`.
If you need to bypass TLS validation while experimenting:
```bash
specify check --skip-tls
specify init demo --skip-tls --ai gemini --ignore-agent-tools --script ps
```
(Use only for local experimentation.)
## 10. Rapid Edit Loop Summary
@@ -164,7 +166,7 @@ rm -rf .venv dist build *.egg-info
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git step skipped | You passed `--no-git` or Git not installed |
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
| TLS errors on corporate network | Try `--skip-tls` (not for production) |
## 13. Next Steps

View File

@@ -1,79 +0,0 @@
# Core Commands
The core `specify` commands handle project initialization, system checks, and version information.
## Initialize a Project
```bash
specify init [<project_name>]
```
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--integration <key>` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys |
| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory |
| `--no-git` | Skip git repository initialization |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
| `--preset <id>` | Install a preset during initialization |
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
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
```bash
# Create a new project with an integration
specify init my-project --integration copilot
# Initialize in the current directory
specify init --here --integration copilot
# Force merge into a non-empty directory
specify init --here --force --integration copilot
# Use PowerShell scripts (Windows/cross-platform)
specify init my-project --integration copilot --script ps
# Skip git initialization
specify init my-project --integration copilot --no-git
# Install a preset during initialization
specify init my-project --integration copilot --preset compliance
# Use timestamp-based branch numbering (useful for distributed teams)
specify init my-project --integration copilot --branch-numbering timestamp
```
### Environment Variables
| Variable | Description |
| ----------------- | ------------------------------------------------------------------------ |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
## Check Installed Tools
```bash
specify check
```
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool.
## Version Information
```bash
specify version
```
Displays the Spec Kit CLI version, Python version, platform, and architecture.
A quick version check is also available via:
```bash
specify --version
specify -V
```

View File

@@ -1,201 +0,0 @@
# Extensions
Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They introduce new commands and templates that go beyond the built-in Spec-Driven Development workflow.
## Search Available Extensions
```bash
specify extension search [query]
```
| Option | Description |
| ------------ | ------------------------------------ |
| `--tag` | Filter by tag |
| `--author` | Filter by author |
| `--verified` | Show only verified extensions |
Searches all active catalogs for extensions matching the query. Without a query, lists all available extensions.
## Install an Extension
```bash
specify extension add <name>
```
| Option | Description |
| --------------- | -------------------------------------------------------- |
| `--dev` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
> **Note:** All extension commands require a project already initialized with `specify init`.
## Remove an Extension
```bash
specify extension remove <name>
```
| Option | Description |
| --------------- | ---------------------------------------------- |
| `--keep-config` | Preserve configuration files during removal |
| `--force` | Skip confirmation prompt |
Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation.
## List Installed Extensions
```bash
specify extension list
```
| Option | Description |
| ------------- | -------------------------------------------------- |
| `--available` | Show available (uninstalled) extensions |
| `--all` | Show both installed and available extensions |
Lists installed extensions with their status, version, and command counts.
## Extension Info
```bash
specify extension info <name>
```
Shows detailed information about an installed or available extension, including its description, version, commands, and configuration.
## Update Extensions
```bash
specify extension update [<name>]
```
Updates a specific extension, or all installed extensions if no name is given.
## Enable / Disable an Extension
```bash
specify extension enable <name>
specify extension disable <name>
```
Disable an extension without removing it. Disabled extensions are not loaded and their commands are not available. Re-enable with `enable`.
## Set Extension Priority
```bash
specify extension set-priority <name> <priority>
```
Changes the resolution priority of an extension. When multiple extensions provide a command with the same name, the extension with the lowest priority number takes precedence.
## Catalog Management
Extension catalogs control where `search` and `add` look for extensions. Catalogs are checked in priority order (lower number = higher precedence).
### List Catalogs
```bash
specify extension catalog list
```
Shows all active catalogs in the stack with their priorities and install permissions.
### Add a Catalog
```bash
specify extension catalog add <url>
```
| Option | Description |
| ------------------------------------ | -------------------------------------------------- |
| `--name <name>` | Required. Unique name for the catalog |
| `--priority <N>` | Priority (default: 10; lower = higher precedence) |
| `--install-allowed / --no-install-allowed` | Whether extensions can be installed from this catalog |
| `--description <text>` | Optional description |
Adds a catalog to the project's `.specify/extension-catalogs.yml`.
### Remove a Catalog
```bash
specify extension catalog remove <name>
```
Removes a catalog from the project configuration.
### Catalog Resolution Order
Catalogs are resolved in this order (first match wins):
1. **Environment variable**`SPECKIT_CATALOG_URL` overrides all catalogs
2. **Project config**`.specify/extension-catalogs.yml`
3. **User config**`~/.specify/extension-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog
Example `.specify/extension-catalogs.yml`:
```yaml
catalogs:
- name: "my-org-catalog"
url: "https://example.com/catalog.json"
priority: 5
install_allowed: true
description: "Our approved extensions"
```
## Extension Configuration
Most extensions include configuration files in their install directory:
```text
.specify/extensions/<ext>/
├── <ext>-config.yml # Project config (version controlled)
├── <ext>-config.local.yml # Local overrides (gitignored)
└── <ext>-config.template.yml # Template reference
```
Configuration is merged in this order (highest priority last):
1. **Extension defaults** (from `extension.yml`)
2. **Project config** (`<ext>-config.yml`)
3. **Local overrides** (`<ext>-config.local.yml`)
4. **Environment variables** (`SPECKIT_<EXT>_*`)
To set up configuration for a newly installed extension, copy the template:
```bash
cp .specify/extensions/<ext>/<ext>-config.template.yml \
.specify/extensions/<ext>/<ext>-config.yml
```
## FAQ
### Why can't I find an extension with `search`?
Check the spelling of the extension name. The extension may not be published yet, or it may be in a catalog you haven't added. Use `specify extension catalog list` to see which catalogs are active.
### Why doesn't the extension command appear in my AI coding agent?
Verify the extension is installed and enabled with `specify extension list`. If it shows as installed, restart your AI coding agent — it may need to reload for it to take effect.
### How do I set up extension configuration?
Copy the config template that ships with the extension:
```bash
cp .specify/extensions/<ext>/<ext>-config.template.yml \
.specify/extensions/<ext>/<ext>-config.yml
```
See [Extension Configuration](#extension-configuration) for details on config layers and overrides.
### How do I resolve an incompatible version error?
Update Spec Kit to the version required by the extension.
### Who maintains extensions?
Most extensions are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support extension code. Review an extension's source code before installing and use at your own discretion. For issues with a specific extension, contact its author or file an issue on the extension's repository.

View File

@@ -1,140 +0,0 @@
# Supported AI Coding Agent Integrations
The Specify CLI supports a wide range of AI coding agents. When you run `specify init`, the CLI sets up the appropriate command files, context rules, and directory structures for your chosen AI coding agent — so you can start using Spec-Driven Development immediately, regardless of which tool you prefer.
## Supported AI Coding Agents
| Agent | Key | Notes |
| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| [Amp](https://ampcode.com/) | `amp` | |
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
| [Roo Code](https://roocode.com/) | `roo` | |
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |
| [Windsurf](https://windsurf.com/) | `windsurf` | |
| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir <path>"` for AI coding agents not listed above |
## List Available Integrations
```bash
specify integration list
```
Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
## Install an Integration
```bash
specify integration install <key>
```
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
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.
## Uninstall an Integration
```bash
specify integration uninstall [<key>]
```
| Option | Description |
| --------- | --------------------------------------------------- |
| `--force` | Remove files even if they have been modified |
Uninstalls the current integration (or the specified one). Spec Kit tracks every file created during install along with a SHA-256 hash of the original content:
- **Unmodified files** are removed automatically.
- **Modified files** (where you've made manual edits) are preserved so your customizations are not lost.
- Use `--force` to remove all integration files regardless of modifications.
## Switch to a Different Integration
```bash
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 |
| `--integration-options` | Options for the target integration |
Equivalent to running `uninstall` followed by `install` in a single step.
## Upgrade an Integration
```bash
specify integration upgrade [<key>]
```
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--force` | Overwrite files even if they have been modified |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--integration-options` | Options for the 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
Some integrations accept additional options via `--integration-options`:
| Integration | Option | Description |
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format |
Example:
```bash
specify integration install generic --integration-options="--commands-dir .myagent/cmds"
```
## FAQ
### Can I use multiple integrations at the same time?
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?
Files you've modified are preserved automatically. Only unmodified files (matching their original SHA-256 hash) are removed. Use `--force` to override this.
### How do I know which key to use?
Run `specify integration list` to see all available integrations with their keys, or check the [Supported AI Coding Agents](#supported-ai-coding-agents) table above.
### Do I need the AI coding agent installed to use an integration?
CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be installed. IDE-based integrations (like Windsurf, Cursor) work through the IDE itself. Some agents like GitHub Copilot support both IDE and CLI usage. `specify integration list` shows which type each integration is.
### When should I use `upgrade` vs `switch`?
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.

View File

@@ -1,33 +0,0 @@
# CLI Reference
The Specify CLI (`specify`) manages the full lifecycle of Spec-Driven Development — from project initialization to workflow automation.
## Core Commands
The foundational commands for creating and managing Spec Kit projects. Initialize a new project with the necessary directory structure, templates, and scripts. Verify that your system has the required tools installed. Check version and system information.
[Core Commands reference →](core.md)
## Integrations
Integrations connect Spec Kit to your AI coding agent. Each integration sets up the appropriate command files, context rules, and directory structures for a specific agent. Only one integration is active per project at a time, and you can switch between them at any point.
[Integrations reference →](integrations.md)
## Extensions
Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They are discovered through catalogs and can be installed, updated, enabled, disabled, or removed independently. Multiple extensions can coexist in a single project.
[Extensions reference →](extensions.md)
## Presets
Presets customize how Spec Kit works — overriding command files, template files, and script files without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering to layer customizations.
[Presets reference →](presets.md)
## Workflows
Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption.
[Workflows reference →](workflows.md)

View File

@@ -1,224 +0,0 @@
# Presets
Presets customize how Spec Kit works — overriding templates, commands, and terminology without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering.
## Search Available Presets
```bash
specify preset search [query]
```
| Option | Description |
| ---------- | -------------------- |
| `--tag` | Filter by tag |
| `--author` | Filter by author |
Searches all active catalogs for presets matching the query. Without a query, lists all available presets.
## Install a Preset
```bash
specify preset add [<preset_id>]
```
| Option | Description |
| ---------------- | -------------------------------------------------------- |
| `--dev <path>` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--priority <N>` | Resolution priority (default: 10; lower = higher precedence) |
Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with the currently installed AI coding agent integration.
> **Note:** All preset commands require a project already initialized with `specify init`.
## Remove a Preset
```bash
specify preset remove <preset_id>
```
Removes an installed preset and cleans up its registered commands.
## List Installed Presets
```bash
specify preset list
```
Lists installed presets with their versions, descriptions, template counts, and current status.
## Preset Info
```bash
specify preset info <preset_id>
```
Shows detailed information about an installed or available preset, including its templates, metadata, and tags.
## Resolve a File
```bash
specify preset resolve <name>
```
Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file.
## Enable / Disable a Preset
```bash
specify preset enable <preset_id>
specify preset disable <preset_id>
```
Disable a preset without removing it. Disabled presets are skipped during file resolution but their commands remain registered. Re-enable with `enable`.
## Set Preset Priority
```bash
specify preset set-priority <preset_id> <priority>
```
Changes the resolution priority of an installed preset. Lower numbers take precedence. When multiple presets provide the same file, the one with the lowest priority number wins.
## Catalog Management
Preset catalogs control where `search` and `add` look for presets. Catalogs are checked in priority order (lower number = higher precedence).
### List Catalogs
```bash
specify preset catalog list
```
Shows all active catalogs with their priorities and install permissions.
### Add a Catalog
```bash
specify preset catalog add <url>
```
| Option | Description |
| -------------------------------------------- | -------------------------------------------------- |
| `--name <name>` | Required. Unique name for the catalog |
| `--priority <N>` | Priority (default: 10; lower = higher precedence) |
| `--install-allowed / --no-install-allowed` | Whether presets can be installed from this catalog (default: discovery only) |
| `--description <text>` | Optional description |
Adds a catalog to the project's `.specify/preset-catalogs.yml`.
### Remove a Catalog
```bash
specify preset catalog remove <name>
```
Removes a catalog from the project configuration.
### Catalog Resolution Order
Catalogs are resolved in this order (first match wins):
1. **Environment variable**`SPECKIT_PRESET_CATALOG_URL` overrides all catalogs
2. **Project config**`.specify/preset-catalogs.yml`
3. **User config**`~/.specify/preset-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog
Example `.specify/preset-catalogs.yml`:
```yaml
catalogs:
- name: "my-org-presets"
url: "https://example.com/preset-catalog.json"
priority: 5
install_allowed: true
description: "Our approved presets"
```
## File Resolution
Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers.
> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release.
The resolution stack, from highest to lowest precedence:
1. **Project-local overrides**`.specify/templates/overrides/`
2. **Installed presets** — sorted by priority (lower = checked first)
3. **Installed extensions** — sorted by priority
4. **Spec Kit core**`.specify/templates/`
Commands are registered at install time (not resolved through the stack at runtime).
### Resolution Stack
```mermaid
flowchart TB
subgraph stack [" "]
direction TB
A["⬆ Highest precedence<br/><br/>1. Project-local overrides<br/>.specify/templates/overrides/"]
B["2. Presets — by priority<br/>.specify/presets/id/"]
C["3. Extensions — by priority<br/>.specify/extensions/id/"]
D["4. Spec Kit core<br/>.specify/templates/<br/><br/>⬇ Lowest precedence"]
end
A --> B --> C --> D
style A fill:#4a9,color:#fff
style B fill:#49a,color:#fff
style C fill:#a94,color:#fff
style D fill:#999,color:#fff
```
Within each layer, files are organized by type:
| Type | Subdirectory | Override path |
| --------- | -------------- | ------------------------------------------ |
| Templates | `templates/` | `.specify/templates/overrides/` |
| Commands | `commands/` | `.specify/templates/overrides/` |
| Scripts | `scripts/` | `.specify/templates/overrides/scripts/` |
### Resolution in Action
```mermaid
flowchart TB
A["File requested:<br/>plan-template.md"] --> B{"Project-local override?"}
B -- Found --> Z["✓ Use this file"]
B -- Not found --> C{"Preset: compliance<br/>(priority 5)"}
C -- Found --> Z
C -- Not found --> D{"Preset: team-workflow<br/>(priority 10)"}
D -- Found --> Z
D -- Not found --> E{"Extension files?"}
E -- Found --> Z
E -- Not found --> F["Spec Kit core"]
F --> Z
```
### Example
```bash
specify preset add compliance --priority 5
specify preset add team-workflow --priority 10
```
For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used.
## FAQ
### Can I use multiple presets at the same time?
Yes. Presets stack by priority — each file is resolved independently from the highest-priority source that provides it. Use `specify preset set-priority` to control the order.
### How do I see which file is actually being used?
Run `specify preset resolve <name>` to trace the resolution stack and see which file wins.
### What's the difference between disabling and removing a preset?
**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`.
**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry.
### Who maintains presets?
Most presets are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support preset code. Review a preset's source code before installing and use at your own discretion. For issues with a specific preset, contact its author or file an issue on the preset's repository.

View File

@@ -1,289 +0,0 @@
# Workflows
Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, and can be paused and resumed from the exact point of interruption.
## Run a Workflow
```bash
specify workflow run <source>
```
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.
Example:
```bash
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
```
> **Note:** All workflow commands require a project already initialized with `specify init`.
## Resume a Workflow
```bash
specify workflow resume <run_id>
```
Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.
## Workflow Status
```bash
specify workflow status [<run_id>]
```
Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.
## List Installed Workflows
```bash
specify workflow list
```
Lists workflows installed in the current project.
## Install a Workflow
```bash
specify workflow add <source>
```
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
## Remove a Workflow
```bash
specify workflow remove <workflow_id>
```
Removes an installed workflow from the project.
## Search Available Workflows
```bash
specify workflow search [query]
```
| Option | Description |
| ------- | --------------- |
| `--tag` | Filter by tag |
Searches all active catalogs for workflows matching the query.
## Workflow Info
```bash
specify workflow info <workflow_id>
```
Shows detailed information about a workflow, including its steps, inputs, and requirements.
## Catalog Management
Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order.
### List Catalogs
```bash
specify workflow catalog list
```
Shows all active catalog sources.
### Add a Catalog
```bash
specify workflow catalog add <url>
```
| Option | Description |
| --------------- | -------------------------------- |
| `--name <name>` | Optional name for the catalog |
Adds a custom catalog URL to the project's `.specify/workflow-catalogs.yml`.
### Remove a Catalog
```bash
specify workflow catalog remove <index>
```
Removes a catalog by its index in the catalog list.
### Catalog Resolution Order
Catalogs are resolved in this order (first match wins):
1. **Environment variable**`SPECKIT_WORKFLOW_CATALOG_URL` overrides all catalogs
2. **Project config**`.specify/workflow-catalogs.yml`
3. **User config**`~/.specify/workflow-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog
## Workflow Definition
Workflows are defined in YAML files. Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit:
```yaml
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.0"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
speckit_version: ">=0.7.2"
integrations:
any: ["copilot", "claude", "gemini"]
inputs:
spec:
type: string
required: true
prompt: "Describe what you want to build"
integration:
type: string
default: "copilot"
prompt: "Integration to use (e.g. claude, copilot, gemini)"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
```
This produces the following execution flow:
```mermaid
flowchart TB
A["specify<br/>(command)"] --> B{"review-spec<br/>(gate)"}
B -- approve --> C["plan<br/>(command)"]
B -- reject --> X1["⏹ Abort"]
C --> D{"review-plan<br/>(gate)"}
D -- approve --> E["tasks<br/>(command)"]
D -- reject --> X2["⏹ Abort"]
E --> F["implement<br/>(command)"]
style A fill:#49a,color:#fff
style B fill:#a94,color:#fff
style C fill:#49a,color:#fff
style D fill:#a94,color:#fff
style E fill:#49a,color:#fff
style F fill:#49a,color:#fff
style X1 fill:#999,color:#fff
style X2 fill:#999,color:#fff
```
Run it with:
```bash
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management"
```
## Step Types
| Type | Purpose |
| ------------ | ------------------------------------------------ |
| `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) |
| `prompt` | Send an arbitrary prompt to the AI coding agent |
| `shell` | Execute a shell command and capture output |
| `gate` | Pause for human approval before continuing |
| `if` | Conditional branching (then/else) |
| `switch` | Multi-branch dispatch on an expression |
| `while` | Loop while a condition is true |
| `do-while` | Execute at least once, then loop on condition |
| `fan-out` | Dispatch a step for each item in a list |
| `fan-in` | Aggregate results from a fan-out step |
## Expressions
Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:
| Namespace | Description |
| ------------------------------ | ------------------------------------ |
| `inputs.spec` | Workflow input values |
| `steps.specify.output.file` | Output from a previous step |
| `item` | Current item in a fan-out iteration |
Available filters: `default`, `join`, `contains`, `map`.
Example:
```yaml
condition: "{{ steps.test.output.exit_code == 0 }}"
args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
## Input Types
| Type | Coercion |
| --------- | ------------------------------------------------- |
| `string` | Pass-through |
| `number` | `"42"``42`, `"3.14"``3.14` |
| `boolean` | `"true"` / `"1"` / `"yes"``True` |
## State and Resume
Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
- `state.json` — current run state and step progress
- `inputs.json` — resolved input values
- `log.jsonl` — step-by-step execution log
This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
## FAQ
### What happens when a workflow hits a gate step?
The workflow pauses and waits for human input. Run `specify workflow resume <run_id>` after reviewing to continue.
### Can I run the same workflow multiple times?
Yes. Each run gets a unique ID and its own state directory. Use `specify workflow status` to see all runs.
### Who maintains workflows?
Most workflows are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support workflow code. Review a workflow's source before installing and use at your own discretion.

View File

@@ -12,22 +12,6 @@
- name: Upgrade
href: upgrade.md
# Reference
- name: Reference
items:
- name: Overview
href: reference/overview.md
- name: Core Commands
href: reference/core.md
- name: Integrations
href: reference/integrations.md
- name: Extensions
href: reference/extensions.md
- name: Presets
href: reference/presets.md
- name: Workflows
href: reference/workflows.md
# Development workflows
- name: Development
items:

View File

@@ -76,7 +76,7 @@ Run this inside your project directory:
specify init --here --force --ai <your-agent>
```
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
Replace `<your-agent>` with your AI assistant. Refer to this list of [Supported AI Agents](../README.md#-supported-ai-agents)
**Example:**
@@ -292,7 +292,7 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a
```bash
ls -la .claude/commands/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .cursor/commands/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
```
@@ -401,7 +401,7 @@ The `specify` CLI tool is used for:
- **Upgrades:** `specify init --here --force` to update templates and commands
- **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.
**If your agent isn't recognizing slash commands:**

View File

@@ -108,7 +108,7 @@ defaults: # Optional, default configuration values
#### `hooks`
- **Type**: object
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`)
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`)
- **Description**: Hooks that execute at lifecycle events
- **Events**: Defined by core spec-kit commands
@@ -559,16 +559,8 @@ Standard events (defined by core):
- `after_tasks` - After task generation
- `before_implement` - Before implementation
- `after_implement` - After implementation
- `before_analyze` - Before cross-artifact analysis
- `after_analyze` - After cross-artifact analysis
- `before_checklist` - Before checklist generation
- `after_checklist` - After checklist generation
- `before_clarify` - Before spec clarification
- `after_clarify` - After spec clarification
- `before_constitution` - Before constitution update
- `after_constitution` - After constitution update
- `before_taskstoissues` - Before tasks-to-issues conversion
- `after_taskstoissues` - After tasks-to-issues conversion
- `before_commit` - Before git commit *(planned - not yet wired into core templates)*
- `after_commit` - After git commit *(planned - not yet wired into core templates)*
### Hook Configuration

View File

@@ -177,9 +177,9 @@ Compatibility requirements.
What the extension provides.
**Optional sub-fields**:
**Required sub-fields**:
- `commands`: Array of command objects (at least one command or hook is required)
- `commands`: Array of command objects (must have at least one)
**Command object**:
@@ -196,19 +196,12 @@ Integration hooks for automatic execution.
Available hook points:
- `before_specify` / `after_specify`: Before/after specification generation
- `before_plan` / `after_plan`: Before/after implementation planning
- `before_tasks` / `after_tasks`: Before/after task generation
- `before_implement` / `after_implement`: Before/after implementation
- `before_analyze` / `after_analyze`: Before/after cross-artifact analysis
- `before_checklist` / `after_checklist`: Before/after checklist generation
- `before_clarify` / `after_clarify`: Before/after spec clarification
- `before_constitution` / `after_constitution`: Before/after constitution update
- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion
- `after_tasks`: After `/speckit.tasks` completes
- `after_implement`: After `/speckit.implement` completes (future)
Hook object:
- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command)
- `command`: Command to execute (must be in `provides.commands`)
- `optional`: If true, prompt user before executing
- `prompt`: Prompt text for optional hooks
- `description`: Hook description

View File

@@ -403,10 +403,8 @@ settings:
# Hook configuration
# Available events: before_specify, after_specify, before_plan, after_plan,
# before_tasks, after_tasks, before_implement, after_implement,
# before_analyze, after_analyze, before_checklist, after_checklist,
# before_clarify, after_clarify, before_constitution, after_constitution,
# before_taskstoissues, after_taskstoissues
# before_tasks, after_tasks, before_implement, after_implement
# Planned (not yet wired into core templates): before_commit, after_commit
hooks:
after_tasks:
- extension: jira

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,20 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-03-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"extensions": {
"git": {
"name": "Git Branching Workflow",
"id": "git",
"selftest": {
"name": "Spec Kit Self-Test Utility",
"id": "selftest",
"version": "1.0.0",
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"bundled": true,
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
"tags": [
"git",
"branching",
"workflow",
"core"
"testing",
"core",
"utility"
]
}
}

View File

@@ -1,100 +0,0 @@
# Git Branching Workflow Extension
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
## Overview
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
## Commands
| Command | Description |
|---------|-------------|
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
## Hooks
| Event | Command | Optional | Description |
|-------|---------|----------|-------------|
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
## Configuration
Configuration is stored in `.specify/extensions/git/git-config.yml`:
```yaml
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:
default: false
after_specify:
enabled: true
message: "[Spec Kit] Add specification"
```
## Installation
```bash
# Install the bundled git extension (no network required)
specify extension add git
```
## Disabling
```bash
# Disable the git extension (spec creation continues without branching)
specify extension disable git
# Re-enable it
specify extension enable git
```
## Graceful Degradation
When Git is not installed or the directory is not a Git repository:
- Spec directories are still created under `specs/`
- Branch creation is skipped with a warning
- Branch validation is skipped with a warning
- Remote detection returns empty results
## Scripts
The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@@ -1,48 +0,0 @@
---
description: "Auto-commit changes after a Spec Kit command completes"
---
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message

View File

@@ -1,67 +0,0 @@
---
description: "Create a feature branch with sequential or timestamp numbering"
---
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -1,49 +0,0 @@
---
description: "Initialize a Git repository with an initial commit"
---
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- `✓ Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository

View File

@@ -1,45 +0,0 @@
---
description: "Detect Git remote URL for GitHub integration"
---
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View File

@@ -1,49 +0,0 @@
---
description: "Validate current branch follows feature branch naming conventions"
---
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View File

@@ -1,62 +0,0 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View File

@@ -1,140 +0,0 @@
schema_version: "1.0"
extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
requires:
speckit_version: ">=0.2.0"
tools:
- name: git
required: false
provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
- name: speckit.git.remote
file: commands/speckit.git.remote.md
description: "Detect Git remote URL for GitHub integration"
- name: speckit.git.initialize
file: commands/speckit.git.initialize.md
description: "Initialize a Git repository with an initial commit"
- name: speckit.git.commit
file: commands/speckit.git.commit.md
description: "Auto-commit changes after a Spec Kit command completes"
config:
- name: "git-config.yml"
template: "config-template.yml"
description: "Git branching configuration"
required: false
hooks:
before_constitution:
command: speckit.git.initialize
optional: false
description: "Initialize Git repository before constitution setup"
before_specify:
command: speckit.git.feature
optional: false
description: "Create feature branch before specification"
before_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before clarification?"
description: "Auto-commit before spec clarification"
before_plan:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before planning?"
description: "Auto-commit before implementation planning"
before_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before task generation?"
description: "Auto-commit before task generation"
before_implement:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before implementation?"
description: "Auto-commit before implementation"
before_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before checklist?"
description: "Auto-commit before checklist generation"
before_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before analysis?"
description: "Auto-commit before analysis"
before_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before issue sync?"
description: "Auto-commit before tasks-to-issues conversion"
after_constitution:
command: speckit.git.commit
optional: true
prompt: "Commit constitution changes?"
description: "Auto-commit after constitution update"
after_specify:
command: speckit.git.commit
optional: true
prompt: "Commit specification changes?"
description: "Auto-commit after specification"
after_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit clarification changes?"
description: "Auto-commit after spec clarification"
after_plan:
command: speckit.git.commit
optional: true
prompt: "Commit plan changes?"
description: "Auto-commit after implementation planning"
after_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit task changes?"
description: "Auto-commit after task generation"
after_implement:
command: speckit.git.commit
optional: true
prompt: "Commit implementation changes?"
description: "Auto-commit after implementation"
after_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit checklist changes?"
description: "Auto-commit after checklist generation"
after_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit analysis results?"
description: "Auto-commit after analysis"
after_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit after syncing issues?"
description: "Auto-commit after tasks-to-issues conversion"
tags:
- "git"
- "branching"
- "workflow"
config:
defaults:
branch_numbering: sequential
init_commit_message: "[Spec Kit] Initial commit"

View File

@@ -1,62 +0,0 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env bash
# Git extension: auto-commit.sh
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.sh <event_name>
# e.g.: auto-commit.sh after_specify
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
exit 1
fi
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
exit 0
fi
# Read per-command config from git-config.yml
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
if [ -f "$_config_file" ]; then
# Parse the auto_commit section for this event.
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
_in_auto_commit=false
_in_event=false
_default_enabled=false
while IFS= read -r _line; do
# Detect auto_commit: section
if echo "$_line" | grep -q '^auto_commit:'; then
_in_auto_commit=true
_in_event=false
continue
fi
# Exit auto_commit section on next top-level key
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
break
fi
if $_in_auto_commit; then
# Check default key
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _default_enabled=true
fi
# Detect our event subsection
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
_in_event=true
continue
fi
# Inside our event subsection
if $_in_event; then
# Exit on next sibling key (same indent level as event name)
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
_in_event=false
continue
fi
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _enabled=true
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi
done < "$_config_file"
# If event-specific key not found, use default
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
# Only use default if the event wasn't explicitly set to false
# Check if event section existed at all
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
_enabled=true
fi
fi
else
# No config file — auto-commit disabled by default
exit 0
fi
if [ "$_enabled" != "true" ]; then
exit 0
fi
# Check if there are changes to commit
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
echo "[specify] No changes to commit after $EVENT_NAME" >&2
exit 0
fi
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
# Use custom message if configured, otherwise default
if [ -z "$_commit_msg" ]; then
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
fi
# Stage and commit
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2

View File

@@ -1,453 +0,0 @@
#!/usr/bin/env bash
# Git extension: create-new-feature.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
# Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers.
set -e
JSON_MODE=false
DRY_RUN=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--dry-run)
DRY_RUN=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
echo 'Error: --number must be a non-negative integer' >&2
exit 1
fi
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name without creating the branch"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches and return next available number.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# ---------------------------------------------------------------------------
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
#
# Search locations in priority order:
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root by walking up from the script location
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
_common_loaded=false
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
_common_loaded=true
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/scripts/bash/common.sh"
_common_loaded=true
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
source "$SCRIPT_DIR/git-common.sh"
_common_loaded=true
fi
if [ "$_common_loaded" != "true" ]; then
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
exit 1
fi
# Resolve repository root
if type get_repo_root >/dev/null 2>&1; then
REPO_ROOT=$(get_repo_root)
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
elif [ -n "$_PROJECT_ROOT" ]; then
REPO_ROOT="$_PROJECT_ROOT"
else
echo "Error: Could not determine repository root." >&2
exit 1
fi
# Check if git is available at this repo root
if type has_git >/dev/null 2>&1; then
if has_git "$REPO_ROOT"; then
HAS_GIT=true
else
HAS_GIT=false
fi
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
# Function to generate branch name with stop word filtering
generate_branch_name() {
local description="$1"
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
local meaningful_words=()
for word in $clean_name; do
[ -z "$word" ] && continue
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -qw -- "${word^^}"; then
meaningful_words+=("$word")
fi
fi
done
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
fi
# GitHub enforces a 244-byte limit on branch names
MAX_BRANCH_LENGTH=244
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
if [ "$DRY_RUN" = true ]; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
else
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
fi
else
if type json_escape >/dev/null 2>&1; then
_je_branch=$(json_escape "$BRANCH_NAME")
_je_num=$(json_escape "$FEATURE_NUM")
else
_je_branch="$BRANCH_NAME"
_je_num="$FEATURE_NUM"
fi
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
else
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
fi

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# Git-specific common functions for the git extension.
# Extracted from scripts/bash/common.sh — contains only git-specific
# branch validation and detection logic.
# Check if we have git available at the repo root
has_git() {
local repo_root="${1:-$(pwd)}"
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
command -v git >/dev/null 2>&1 && \
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
}

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env bash
# Git extension: initialize-repo.sh
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
set -e
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Read commit message from extension config, fall back to default
COMMIT_MSG="[Spec Kit] Initial commit"
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
if [ -f "$_config_file" ]; then
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
if [ -n "$_msg" ]; then
COMMIT_MSG="$_msg"
fi
fi
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
exit 0
fi
# Check if already a git repo
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Git repository already initialized; skipping" >&2
exit 0
fi
# Initialize
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "✓ Git repository initialized" >&2

View File

@@ -1,149 +0,0 @@
#!/usr/bin/env pwsh
# Git extension: auto-commit.ps1
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.ps1 <event_name>
# e.g.: auto-commit.ps1 after_specify
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
)
$ErrorActionPreference = 'Stop'
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
exit 0
}
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "not a repo" }
} catch {
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
exit 0
}
# Read per-command config from git-config.yml
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
if (Test-Path $configFile) {
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
$defaultEnabled = $false
foreach ($line in Get-Content $configFile) {
# Detect auto_commit: section
if ($line -match '^auto_commit:') {
$inAutoCommit = $true
$inEvent = $false
continue
}
# Exit auto_commit section on next top-level key
if ($inAutoCommit -and $line -match '^[a-z]') {
break
}
if ($inAutoCommit) {
# Check default key
if ($line -match '^\s+default:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $defaultEnabled = $true }
}
# Detect our event subsection
if ($line -match "^\s+${EventName}:") {
$inEvent = $true
continue
}
# Inside our event subsection
if ($inEvent) {
# Exit on next sibling key (2-space indent, not 4+)
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
$inEvent = $false
continue
}
if ($line -match '\s+enabled:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $enabled = $true }
if ($val -eq 'false') { $enabled = $false }
}
if ($line -match '\s+message:\s*(.+)$') {
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
}
}
}
}
# If event-specific key not found, use default
if (-not $enabled -and $defaultEnabled) {
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
if (-not $hasEventKey) {
$enabled = $true
}
}
} else {
# No config file — auto-commit disabled by default
exit 0
}
if (-not $enabled) {
exit 0
}
# Check if there are changes to commit
$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
$untracked = git ls-files --others --exclude-standard 2>$null
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
exit 0
}
# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
# Use custom message if configured, otherwise default
if (-not $commitMsg) {
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
}
# Stage and commit
try {
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
}
Write-Host "[OK] Changes committed $phase $commandName"

View File

@@ -1,403 +0,0 @@
#!/usr/bin/env pwsh
# Git extension: create-new-feature.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
# Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers.
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name without creating the branch"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
exit 0
}
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
}
return $highest
}
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
$maxNum = [Math]::Max($highestBranch, $highestSpec)
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# ---------------------------------------------------------------------------
# Source common.ps1 from the project's installed scripts.
# Search locations in priority order:
# 1. .specify/scripts/powershell/common.ps1 under the project root
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
# 3. git-common.ps1 next to this script (minimal fallback)
# ---------------------------------------------------------------------------
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
$commonLoaded = $false
if ($projectRoot) {
$candidates = @(
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
(Join-Path $projectRoot "scripts/powershell/common.ps1")
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
. $candidate
$commonLoaded = $true
break
}
}
}
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
. "$PSScriptRoot/git-common.ps1"
$commonLoaded = $true
}
if (-not $commonLoaded) {
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
}
# Resolve repository root
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
$repoRoot = Get-RepoRoot
} elseif ($projectRoot) {
$repoRoot = $projectRoot
} else {
throw "Could not determine repository root."
}
# Check if git is available
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
# and git-common.ps1 (has -RepoRoot param with default).
$hasGit = Test-HasGit
} else {
try {
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
$hasGit = ($LASTEXITCODE -eq 0)
} catch {
$hasGit = $false
}
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
function Get-BranchName {
param([string]$Description)
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
$meaningfulWords = @()
foreach ($word in $words) {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
$meaningfulWords += $word
}
}
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
}
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} else {
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
$branchSuffix = Get-BranchName -Description $featureDesc
}
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
}
}
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
if (-not $DryRun) {
if ($hasGit) {
$branchCreated = $false
$branchCreateError = ''
try {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
}
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
if ($currentBranch -eq $branchName) {
# Already on the target branch
} else {
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
}
exit 1
}
}
} else {
if ($Json) {
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
}
$env:SPECIFY_FEATURE = $branchName
}
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env pwsh
# Git-specific common functions for the git extension.
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
# branch validation and detection logic.
function Test-HasGit {
param([string]$RepoRoot = (Get-Location))
try {
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env pwsh
# Git extension: initialize-repo.ps1
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
$ErrorActionPreference = 'Stop'
# Find project root
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Read commit message from extension config, fall back to default
$commitMsg = "[Spec Kit] Initial commit"
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
if (Test-Path $configFile) {
foreach ($line in Get-Content $configFile) {
if ($line -match '^init_commit_message:\s*(.+)$') {
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
if ($val) { $commitMsg = $val }
break
}
}
}
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
exit 0
}
# Check if already a git repo
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Warning "[specify] Git repository already initialized; skipping"
exit 0
}
} catch { }
# Initialize
try {
$out = git init -q 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
}
Write-Host "✓ Git repository initialized"

View File

@@ -1,142 +0,0 @@
# Contributing to the Integration Catalog
This guide covers adding integrations to both the **built-in** and **community** catalogs.
## Adding a Built-In Integration
Built-in integrations are maintained by the Spec Kit core team and ship with the CLI.
### Checklist
1. **Create the integration subpackage** under `src/specify_cli/integrations/<package_dir>/`
`<package_dir>` matches the integration key when it contains no hyphens (e.g., `gemini`), or replaces hyphens with underscores when it does (e.g., key `cursor-agent` → directory `cursor_agent/`, key `kiro-cli` → directory `kiro_cli/`). Python package names cannot use hyphens.
2. **Implement the integration class** extending `MarkdownIntegration`, `TomlIntegration`, or `SkillsIntegration`
3. **Register the integration** in `src/specify_cli/integrations/__init__.py`
4. **Add tests** under `tests/integrations/test_integration_<package_dir>.py`
5. **Add a catalog entry** in `integrations/catalog.json`
6. **Update documentation** in `AGENTS.md` and `README.md`
### Catalog Entry Format
Add your integration under the top-level `integrations` key in `integrations/catalog.json`:
```json
{
"schema_version": "1.0",
"integrations": {
"my-agent": {
"id": "my-agent",
"name": "My Agent",
"version": "1.0.0",
"description": "Integration for My Agent",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
}
}
}
```
## Adding a Community Integration
Community integrations are contributed by external developers and listed in `integrations/catalog.community.json` for discovery.
### Prerequisites
1. **Working integration** — tested with `specify integration install`
2. **Public repository** — hosted on GitHub or similar
3. **`integration.yml` descriptor** — valid descriptor file (see below)
4. **Documentation** — README with usage instructions
5. **License** — open source license file
### `integration.yml` Descriptor
Every community integration must include an `integration.yml`:
```yaml
schema_version: "1.0"
integration:
id: "my-agent"
name: "My Agent"
version: "1.0.0"
description: "Integration for My Agent"
author: "your-name"
repository: "https://github.com/your-name/speckit-my-agent"
license: "MIT"
requires:
speckit_version: ">=0.6.0"
tools:
- name: "my-agent"
version: ">=1.0.0"
required: true
provides:
commands:
- name: "speckit.specify"
file: "templates/speckit.specify.md"
scripts:
- update-context.sh
```
### Descriptor Validation Rules
| Field | Rule |
|-------|------|
| `schema_version` | Must be `"1.0"` |
| `integration.id` | Lowercase alphanumeric + hyphens (`^[a-z0-9-]+$`) |
| `integration.version` | Valid PEP 440 version (parsed with `packaging.version.Version()`) |
| `requires.speckit_version` | Required field; specify a version constraint such as `>=0.6.0` (current validation checks presence only) |
| `provides` | Must include at least one command or script |
| `provides.commands[].name` | String identifier |
| `provides.commands[].file` | Relative path to template file |
### Submitting to the Community Catalog
1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit)
2. **Add your entry** under the `integrations` key in `integrations/catalog.community.json`:
```json
{
"schema_version": "1.0",
"integrations": {
"my-agent": {
"id": "my-agent",
"name": "My Agent",
"version": "1.0.0",
"description": "Integration for My Agent",
"author": "your-name",
"repository": "https://github.com/your-name/speckit-my-agent",
"tags": ["cli"]
}
}
}
```
3. **Open a pull request** with:
- Your catalog entry
- Link to your integration repository
- Confirmation that `integration.yml` is valid
### Version Updates
To update your integration version in the catalog:
1. Release a new version of your integration
2. Open a PR updating the `version` field in `catalog.community.json`
3. Ensure backward compatibility or document breaking changes
## Upgrade Workflow
The `specify integration upgrade` command supports diff-aware upgrades:
1. **Hash comparison** — the manifest records SHA-256 hashes of all installed files
2. **Modified file detection** — files changed since installation are flagged
3. **Safe default** — the upgrade blocks if any installed files were modified since installation
4. **Forced reinstall** — passing `--force` overwrites modified files with the latest version
```bash
# Upgrade current integration (blocks if files are modified)
specify integration upgrade
# Force upgrade (overwrites modified files)
specify integration upgrade --force
```

View File

@@ -1,129 +0,0 @@
# Spec Kit Integration Catalog
The integration catalog enables discovery, versioning, and distribution of AI agent integrations for Spec Kit.
## Catalog Files
### Built-In Catalog (`catalog.json`)
Contains integrations that ship with Spec Kit. These are maintained by the core team and always installable.
### Community Catalog (`catalog.community.json`)
Community-contributed integrations. Listed for discovery only — users install from the source repositories.
## Catalog Configuration
The catalog stack is resolved in this order (first match wins):
1. **Environment variable**`SPECKIT_INTEGRATION_CATALOG_URL` overrides all catalogs with a single URL
2. **Project config**`.specify/integration-catalogs.yml` in the project root
3. **User config**`~/.specify/integration-catalogs.yml` in the user home directory
4. **Built-in defaults**`catalog.json` + `catalog.community.json`
Example `integration-catalogs.yml`:
```yaml
catalogs:
- url: "https://example.com/my-catalog.json"
name: "my-catalog"
priority: 1
install_allowed: true
```
## CLI Commands
```bash
# List built-in integrations (default)
specify integration list
# Browse full catalog (built-in + community)
specify integration list --catalog
# Install an integration
specify integration install copilot
# Upgrade the current integration (diff-aware)
specify integration upgrade
# Upgrade with force (overwrite modified files)
specify integration upgrade --force
```
## Integration Descriptor (`integration.yml`)
Each integration can include an `integration.yml` descriptor that documents its metadata, requirements, and provided commands/scripts:
```yaml
schema_version: "1.0"
integration:
id: "my-agent"
name: "My Agent"
version: "1.0.0"
description: "Integration for My Agent"
author: "my-org"
repository: "https://github.com/my-org/speckit-my-agent"
license: "MIT"
requires:
speckit_version: ">=0.6.0"
tools:
- name: "my-agent"
version: ">=1.0.0"
required: true
provides:
commands:
- name: "speckit.specify"
file: "templates/speckit.specify.md"
- name: "speckit.plan"
file: "templates/speckit.plan.md"
scripts:
- update-context.sh
- update-context.ps1
```
## Catalog Schema
Both catalog files follow the same JSON schema:
```json
{
"schema_version": "1.0",
"updated_at": "2026-04-08T00:00:00Z",
"catalog_url": "https://...",
"integrations": {
"my-agent": {
"id": "my-agent",
"name": "My Agent",
"version": "1.0.0",
"description": "Integration for My Agent",
"author": "my-org",
"repository": "https://github.com/my-org/speckit-my-agent",
"tags": ["cli"]
}
}
}
```
### Required Fields
| Field | Type | Description |
|-------|------|-------------|
| `schema_version` | string | Must be `"1.0"` |
| `updated_at` | string | ISO 8601 timestamp |
| `integrations` | object | Map of integration ID → metadata |
### Integration Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | Yes | Unique ID (lowercase alphanumeric + hyphens) |
| `name` | string | Yes | Human-readable display name |
| `version` | string | Yes | PEP 440 version (e.g., `1.0.0`, `1.0.0a1`) |
| `description` | string | Yes | One-line description |
| `author` | string | No | Author name or organization |
| `repository` | string | No | Source repository URL |
| `tags` | array | No | Searchable tags (e.g., `["cli", "ide"]`) |
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add integrations to the community catalog.

View File

@@ -1,6 +0,0 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-08T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json",
"integrations": {}
}

View File

@@ -1,259 +0,0 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-08T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
"id": "claude",
"name": "Claude Code",
"version": "1.0.0",
"description": "Anthropic Claude Code CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "anthropic"]
},
"copilot": {
"id": "copilot",
"name": "GitHub Copilot",
"version": "1.0.0",
"description": "GitHub Copilot IDE integration with agent commands and prompt files",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide", "github"]
},
"gemini": {
"id": "gemini",
"name": "Gemini CLI",
"version": "1.0.0",
"description": "Google Gemini CLI integration with TOML command format",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "google"]
},
"cursor-agent": {
"id": "cursor-agent",
"name": "Cursor",
"version": "1.0.0",
"description": "Cursor IDE integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"windsurf": {
"id": "windsurf",
"name": "Windsurf",
"version": "1.0.0",
"description": "Windsurf IDE workflow integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"amp": {
"id": "amp",
"name": "Amp",
"version": "1.0.0",
"description": "Amp CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"codex": {
"id": "codex",
"name": "Codex CLI",
"version": "1.0.0",
"description": "Codex CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"qwen": {
"id": "qwen",
"name": "Qwen Code",
"version": "1.0.0",
"description": "Alibaba Qwen Code CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "alibaba"]
},
"opencode": {
"id": "opencode",
"name": "opencode",
"version": "1.0.0",
"description": "opencode CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"forge": {
"id": "forge",
"name": "Forge",
"version": "1.0.0",
"description": "Forge CLI integration with parameter-based commands",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"kiro-cli": {
"id": "kiro-cli",
"name": "Kiro CLI",
"version": "1.0.0",
"description": "Kiro CLI prompt-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"junie": {
"id": "junie",
"name": "Junie",
"version": "1.0.0",
"description": "Junie by JetBrains CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "jetbrains"]
},
"auggie": {
"id": "auggie",
"name": "Auggie CLI",
"version": "1.0.0",
"description": "Auggie CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"shai": {
"id": "shai",
"name": "SHAI",
"version": "1.0.0",
"description": "SHAI CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"tabnine": {
"id": "tabnine",
"name": "Tabnine CLI",
"version": "1.0.0",
"description": "Tabnine CLI integration with TOML command format",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"kilocode": {
"id": "kilocode",
"name": "Kilo Code",
"version": "1.0.0",
"description": "Kilo Code IDE workflow integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"roo": {
"id": "roo",
"name": "Roo Code",
"version": "1.0.0",
"description": "Roo Code IDE integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"bob": {
"id": "bob",
"name": "IBM Bob",
"version": "1.0.0",
"description": "IBM Bob IDE integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide", "ibm"]
},
"trae": {
"id": "trae",
"name": "Trae",
"version": "1.0.0",
"description": "Trae IDE rules-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"codebuddy": {
"id": "codebuddy",
"name": "CodeBuddy",
"version": "1.0.0",
"description": "CodeBuddy CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"qodercli": {
"id": "qodercli",
"name": "Qoder CLI",
"version": "1.0.0",
"description": "Qoder CLI integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"kimi": {
"id": "kimi",
"name": "Kimi Code",
"version": "1.0.0",
"description": "Kimi Code CLI skills-based integration by Moonshot AI",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"pi": {
"id": "pi",
"name": "Pi Coding Agent",
"version": "1.0.0",
"description": "Pi terminal coding agent prompt-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"iflow": {
"id": "iflow",
"name": "iFlow CLI",
"version": "1.0.0",
"description": "iFlow CLI integration by iflow-ai",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"vibe": {
"id": "vibe",
"name": "Mistral Vibe",
"version": "1.0.0",
"description": "Mistral Vibe CLI prompt-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "mistral"]
},
"agy": {
"id": "agy",
"name": "Antigravity",
"version": "1.0.0",
"description": "Antigravity IDE skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide", "skills"]
},
"generic": {
"id": "generic",
"name": "Generic (bring your own agent)",
"version": "1.0.0",
"description": "Generic integration for any agent via --ai-commands-dir",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["generic"]
},
"goose": {
"id": "goose",
"name": "Goose",
"version": "1.0.0",
"description": "Goose CLI integration with YAML recipe format",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
}
}
}

View File

@@ -1,80 +0,0 @@
# Spec Kit - March 2026 Newsletter
This edition covers Spec Kit activity in March 2026. Nine releases shipped (v0.2.0 through v0.4.3), introducing a pluggable preset system, air-gapped deployment, automatic skill registration, and seven new AI agent integrations. The community extension catalog grew past 20 entries, independent walkthroughs and blog posts proliferated, and industry coverage debated whether "vibe coding" is dead. A summary is in the table below, followed by details.
| **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Nine releases shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added. The repo grew from ~71k to **82,616 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev. Over 20 community extensions. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module became available. | ByteIota reported AWS pushing SDD as the new standard. Augment Code published a Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. |
***
## Spec Kit Project Updates
### Releases Overview
**v0.2.0** (March 10) opened the month with **simultaneous multi-catalog support**, enabling both core and community extension catalogs at the same time. It added **Tabnine CLI** and **Kimi Code CLI** agents, four community extensions (Understanding, Ralph, Review, Fleet Orchestrator), and `.extensionignore` support. Patch **v0.2.1** fixed broken quickstart links and added catalog CLI help. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.3.0** (mid-March) delivered the **pluggable preset system** with catalog, resolver, and skills propagation. Presets let teams override default templates with their own conventions, using priority-based stacking. The release also added a **/selftest.extension** for testing extensions, **Mistral Vibe CLI**, migrated **Qwen Code CLI** from TOML to Markdown, and hardened bash scripts against shell injection. New community extensions included DocGuard CDD, Archive & Reconcile, specify-status, and specify-doctor. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.3.1** added before/after hook events, JSONC deep-merge for `settings.json`, and the **Trae IDE** agent. **v0.3.2** added **Junie**, **iFlow CLI**, and **Pi Coding Agent**, plus a preset submission template and an Extension Comparison Guide. Community extensions continued arriving: verify-tasks, conduct, cognitive-squad, speckit-utils, spec-kit-iterate, and spec-kit-learn. [\[github.com\]](https://github.com/github/spec-kit/releases)
**v0.4.0** (late March) introduced **auto-registration of extension skills** — installed extensions' commands are now automatically exposed as agent skills. It also delivered **air-gapped/offline deployment** by embedding core templates in the CLI wheel and added timestamp-based branch naming. [\[github.com\]](https://github.com/github/spec-kit/releases)
Three patches closed the month. **v0.4.1** fixed a missing Assumptions section in the spec template and improved repo root detection. **v0.4.2** added AIDE, Extensify, and Presetify to the community catalog, moved the community extensions table into the main README, and recognized the **Spec Kit Assistant VS Code extension** as a Community Friend. **v0.4.3** unified skill naming conventions and restored **PowerShell 5.1 compatibility**. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Bug Fixes and Security Hardening
The most significant fix was **shell injection hardening** of bash scripts, addressing potential vulnerabilities from unsanitized git branch names and environment variables. Other fixes included switching to **global branch numbering** for consistent sequencing, suppressing git checkout exceptions and fetch stdout leaks, properly encoding JSON control characters, and adding explicit PowerShell positional binding. [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Extension Ecosystem
By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article *"The Feature That Turns Spec Kit Into a Platform"* highlighted standouts: **Conduct** (orchestrates SDD phases via sub-agents to avoid context pollution), **Verify Tasks** (catches "phantom completions" — tasks marked done with no real code), **Understanding** (31 quality metrics against specs based on IEEE/ISO standards), and the **Jira and Azure DevOps integrations**. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc)
Rajasekaran argued the real significance of presets is what they enable: the same machinery that turned "User Stories" into pirate-speak "Crew Tales" could enforce compliance requirements, add mandatory threat-model sections, or require test tasks before implementation tasks. Organizations can curate available extensions by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc)
## Community & Content
### Developer Walkthroughs and Blog Posts
March produced a wave of independent content as developers explored SDD in practice.
**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14. He documents building an Instagram-style photo mural feature using the full Spec Kit workflow, contrasting it with previous ad-hoc prompting: while directly prompting Claude worked for small changes, complex work led to scope creep, ambiguous requirements discovered too late, and no artifacts left behind. Valverde recommends being specific in the initial prompt, reviewing `spec.md` immediately, and highlights the clarify step as particularly valuable. A shorter companion piece, *"The Shift from Vibe Coding to Spec-Driven Development,"* appeared on March 8. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit)
**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach. He praises SDD in principle but argues the full seven-step workflow carries too much ceremony for smaller tasks. His solution is a lean **4-step custom workflow**`specify → plan → tasks → implement` — dropping constitution, clarify, and review, wired into the **SpecKit Companion** VS Code extension. The article highlights an important tradeoff: full rigor vs. lightweight adoption. Perez also presented this workflow at an **Angular Community Meetup** on March 25. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow)
**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17. The catalog organizes **20+ frameworks in 6 categories**, highlighting **BMAD-METHOD** (~41k stars, simulates an agile team from AI roles), **QuintCode + FPF** (preserves decision rationale via a 5-phase ADI Cycle), and **cc-sdd** (~2.9k stars, enforced SDD workflow for 8 tools). Golubev presents a three-level maturity model: *Spec-First* (spec per task, discarded after), *Spec-Anchored* (living document), and *Spec-as-Source* (spec is the only artifact). His conclusion: "SDD is not a fad… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice." [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog)
### Community Tools and Documentation
The **Spec Kit Assistant VS Code extension** was formally recognized as a Community Friend and added to the README. The README was reorganized: community extensions table moved into the main page for discoverability, a community presets section was added, and the publishing guide gained Category and Effect columns. New walkthroughs included Java brownfield, Go/React brownfield dashboard, and the Spring Boot pirate-speak preset demo. [\[github.com\]](https://github.com/github/spec-kit/releases)
A notable community project appeared: **speckit-pipeline** by iandeherdt — a pipeline atop Spec Kit with a **design loop** (designer + critic agents iterating in a browser) and a **build loop** (developer + evaluator agents verifying against acceptance criteria). An open issue (#1966) requests a built-in pipeline command, suggesting this pattern may eventually reach core.
A public **Microsoft Learn** training module, *"Implement Spec-Driven Development using the GitHub Spec Kit"* (3 hours, 13 units), provided an onboarding path for enterprise developers.
## SDD Ecosystem & Industry Trends
### The "Vibe Coding Is Dead" Narrative
*ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"* on March 20, reporting AWS pushing SDD as the new standard. Key claims: over 100,000 developers adopting SDD approaches in early tool previews, AWS demonstrating a two-week feature completed in two days using Kiro IDE, and WEF research indicating 65% of developers expect their role to shift toward spec-first workflows in 2026. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
Critics got equal space. *Marmelab* called SDD "the exact mistakes Agile was designed to solve." An *Isoform* controlled test found SDD took 33 minutes for 689 lines vs. 8 minutes with iterative prompting, with no measured quality improvement. The emerging consensus favored hybrids — a Red Hat developer captured it: "Use the vibes to explore. Use specifications to build." Other independent articles appeared from Shimon Ifrah, Raul Proenza (Cox Automotive), CGI, and Vishal Mysore. ByteIota also raised an underappreciated concern: if specs replace coding, how do juniors build the judgment to write good specs or review AI-generated code? [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
### Competitive Landscape
**Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* on March 31. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent offers **living specs** with automated drift detection. The comparison surfaced spec drift as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions address this, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github)
The broader landscape continued evolving. OpenSpec held ~29.3k stars, BMAD-METHOD grew to ~41k, and Tessl continued in private beta. While Spec Kit leads in GitHub popularity and agent breadth, alternatives differentiate on orchestration depth (Intent, BMAD), enforced discipline (cc-sdd), decision trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog)
## Roadmap
Areas under discussion or in progress for future development:
- **Spec lifecycle management** -- supporting longer-lived specifications that evolve across multiple iterations. The Augment Code comparison and community commentary highlighted "spec drift" as a key concern. The Archive & Reconcile extension (#1844) is a community step; a core solution is expected to be a focus area. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) [\[github.com\]](https://github.com/github/spec-kit/releases)
- **CI/CD integration** -- incorporating Spec Kit verification into pull request workflows and failing builds when specs are out of alignment. The Jira and Azure DevOps extensions (#1764, #1734) are a first step. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **End-to-end workflow automation** -- an open issue (#1966) proposes a built-in pipeline command. The community-built **speckit-pipeline** by iandeherdt already demonstrates multi-agent loops with browser verification. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline)
- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/)
- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md)

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-03-24T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"aide-in-place": {
@@ -29,143 +29,6 @@
"aide"
]
},
"canon-core": {
"name": "Canon Core",
"id": "canon-core",
"version": "0.1.0",
"description": "Adapts original Spec Kit workflow to work together with Canon extension.",
"author": "Maxim Stupakov",
"download_url": "https://github.com/maximiliamus/spec-kit-canon/releases/download/v0.1.0/spec-kit-canon-core-v0.1.0.zip",
"repository": "https://github.com/maximiliamus/spec-kit-canon",
"homepage": "https://github.com/maximiliamus/spec-kit-canon",
"documentation": "https://github.com/maximiliamus/spec-kit-canon/blob/master/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.3"
},
"provides": {
"templates": 2,
"commands": 8
},
"tags": [
"baseline",
"canon",
"spec-first"
]
},
"claude-ask-questions": {
"name": "Claude AskUserQuestion",
"id": "claude-ask-questions",
"version": "1.0.0",
"description": "Upgrades /speckit.clarify and /speckit.checklist on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question.",
"author": "0xrafasec",
"repository": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions",
"download_url": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions",
"documentation": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"templates": 0,
"commands": 2
},
"tags": [
"claude",
"ask-user-question",
"clarify",
"checklist"
],
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
"id": "explicit-task-dependencies",
"version": "1.0.0",
"description": "Adds explicit (depends on T###) dependency declarations and an Execution Wave DAG to tasks.md for dependency-resolved parallel scheduling",
"author": "Quratulain-bilal",
"repository": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"templates": 1,
"commands": 1
},
"tags": [
"dependencies",
"parallel",
"scheduling",
"wave-dag"
]
},
"fiction-book-writing": {
"name": "Fiction Book Writing",
"id": "fiction-book-writing",
"version": "1.3.0",
"description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.3.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 21,
"commands": 17,
"scripts": 1
},
"tags": [
"writing",
"novel",
"book",
"fiction",
"storytelling",
"creative-writing",
"kdp",
"single-pov",
"multi-pov",
"export"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-09T08:00:00Z"
},
"multi-repo-branching": {
"name": "Multi-Repo Branching",
"id": "multi-repo-branching",
"version": "1.0.0",
"description": "Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases.",
"author": "sakitA",
"repository": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching",
"download_url": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching",
"documentation": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/blob/master/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"templates": 0,
"commands": 2
},
"tags": [
"multi-repo-branching",
"multi-module",
"submodules",
"monorepo"
],
"created_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
},
"pirate": {
"name": "Pirate Speak (Full)",
"id": "pirate",
@@ -190,55 +53,6 @@
"fun",
"experimental"
]
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",
"version": "1.0.0",
"description": "Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents",
"author": "Quratulain-bilal",
"repository": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"templates": 3,
"commands": 3
},
"tags": [
"navigation",
"toc",
"documentation"
]
},
"vscode-ask-questions": {
"name": "VS Code Ask Questions",
"id": "vscode-ask-questions",
"version": "1.0.0",
"description": "Enhances the clarify command to use vscode/askQuestions for batched interactive questioning, reducing API request costs in GitHub Copilot.",
"author": "fdcastel",
"repository": "https://github.com/fdcastel/spec-kit-presets",
"download_url": "https://github.com/fdcastel/spec-kit-presets/releases/download/vscode-ask-questions-v1.0.0/vscode-ask-questions.zip",
"homepage": "https://github.com/fdcastel/spec-kit-presets",
"documentation": "https://github.com/fdcastel/spec-kit-presets/blob/main/vscode-ask-questions/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"templates": 0,
"commands": 1
},
"tags": [
"vscode",
"askquestions",
"clarify",
"interactive"
]
}
}
}

View File

@@ -1,22 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-03-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {
"lean": {
"name": "Lean Workflow",
"id": "lean",
"version": "1.0.0",
"description": "Minimal core workflow commands - just the prompt, just the artifact",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"bundled": true,
"tags": [
"lean",
"minimal",
"workflow",
"core"
]
}
}
"presets": {}
}

View File

@@ -1,15 +0,0 @@
---
description: Create or update the project constitution.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
- Project name, guiding principles, non-negotiable rules
- Derive from user input and existing repo context (README, docs)

View File

@@ -1,22 +0,0 @@
---
description: Execute the implementation plan by processing all tasks in tasks.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md` and `<feature_directory>/tasks.md`.
3. **Execute tasks** in order:
- Complete each task before moving to the next
- Mark completed tasks by changing `- [ ]` to `- [x]` in `<feature_directory>/tasks.md`
- Halt on failure and report the issue
4. **Validate**: Verify all tasks are completed and the implementation matches the spec.

View File

@@ -1,19 +0,0 @@
---
description: Create a plan and store it in plan.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md`.
3. Create an implementation plan and store it in `<feature_directory>/plan.md`.
- Technical context: tech stack, dependencies, project structure
- Design decisions, architecture, file structure

View File

@@ -1,23 +0,0 @@
---
description: Create a specification and store it in spec.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided.
2. Create the directory and write `.specify/feature.json`:
```json
{ "feature_directory": "<feature_directory>" }
```
3. Create a specification from the user input and store it in `<feature_directory>/spec.md`.
- Overview, functional requirements, user scenarios, success criteria
- Every requirement must be testable
- Make informed defaults for unspecified details

View File

@@ -1,19 +0,0 @@
---
description: Create the tasks needed for implementation and store them in tasks.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md`.
3. Create dependency-ordered implementation tasks and store them in `<feature_directory>/tasks.md`.
- Every task uses checklist format: `- [ ] [TaskID] Description with file path`
- Organized by phase: setup, foundational, user stories in priority order, polish

View File

@@ -1,50 +0,0 @@
schema_version: "1.0"
preset:
id: "lean"
name: "Lean Workflow"
version: "1.0.0"
description: "Minimal core workflow commands - just the prompt, just the artifact"
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
requires:
speckit_version: ">=0.6.0"
provides:
templates:
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"
description: "Lean specify - create spec.md from a feature description"
replaces: "speckit.specify"
- type: "command"
name: "speckit.plan"
file: "commands/speckit.plan.md"
description: "Lean plan - create plan.md from the spec"
replaces: "speckit.plan"
- type: "command"
name: "speckit.tasks"
file: "commands/speckit.tasks.md"
description: "Lean tasks - create tasks.md from plan and spec"
replaces: "speckit.tasks"
- type: "command"
name: "speckit.implement"
file: "commands/speckit.implement.md"
description: "Lean implement - execute tasks from tasks.md"
replaces: "speckit.implement"
- type: "command"
name: "speckit.constitution"
file: "commands/speckit.constitution.md"
description: "Lean constitution - create or update project constitution"
replaces: "speckit.constitution"
tags:
- "lean"
- "minimal"
- "workflow"

View File

@@ -1,14 +1,16 @@
[project]
name = "specify-cli"
version = "0.7.2"
version = "0.4.5"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
"typer>=0.24.0",
"click>=8.2.1",
"typer",
"click>=8.1",
"rich",
"httpx[socks]",
"platformdirs",
"readchar",
"truststore>=0.10.4",
"pyyaml>=6.0",
"packaging>=23.0",
"pathspec>=0.12.0",
@@ -39,12 +41,6 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
# Bundled extensions (installable via `specify extension add <name>`)
"extensions/git" = "specify_cli/core_pack/extensions/git"
# Bundled workflows (auto-installed during `specify init`)
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
"presets/lean" = "specify_cli/core_pack/presets/lean"
[project.optional-dependencies]
test = [

View File

@@ -114,19 +114,8 @@ has_git() {
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
check_feature_branch() {
local raw="$1"
local branch="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
@@ -135,9 +124,6 @@ check_feature_branch() {
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
@@ -145,7 +131,7 @@ check_feature_branch() {
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
@@ -153,12 +139,13 @@ check_feature_branch() {
return 0
}
get_feature_dir() { echo "$1/specs/$2"; }
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name
branch_name=$(spec_kit_effective_branch_name "$2")
local branch_name="$2"
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
@@ -207,35 +194,9 @@ get_feature_paths() {
has_git_repo="true"
fi
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Branch-name-based prefix lookup (legacy fallback)
# Use prefix-based lookup to support multiple branches per spec
local feature_dir
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
local _fd
if command -v jq >/dev/null 2>&1; then
_fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null)
elif command -v python3 >/dev/null 2>&1; then
# Fallback: use Python to parse JSON so pretty-printed/multi-line files work
_fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null)
else
# Last resort: single-line grep fallback (won't work on multi-line JSON)
_fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
fi
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi

View File

@@ -327,21 +327,13 @@ SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# If we're already on the branch, continue without another checkout.
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
# Otherwise switch to the existing branch instead of failing.
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
# Switch to the existing branch instead of failing
if ! git checkout "$BRANCH_NAME" 2>/dev/null; then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
@@ -352,12 +344,7 @@ if [ "$DRY_RUN" != true ]; then
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
exit 1
fi
fi

View File

@@ -30,12 +30,12 @@
#
# 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic
# - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist
#
# Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
# Leave empty to update all existing agent files
set -e
@@ -74,7 +74,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
QODER_FILE="$REPO_ROOT/QODER.md"
# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid
# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid
# updating the same file multiple times.
AMP_FILE="$AGENTS_FILE"
SHAI_FILE="$REPO_ROOT/SHAI.md"
@@ -84,9 +84,8 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
BOB_FILE="$AGENTS_FILE"
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
KIMI_FILE="$REPO_ROOT/KIMI.md"
TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md"
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
IFLOW_FILE="$REPO_ROOT/IFLOW.md"
FORGE_FILE="$AGENTS_FILE"
# Template file
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
@@ -117,19 +116,13 @@ log_warning() {
echo "WARNING: $1" >&2
}
# Track temporary files for cleanup on interrupt
_CLEANUP_FILES=()
# Cleanup function for temporary files
cleanup() {
local exit_code=$?
# Disarm traps to prevent re-entrant loop
trap - EXIT INT TERM
if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then
for f in "${_CLEANUP_FILES[@]}"; do
rm -f "$f" "$f.bak" "$f.tmp"
done
fi
rm -f /tmp/agent_update_*_$$
rm -f /tmp/manual_additions_$$
exit $exit_code
}
@@ -274,7 +267,7 @@ get_commands_for_language() {
echo "cargo test && cargo clippy"
;;
*"JavaScript"*|*"TypeScript"*)
echo "npm test && npm run lint"
echo "npm test \\&\\& npm run lint"
;;
*)
echo "# Add commands for $lang"
@@ -287,15 +280,10 @@ get_language_conventions() {
echo "$lang: Follow standard conventions"
}
# Escape sed replacement-side specials for | delimiter.
# & and \ are replacement-side specials; | is our sed delimiter.
_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; }
create_new_agent_file() {
local target_file="$1"
local temp_file="$2"
local project_name
project_name=$(_esc_sed "$3")
local project_name="$3"
local current_date="$4"
if [[ ! -f "$TEMPLATE_FILE" ]]; then
@@ -318,19 +306,18 @@ create_new_agent_file() {
# Replace template placeholders
local project_structure
project_structure=$(get_project_structure "$NEW_PROJECT_TYPE")
project_structure=$(_esc_sed "$project_structure")
local commands
commands=$(get_commands_for_language "$NEW_LANG")
local language_conventions
language_conventions=$(get_language_conventions "$NEW_LANG")
local escaped_lang=$(_esc_sed "$NEW_LANG")
local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK")
commands=$(_esc_sed "$commands")
language_conventions=$(_esc_sed "$language_conventions")
local escaped_branch=$(_esc_sed "$CURRENT_BRANCH")
# Perform substitutions with error checking using safer approach
# Escape special characters for sed by using a different delimiter or escaping
local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g')
local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g')
local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g')
# Build technology stack and recent change strings conditionally
local tech_stack
@@ -373,18 +360,17 @@ create_new_agent_file() {
fi
done
# Convert literal \n sequences to actual newlines (portable — works on BSD + GNU)
awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp"
mv "$temp_file.tmp" "$temp_file"
# Convert \n sequences to actual newlines
newline=$(printf '\n')
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
# Clean up backup files from sed -i.bak
rm -f "$temp_file.bak"
# Clean up backup files
rm -f "$temp_file.bak" "$temp_file.bak2"
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
if [[ "$target_file" == *.mdc ]]; then
local frontmatter_file
frontmatter_file=$(mktemp) || return 1
_CLEANUP_FILES+=("$frontmatter_file")
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
cat "$temp_file" >> "$frontmatter_file"
mv "$frontmatter_file" "$temp_file"
@@ -408,7 +394,6 @@ update_existing_agent_file() {
log_error "Failed to create temporary file"
return 1
}
_CLEANUP_FILES+=("$temp_file")
# Process the file in one pass
local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK")
@@ -533,7 +518,6 @@ update_existing_agent_file() {
if ! head -1 "$temp_file" | grep -q '^---'; then
local frontmatter_file
frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; }
_CLEANUP_FILES+=("$frontmatter_file")
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
cat "$temp_file" >> "$frontmatter_file"
mv "$frontmatter_file" "$temp_file"
@@ -586,7 +570,6 @@ update_agent_file() {
log_error "Failed to create temporary file"
return 1
}
_CLEANUP_FILES+=("$temp_file")
if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then
if mv "$temp_file" "$target_file"; then
@@ -707,18 +690,12 @@ update_specific_agent() {
iflow)
update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1
;;
forge)
update_agent_file "$AGENTS_FILE" "Forge" || return 1
;;
goose)
update_agent_file "$AGENTS_FILE" "Goose" || return 1
;;
generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
;;
*)
log_error "Unknown agent type '$agent_type'"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
exit 1
;;
esac
@@ -762,7 +739,10 @@ update_all_existing_agents() {
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
@@ -803,7 +783,7 @@ print_summary() {
fi
echo
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]"
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
}
#==============================================================================

View File

@@ -127,16 +127,6 @@ function Test-HasGit {
}
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
@@ -148,107 +138,29 @@ function Test-FeatureBranch {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
return $false
}
return $true
}
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
function Find-FeatureDirByPrefix {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$Branch
)
$specsDir = Join-Path $RepoRoot 'specs'
$branchName = Get-SpecKitEffectiveBranchName $Branch
$prefix = $null
if ($branchName -match '^(\d{8}-\d{6})-') {
$prefix = $Matches[1]
} elseif ($branchName -match '^(\d{3,})-') {
$prefix = $Matches[1]
} else {
return (Join-Path $specsDir $branchName)
}
$dirMatches = @()
if (Test-Path -LiteralPath $specsDir -PathType Container) {
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
}
if ($dirMatches.Count -eq 0) {
return (Join-Path $specsDir $branchName)
}
if ($dirMatches.Count -eq 1) {
return $dirMatches[0].FullName
}
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
return $null
}
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
function Get-FeatureDirFromBranchPrefixOrExit {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$CurrentBranch
)
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
if ($null -eq $resolved) {
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
exit 1
}
return $resolved
function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
}
function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
exit 1
}
if ($featureConfig.feature_directory) {
$featureDir = $featureConfig.feature_directory
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
}
} else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
}
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
[PSCustomObject]@{
REPO_ROOT = $repoRoot

View File

@@ -293,37 +293,25 @@ $specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) {
if ($hasGit) {
$branchCreated = $false
$branchCreateError = ''
try {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
git checkout -q -b $branchName 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
# Exception during git command
}
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
# If we're already on the branch, continue without another checkout.
if ($currentBranch -eq $branchName) {
# Already on the target branch — nothing to do
} else {
# Otherwise switch to the existing branch instead of failing.
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
# Switch to the existing branch instead of failing
git checkout -q $branchName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
exit 1
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
@@ -333,11 +321,7 @@ if (-not $DryRun) {
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
}
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
exit 1
}
}

View File

@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
2. Plan Data Extraction
3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic)
.PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
#>
param(
[Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')]
[string]$AgentType
)
@@ -65,10 +65,8 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md'
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'
$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md'
$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
@@ -417,67 +415,36 @@ function Update-SpecificAgent {
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' }
'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false }
}
}
function Update-AllExistingAgents {
$found = $false
$ok = $true
$updatedPaths = @()
# Helper function to update only if file exists and hasn't been updated yet
function Update-IfNew {
param(
[Parameter(Mandatory=$true)]
[string]$FilePath,
[Parameter(Mandatory=$true)]
[string]$AgentName
)
if (-not (Test-Path $FilePath)) { return $true }
# Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md)
$realPath = (Get-Item -LiteralPath $FilePath).FullName
# Check if we've already updated this file
if ($updatedPaths -contains $realPath) {
return $true
}
# Record the file as seen before attempting the update
# Use parent scope (1) to modify Update-AllExistingAgents' local variables
Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1
Set-Variable -Name found -Value $true -Scope 1
# Perform the update
return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName)
}
if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }
if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }
if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }
if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false }
if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }
if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }
if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }
if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }
if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }
if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }
if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }
if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }
if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false }
if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }
if (Test-Path $CLAUDE_FILE) { if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }; $found = $true }
if (Test-Path $GEMINI_FILE) { if (-not (Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false }; $found = $true }
if (Test-Path $COPILOT_FILE) { if (-not (Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }; $found = $true }
if (Test-Path $CURSOR_FILE) { if (-not (Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }; $found = $true }
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
if (Test-Path $JUNIE_FILE) { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }; $found = $true }
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
if (Test-Path $CODEBUDDY_FILE) { if (-not (Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false }; $found = $true }
if (Test-Path $QODER_FILE) { if (-not (Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false }; $found = $true }
if (Test-Path $SHAI_FILE) { if (-not (Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI')) { $ok = $false }; $found = $true }
if (Test-Path $TABNINE_FILE) { if (-not (Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false }; $found = $true }
if (Test-Path $KIRO_FILE) { if (-not (Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI')) { $ok = $false }; $found = $true }
if (Test-Path $AGY_FILE) { if (-not (Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity')) { $ok = $false }; $found = $true }
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true }
if (Test-Path $IFLOW_FILE) { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }; $found = $true }
if (-not $found) {
Write-Info 'No existing agent files found, creating default Claude file...'
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
@@ -492,7 +459,7 @@ function Print-Summary {
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]'
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]'
}
function Main {

View File

@@ -78,7 +78,7 @@ The SDD methodology is significantly enhanced through three powerful commands th
This command transforms a simple feature description (the user-prompt) into a complete, structured specification with automatic repository management:
1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003, …, 1000 — expands beyond 3 digits automatically)
1. **Automatic Feature Numbering**: Scans existing specs to determine the next feature number (e.g., 001, 002, 003)
2. **Branch Creation**: Generates a semantic branch name from your description and creates it automatically
3. **Template-Based Generation**: Copies and customizes the feature specification template with your requirements
4. **Directory Structure**: Creates the proper `specs/[branch-name]/` structure for all related documents

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@ import yaml
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
from specify_cli.integrations import INTEGRATION_REGISTRY
configs: dict[str, dict[str, Any]] = {}
for key, integration in INTEGRATION_REGISTRY.items():
if key == "generic":
@@ -76,7 +75,7 @@ class CommandRegistrar:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3 :].strip()
body = content[end_marker + 3:].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
@@ -101,9 +100,7 @@ class CommandRegistrar:
if not fm:
return ""
yaml_str = yaml.dump(
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
)
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
@@ -149,16 +146,16 @@ class CommandRegistrar:
# ".specify/extensions/<ext>/scripts/..." remain intact.
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
return text.replace(".specify/.specify/", ".specify/").replace(
".specify.specify/", ".specify/"
)
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
self,
frontmatter: dict,
body: str,
source_id: str,
context_note: str = None
) -> str:
"""Render command in Markdown format.
@@ -175,7 +172,12 @@ class CommandRegistrar:
context_note = f"\n<!-- Source: {source_id} -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str:
def render_toml_command(
self,
frontmatter: dict,
body: str,
source_id: str
) -> str:
"""Render command in TOML format.
Args:
@@ -189,9 +191,8 @@ class CommandRegistrar:
toml_lines = []
if "description" in frontmatter:
toml_lines.append(
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
)
desc = frontmatter["description"].replace('"', '\\"')
toml_lines.append(f'description = "{desc}"')
toml_lines.append("")
toml_lines.append(f"# Source: {source_id}")
@@ -208,57 +209,17 @@ class CommandRegistrar:
toml_lines.append(body)
toml_lines.append("'''")
else:
toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}")
escaped_body = (
body.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
toml_lines.append(f'prompt = "{escaped_body}"')
return "\n".join(toml_lines)
@staticmethod
def _render_basic_toml_string(value: str) -> str:
"""Render *value* as a TOML basic string literal."""
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return f'"{escaped}"'
def render_yaml_command(
self,
frontmatter: dict,
body: str,
source_id: str,
cmd_name: str = "",
) -> str:
"""Render command in YAML recipe format for Goose.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
cmd_name: Command name used as title fallback
Returns:
Formatted YAML recipe file content
"""
from specify_cli.integrations.base import YamlIntegration
title = frontmatter.get("title", "") or frontmatter.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title and cmd_name:
title = YamlIntegration._human_title(cmd_name)
if not title and source_id:
title = YamlIntegration._human_title(Path(str(source_id)).stem)
if not title:
title = "Command"
description = frontmatter.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
return YamlIntegration._render_yaml(title, description, body, source_id)
def render_skill_command(
self,
agent_name: str,
@@ -285,13 +246,9 @@ class CommandRegistrar:
frontmatter = {}
if agent_name in {"codex", "kimi"}:
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
description = frontmatter.get(
"description", f"Spec-kit workflow command: {skill_name}"
)
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
skill_frontmatter = self.build_skill_frontmatter(
agent_name,
skill_name,
@@ -317,12 +274,13 @@ class CommandRegistrar:
"source": source,
},
}
if agent_name == "claude":
# Claude skills should only run when explicitly invoked.
skill_frontmatter["disable-model-invocation"] = True
return skill_frontmatter
@staticmethod
def resolve_skill_placeholders(
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
"""Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
@@ -346,9 +304,7 @@ class CommandRegistrar:
script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
)
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts or default_variant in agent_scripts:
@@ -370,9 +326,7 @@ class CommandRegistrar:
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
agent_script_command = (
agent_scripts.get(script_variant) if script_variant else None
)
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
if agent_script_command:
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
@@ -380,9 +334,7 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
) -> str:
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
"""Convert argument placeholder format.
Args:
@@ -396,16 +348,14 @@ class CommandRegistrar:
return content.replace(from_placeholder, to_placeholder)
@staticmethod
def _compute_output_name(
agent_name: str, cmd_name: str, agent_config: Dict[str, Any]
) -> str:
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
return cmd_name
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit.") :]
short_name = short_name[len("speckit."):]
short_name = short_name.replace(".", "-")
return f"speckit-{short_name}"
@@ -417,7 +367,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
context_note: str = None
) -> List[str]:
"""Register commands for a specific agent.
@@ -458,14 +408,6 @@ class CommandRegistrar:
frontmatter = self._adjust_script_paths(frontmatter)
for key in agent_config.get("strip_frontmatter_keys", []):
frontmatter.pop(key, None)
if agent_config.get("inject_name") and not frontmatter.get("name"):
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
@@ -474,24 +416,12 @@ class CommandRegistrar:
if agent_config["extension"] == "/SKILL.md":
output = self.render_skill_command(
agent_name,
output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
)
elif agent_config["format"] == "markdown":
output = self.render_markdown_command(
frontmatter, body, source_id, context_note
)
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
elif agent_config["format"] == "toml":
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name
)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
@@ -505,68 +435,13 @@ class CommandRegistrar:
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
alias_frontmatter["name"] = (
format_name(alias) if format_name else alias
)
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name,
alias_output_name,
alias_frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
alias_frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
alias_output = self.render_toml_command(
alias_frontmatter, body, source_id
)
elif agent_config["format"] == "yaml":
alias_output = self.render_yaml_command(
alias_frontmatter, body, source_id, alias
)
else:
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
else:
# For other agents, reuse the primary output
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name,
alias_output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
alias_file = (
commands_dir / f"{alias_output_name}{agent_config['extension']}"
)
try:
alias_file.resolve().relative_to(commands_dir.resolve())
except ValueError:
raise ValueError(
f"Alias output path escapes commands directory: {alias_file!r}"
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
)
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot":
@@ -594,7 +469,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None,
context_note: str = None
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -617,12 +492,8 @@ class CommandRegistrar:
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name,
commands,
source_id,
source_dir,
project_root,
context_note=context_note,
agent_name, commands, source_id, source_dir, project_root,
context_note=context_note
)
if registered:
results[agent_name] = registered
@@ -632,7 +503,9 @@ class CommandRegistrar:
return results
def unregister_commands(
self, registered_commands: Dict[str, List[str]], project_root: Path
self,
registered_commands: Dict[str, List[str]],
project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
@@ -649,26 +522,13 @@ class CommandRegistrar:
commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names:
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
# For SKILL.md agents each command lives in its own subdirectory
# (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the
# parent dir when it becomes empty to avoid orphaned directories.
parent = cmd_file.parent
if parent != commands_dir and parent.exists():
try:
parent.rmdir() # no-op if dir still has other files
except OSError:
pass
if agent_name == "copilot":
prompt_file = (
project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
)
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
prompt_file.unlink()
@@ -680,3 +540,4 @@ try:
CommandRegistrar._ensure_configs()
except ImportError:
pass

View File

@@ -38,8 +38,6 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
})
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
def _load_core_command_names() -> frozenset[str]:
"""Discover bundled core command names from the packaged templates.
@@ -132,7 +130,6 @@ class ExtensionManifest:
ValidationError: If manifest is invalid
"""
self.path = manifest_path
self.warnings: List[str] = []
self.data = self._load_yaml(manifest_path)
self._validate()
@@ -186,130 +183,20 @@ class ExtensionManifest:
# Validate provides section
provides = self.data["provides"]
commands = provides.get("commands", [])
hooks = self.data.get("hooks")
if "commands" not in provides or not provides["commands"]:
raise ValidationError("Extension must provide at least one command")
if "commands" in provides and not isinstance(commands, list):
raise ValidationError(
"Invalid provides.commands: expected a list"
)
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError(
"Invalid hooks: expected a mapping"
)
has_commands = bool(commands)
has_hooks = bool(hooks)
if not has_commands and not has_hooks:
raise ValidationError(
"Extension must provide at least one command or hook"
)
# Validate hook values (if present)
if hooks:
for hook_name, hook_config in hooks.items():
if not isinstance(hook_config, dict):
raise ValidationError(
f"Invalid hook '{hook_name}': expected a mapping"
)
if not hook_config.get("command"):
raise ValidationError(
f"Hook '{hook_name}' missing required 'command' field"
)
# Validate commands; track renames so hook references can be rewritten.
rename_map: Dict[str, str] = {}
for cmd in commands:
if not isinstance(cmd, dict):
raise ValidationError(
"Each command entry in 'provides.commands' must be a mapping"
)
# Validate commands
for cmd in provides["commands"]:
if "name" not in cmd or "file" not in cmd:
raise ValidationError("Command missing 'name' or 'file'")
# Validate command name format
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
if corrected:
self.warnings.append(
f"Command name '{cmd['name']}' does not follow the required pattern "
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
f"The extension author should update the manifest to use this name."
)
rename_map[cmd["name"]] = corrected
cmd["name"] = corrected
else:
raise ValidationError(
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
)
# Validate alias types; no pattern enforcement on aliases — they are
# intentionally free-form to preserve community extension compatibility
# (e.g. 'speckit.verify' short aliases used by existing extensions).
aliases = cmd.get("aliases")
if aliases is None:
cmd["aliases"] = []
aliases = []
if not isinstance(aliases, list):
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
raise ValidationError(
f"Aliases for command '{cmd['name']}' must be a list"
f"Invalid command name '{cmd['name']}': "
"must follow pattern 'speckit.{extension}.{command}'"
)
for alias in aliases:
if not isinstance(alias, str):
raise ValidationError(
f"Aliases for command '{cmd['name']}' must be strings"
)
# Rewrite any hook command references that pointed at a renamed command or
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
# the reference is changed so extension authors know to update the manifest.
for hook_name, hook_data in self.data.get("hooks", {}).items():
if not isinstance(hook_data, dict):
raise ValidationError(
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}"
)
command_ref = hook_data.get("command")
if not isinstance(command_ref, str):
continue
# Step 1: apply any rename from the auto-correction pass.
after_rename = rename_map.get(command_ref, command_ref)
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
parts = after_rename.split(".")
if len(parts) == 2 and parts[0] == ext["id"]:
final_ref = f"speckit.{ext['id']}.{parts[1]}"
else:
final_ref = after_rename
if final_ref != command_ref:
hook_data["command"] = final_ref
self.warnings.append(
f"Hook '{hook_name}' referenced command '{command_ref}'; "
f"updated to canonical form '{final_ref}'. "
f"The extension author should update the manifest."
)
@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.
Handles the two legacy formats used by community extensions:
- 'speckit.command''speckit.{ext_id}.command'
- '{ext_id}.command''speckit.{ext_id}.command'
The 'X.Y' form is only corrected when X matches ext_id to ensure the
result passes the install-time namespace check. Any other prefix is
uncorrectable and will produce a ValidationError at the call site.
Returns the corrected name, or None if no safe correction is possible.
"""
parts = name.split('.')
if len(parts) == 2:
if parts[0] == 'speckit' or parts[0] == ext_id:
candidate = f"speckit.{ext_id}.{parts[1]}"
if EXTENSION_COMMAND_NAME_PATTERN.match(candidate):
return candidate
return None
@property
def id(self) -> str:
@@ -339,7 +226,7 @@ class ExtensionManifest:
@property
def commands(self) -> List[Dict[str, Any]]:
"""Get list of provided commands."""
return self.data.get("provides", {}).get("commands", [])
return self.data["provides"]["commands"]
@property
def hooks(self) -> Dict[str, Any]:
@@ -607,11 +494,10 @@ class ExtensionManager:
"""Collect command and alias names declared by a manifest.
Performs install-time validation for extension-specific constraints:
- primary commands must use the canonical `speckit.{extension}.{command}` shape
- primary commands must use this extension's namespace
- commands and aliases must use the canonical `speckit.{extension}.{command}` shape
- commands and aliases must use this extension's namespace
- command namespaces must not shadow core commands
- duplicate command/alias names inside one manifest are rejected
- aliases are validated for type and uniqueness only (no pattern enforcement)
Args:
manifest: Parsed extension manifest
@@ -648,26 +534,23 @@ class ExtensionManager:
f"{kind.capitalize()} for command '{primary_name}' must be a string"
)
# Enforce canonical pattern only for primary command names;
# aliases are free-form to preserve community extension compat.
if kind == "command":
match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
if match is None:
raise ValidationError(
f"Invalid {kind} '{name}': "
"must follow pattern 'speckit.{extension}.{command}'"
)
match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
if match is None:
raise ValidationError(
f"Invalid {kind} '{name}': "
"must follow pattern 'speckit.{extension}.{command}'"
)
namespace = match.group(1)
if namespace != manifest.id:
raise ValidationError(
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
)
namespace = match.group(1)
if namespace != manifest.id:
raise ValidationError(
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
)
if namespace in CORE_COMMAND_NAMES:
raise ValidationError(
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
)
if namespace in CORE_COMMAND_NAMES:
raise ValidationError(
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
)
if name in declared_names:
raise ValidationError(
@@ -850,7 +733,6 @@ class ExtensionManager:
from . import load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
import yaml
written: List[str] = []
@@ -861,7 +743,6 @@ class ExtensionManager:
if not isinstance(selected_ai, str) or not selected_ai:
return []
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
for cmd_info in manifest.commands:
cmd_name = cmd_info["name"]
@@ -941,10 +822,6 @@ class ExtensionManager:
f"# {title_name} Skill\n\n"
f"{body}\n"
)
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(
skill_content
)
skill_file.write_text(skill_content, encoding="utf-8")
written.append(skill_name)
@@ -1960,14 +1837,6 @@ class ExtensionCatalog:
if not ext_info:
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
# Bundled extensions without a download URL must be installed locally
if ext_info.get("bundled") and not ext_info.get("download_url"):
raise ExtensionError(
f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Try reinstalling: {REINSTALL_COMMAND}"
)
download_url = ext_info.get("download_url")
if not download_url:
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
@@ -2268,7 +2137,6 @@ class HookExecutor:
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
@@ -2277,8 +2145,6 @@ class HookExecutor:
return f"/{skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
return f"/{command_id}"

View File

@@ -36,7 +36,6 @@ def get_integration(key: str) -> IntegrationBase | None:
# -- Register built-in integrations --------------------------------------
def _register_builtins() -> None:
"""Register all built-in integrations.
@@ -52,14 +51,12 @@ def _register_builtins() -> None:
from .auggie import AuggieIntegration
from .bob import BobIntegration
from .claude import ClaudeIntegration
from .codebuddy import CodebuddyIntegration
from .codex import CodexIntegration
from .codebuddy import CodebuddyIntegration
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .goose import GooseIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
@@ -82,14 +79,12 @@ def _register_builtins() -> None:
_register(AuggieIntegration())
_register(BobIntegration())
_register(ClaudeIntegration())
_register(CodebuddyIntegration())
_register(CodexIntegration())
_register(CodebuddyIntegration())
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())

View File

@@ -28,7 +28,6 @@ if TYPE_CHECKING:
# IntegrationOption
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class IntegrationOption:
"""Declares an option that an integration accepts via ``--integration-options``.
@@ -52,7 +51,6 @@ class IntegrationOption:
# IntegrationBase — abstract base class
# ---------------------------------------------------------------------------
class IntegrationBase(ABC):
"""Abstract base class every integration must implement.
@@ -91,123 +89,6 @@ class IntegrationBase(ABC):
"""Return options this integration accepts. Default: none."""
return []
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive execution.
Returns a list of command-line tokens that will execute *prompt*
non-interactively using this integration's CLI tool, or ``None``
if the integration does not support CLI dispatch.
Subclasses for CLI-based integrations should override this.
"""
return None
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build the native slash-command invocation for a Spec Kit command.
The CLI tools discover and execute commands from installed files
on disk. This method builds the invocation string the CLI
expects — e.g. ``"/speckit.specify my-feature"`` for markdown
agents or ``"/speckit-specify my-feature"`` for skills agents.
*command_name* may be a full dotted name like
``"speckit.specify"`` or a bare stem like ``"specify"``.
"""
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
invocation = f"/speckit.{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation
def dispatch_command(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
model: str | None = None,
timeout: int = 600,
stream: bool = True,
) -> dict[str, Any]:
"""Dispatch a Spec Kit command through this integration's CLI.
By default this builds a slash-command invocation with
``build_command_invocation()`` and passes that prompt to
``build_exec_args()`` to construct the CLI command line.
Integrations with custom dispatch behavior can override
``build_command_invocation()``, ``build_exec_args()``, or
``dispatch_command()`` directly.
When *stream* is ``True`` (the default), stdout and stderr are
piped directly to the terminal so the user sees live output.
When ``False``, output is captured and returned in the dict.
Returns a dict with ``exit_code``, ``stdout``, and ``stderr``.
Raises ``NotImplementedError`` if the integration does not
support CLI dispatch.
"""
import subprocess
prompt = self.build_command_invocation(command_name, args)
# When streaming to the terminal, request text output so the
# user sees readable output instead of raw JSONL events.
exec_args = self.build_exec_args(
prompt, model=model, output_json=not stream
)
if exec_args is None:
msg = (
f"Integration {self.key!r} does not support CLI dispatch. "
f"Override build_exec_args() to enable it."
)
raise NotImplementedError(msg)
cwd = str(project_root) if project_root else None
if stream:
# No timeout when streaming — the user sees live output and
# can Ctrl+C at any time. The timeout parameter is only
# applied in the captured (non-streaming) branch below.
try:
result = subprocess.run(
exec_args,
text=True,
cwd=cwd,
)
except KeyboardInterrupt:
return {
"exit_code": 130,
"stdout": "",
"stderr": "Interrupted by user",
}
return {
"exit_code": result.returncode,
"stdout": "",
"stderr": "",
}
result = subprocess.run(
exec_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)
return {
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
# -- Primitives — building blocks for setup() -------------------------
def shared_commands_dir(self) -> Path | None:
@@ -394,7 +275,7 @@ class IntegrationBase(ABC):
2. Replace ``{SCRIPT}`` with the extracted script command
3. Extract ``agent_scripts.<script_type>`` and replace ``{AGENT_SCRIPT}``
4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter
5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
5. Replace ``{ARGS}`` with *arg_placeholder*
6. Replace ``__AGENT__`` with *agent_name*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
"""
@@ -467,9 +348,8 @@ class IntegrationBase(ABC):
output_lines.append(line)
content = "".join(output_lines)
# 5. Replace {ARGS} and $ARGUMENTS
# 5. Replace {ARGS}
content = content.replace("{ARGS}", arg_placeholder)
content = content.replace("$ARGUMENTS", arg_placeholder)
# 6. Replace __AGENT__
content = content.replace("__AGENT__", agent_name)
@@ -478,7 +358,6 @@ class IntegrationBase(ABC):
# CommandRegistrar so extension-local paths are preserved and
# boundary rules stay consistent across the codebase.
from specify_cli.agents import CommandRegistrar
content = CommandRegistrar.rewrite_project_relative_paths(content)
return content
@@ -554,7 +433,9 @@ class IntegrationBase(ABC):
**opts: Any,
) -> list[Path]:
"""High-level install — calls ``setup()`` and returns created files."""
return self.setup(project_root, manifest, parsed_options=parsed_options, **opts)
return self.setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
def uninstall(
self,
@@ -571,7 +452,6 @@ class IntegrationBase(ABC):
# MarkdownIntegration — covers ~20 standard agents
# ---------------------------------------------------------------------------
class MarkdownIntegration(IntegrationBase):
"""Concrete base for integrations that use standard Markdown commands.
@@ -583,22 +463,6 @@ class MarkdownIntegration(IntegrationBase):
integration-specific scripts (``update-context.sh`` / ``.ps1``).
"""
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def setup(
self,
project_root: Path,
@@ -628,18 +492,12 @@ class MarkdownIntegration(IntegrationBase):
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "$ARGUMENTS")
if self.registrar_config
else "$ARGUMENTS"
)
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
@@ -654,7 +512,6 @@ class MarkdownIntegration(IntegrationBase):
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
# ---------------------------------------------------------------------------
class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
@@ -667,22 +524,6 @@ class TomlIntegration(IntegrationBase):
TOML format (``description`` key + ``prompt`` multiline string).
"""
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["-m", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def command_filename(self, template_name: str) -> str:
"""TOML commands use ``.toml`` extension."""
return f"speckit.{template_name}.toml"
@@ -691,89 +532,23 @@ class TomlIntegration(IntegrationBase):
def _extract_description(content: str) -> str:
"""Extract the ``description`` value from YAML frontmatter.
Parses the YAML frontmatter so block scalar descriptions (``|``
and ``>``) keep their YAML semantics instead of being treated as
raw text.
Scans lines between the first pair of ``---`` delimiters for a
top-level ``description:`` key. Returns the value (with
surrounding quotes stripped) or an empty string if not found.
"""
import yaml
frontmatter_text, _ = TomlIntegration._split_frontmatter(content)
if not frontmatter_text:
return ""
try:
frontmatter = yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return ""
if not isinstance(frontmatter, dict):
return ""
description = frontmatter.get("description", "")
if isinstance(description, str):
return description
in_frontmatter = False
for line in content.splitlines():
stripped = line.rstrip("\n\r")
if stripped == "---":
if not in_frontmatter:
in_frontmatter = True
continue
break # second ---
if in_frontmatter and stripped.startswith("description:"):
_, _, value = stripped.partition(":")
return value.strip().strip('"').strip("'")
return ""
@staticmethod
def _split_frontmatter(content: str) -> tuple[str, str]:
"""Split YAML frontmatter from the remaining content.
Returns ``("", content)`` when no complete frontmatter block is
present. The body is preserved exactly as written so prompt text
keeps its intended formatting.
"""
if not content.startswith("---"):
return "", content
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return "", content
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return "", content
frontmatter = "".join(lines[1:frontmatter_end])
body = "".join(lines[frontmatter_end + 1 :])
return frontmatter, body
@staticmethod
def _render_toml_string(value: str) -> str:
"""Render *value* as a TOML string literal.
Uses a basic string for single-line values, multiline basic
strings for values containing newlines, and falls back to a
literal string or escaped basic string when delimiters appear in
the content.
"""
if "\n" not in value and "\r" not in value:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
escaped = value.replace("\\", "\\\\")
if '"""' not in escaped:
if escaped.endswith('"'):
return '"""\n' + escaped + '\\\n"""'
return '"""\n' + escaped + '"""'
if "'''" not in value and not value.endswith("'"):
return "'''\n" + value + "'''"
return (
'"'
+ (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
+ '"'
)
@staticmethod
def _render_toml(description: str, body: str) -> str:
"""Render a TOML command file from description and body.
@@ -783,21 +558,39 @@ class TomlIntegration(IntegrationBase):
to multiline literal strings (``'''``) if the body contains
``\"\"\"``, then to an escaped basic string as a last resort.
The body is ``rstrip("\\n")``'d before rendering, so the TOML
value preserves content without forcing a trailing newline. As a
result, multiline delimiters appear on their own line only when
the rendered value itself ends with a newline.
The body is rstrip'd so the closing delimiter appears on the line
immediately after the last content line — matching the release
script's ``echo "$body"; echo '\"\"\"'`` pattern.
"""
toml_lines: list[str] = []
if description:
toml_lines.append(
f"description = {TomlIntegration._render_toml_string(description)}"
)
desc = description.replace('"', '\\"')
toml_lines.append(f'description = "{desc}"')
toml_lines.append("")
body = body.rstrip("\n")
toml_lines.append(f"prompt = {TomlIntegration._render_toml_string(body)}")
# Escape backslashes for basic multiline strings.
escaped = body.replace("\\", "\\\\")
if '"""' not in escaped:
toml_lines.append('prompt = """')
toml_lines.append(escaped)
toml_lines.append('"""')
elif "'''" not in body:
toml_lines.append("prompt = '''")
toml_lines.append(body)
toml_lines.append("'''")
else:
escaped_body = (
body.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
toml_lines.append(f'prompt = "{escaped_body}"')
return "\n".join(toml_lines) + "\n"
@@ -830,21 +623,14 @@ class TomlIntegration(IntegrationBase):
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "{{args}}")
if self.registrar_config
else "{{args}}"
)
arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}"
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
toml_content = self._render_toml(description, processed)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
toml_content, dest / dst_name, project_root, manifest
@@ -855,188 +641,6 @@ class TomlIntegration(IntegrationBase):
return created
# ---------------------------------------------------------------------------
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
Mirrors ``TomlIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
YAML recipe format (version, title, description, prompt block scalar).
"""
def command_filename(self, template_name: str) -> str:
"""YAML commands use ``.yaml`` extension."""
return f"speckit.{template_name}.yaml"
@staticmethod
def _extract_frontmatter(content: str) -> dict[str, Any]:
"""Extract frontmatter as a dict from YAML frontmatter block."""
import yaml
if not content.startswith("---"):
return {}
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return {}
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return {}
frontmatter_text = "".join(lines[1:frontmatter_end])
try:
fm = yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return {}
return fm if isinstance(fm, dict) else {}
@staticmethod
def _split_frontmatter(content: str) -> tuple[str, str]:
"""Split YAML frontmatter from the remaining body content."""
if not content.startswith("---"):
return "", content
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return "", content
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return "", content
frontmatter = "".join(lines[1:frontmatter_end])
body = "".join(lines[frontmatter_end + 1 :])
return frontmatter, body
@staticmethod
def _human_title(identifier: str) -> str:
"""Convert an identifier to a human-readable title.
Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``,
and ``_`` with spaces before title-casing.
"""
text = identifier
if text.startswith("speckit."):
text = text[len("speckit.") :]
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
@staticmethod
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
import yaml
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
header_yaml = yaml.safe_dump(
header,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
).strip()
# Indent each line for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
return "\n".join(lines) + "\n"
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = self.commands_dest(project_root).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "{{args}}")
if self.registrar_config
else "{{args}}"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
fm = self._extract_frontmatter(raw)
description = fm.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
title = fm.get("title", "") or fm.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title:
title = self._human_title(src_file.stem)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
title, description, body, f"templates/commands/{src_file.name}"
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
yaml_content, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created
# ---------------------------------------------------------------------------
# SkillsIntegration — skills-format agents (Codex, Kimi, Agy)
# ---------------------------------------------------------------------------
@@ -1057,22 +661,6 @@ class SkillsIntegration(IntegrationBase):
``speckit-<name>/SKILL.md`` file with skills-oriented frontmatter.
"""
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def skills_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the skills output directory.
@@ -1082,7 +670,9 @@ class SkillsIntegration(IntegrationBase):
Raises ``ValueError`` when ``config`` or ``folder`` is missing.
"""
if not self.config:
raise ValueError(f"{type(self).__name__}.config is not set.")
raise ValueError(
f"{type(self).__name__}.config is not set."
)
folder = self.config.get("folder")
if not folder:
raise ValueError(
@@ -1091,27 +681,6 @@ class SkillsIntegration(IntegrationBase):
subdir = self.config.get("commands_subdir", "skills")
return project_root / folder / subdir
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
invocation = f"/speckit-{stem}"
if args:
invocation = f"{invocation} {args}"
return invocation
def post_process_skill_content(self, content: str) -> str:
"""Post-process a SKILL.md file's content after generation.
Called by external skill generators (presets, extensions) to let
the integration inject agent-specific frontmatter or body
transformations. The default implementation returns *content*
unchanged. Subclasses may override — see ``ClaudeIntegration``.
"""
return content
def setup(
self,
project_root: Path,

View File

@@ -1,626 +0,0 @@
"""Integration catalog — discovery, validation, and upgrade support.
Provides:
- ``IntegrationCatalogEntry`` — single catalog source metadata.
- ``IntegrationCatalog`` — fetches, caches, and searches integration
catalogs (built-in + community).
- ``IntegrationDescriptor`` — loads and validates ``integration.yml``.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from packaging import version as pkg_version
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class IntegrationCatalogError(Exception):
"""Raised when a catalog operation fails."""
class IntegrationDescriptorError(Exception):
"""Raised when an integration.yml descriptor is invalid."""
# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------
@dataclass
class IntegrationCatalogEntry:
"""Represents a single catalog source in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
# ---------------------------------------------------------------------------
# IntegrationCatalog
# ---------------------------------------------------------------------------
class IntegrationCatalog:
"""Manages integration catalog fetching, caching, and searching."""
DEFAULT_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json"
)
COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json"
)
CACHE_DURATION = 3600 # 1 hour
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.cache_dir = project_root / ".specify" / "integrations" / ".cache"
# -- URL validation ---------------------------------------------------
@staticmethod
def _validate_catalog_url(url: str) -> None:
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise IntegrationCatalogError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise IntegrationCatalogError(
"Catalog URL must be a valid URL with a host."
)
# -- Catalog stack ----------------------------------------------------
def _load_catalog_config(
self, config_path: Path
) -> Optional[List[IntegrationCatalogEntry]]:
"""Load catalog stack from a YAML file.
Returns None when the file does not exist.
Raises:
IntegrationCatalogError: on invalid content
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise IntegrationCatalogError(
f"Failed to read catalog config {config_path}: {exc}"
)
if not isinstance(data, dict):
raise IntegrationCatalogError(
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
)
catalogs_data = data.get("catalogs", [])
if not isinstance(catalogs_data, list):
raise IntegrationCatalogError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
raise IntegrationCatalogError(
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
f"Remove the file to use built-in defaults, or add valid catalog entries."
)
entries: List[IntegrationCatalogEntry] = []
skipped: List[int] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise IntegrationCatalogError(
f"Invalid catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
skipped.append(idx)
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise IntegrationCatalogError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
entries.append(
IntegrationCatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
)
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise IntegrationCatalogError(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs (entries at indices {skipped} "
f"were skipped). Each catalog entry must have a 'url' field."
)
return entries
def get_active_catalogs(self) -> List[IntegrationCatalogEntry]:
"""Return the ordered list of active integration catalogs.
Resolution:
1. ``SPECKIT_INTEGRATION_CATALOG_URL`` env var
2. Project ``.specify/integration-catalogs.yml``
3. User ``~/.specify/integration-catalogs.yml``
4. Built-in defaults (built-in + community)
"""
import sys
env_value = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
if env_value:
self._validate_catalog_url(env_value)
if env_value != self.DEFAULT_CATALOG_URL:
if not getattr(self, "_non_default_catalog_warning_shown", False):
print(
"Warning: Using non-default integration catalog. "
"Only use catalogs from sources you trust.",
file=sys.stderr,
)
self._non_default_catalog_warning_shown = True
return [
IntegrationCatalogEntry(
url=env_value,
name="custom",
priority=1,
install_allowed=True,
description="Custom catalog via SPECKIT_INTEGRATION_CATALOG_URL",
)
]
project_cfg = self.project_root / ".specify" / "integration-catalogs.yml"
catalogs = self._load_catalog_config(project_cfg)
if catalogs is not None:
return catalogs
user_cfg = Path.home() / ".specify" / "integration-catalogs.yml"
catalogs = self._load_catalog_config(user_cfg)
if catalogs is not None:
return catalogs
return [
IntegrationCatalogEntry(
url=self.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
description="Built-in catalog of installable integrations",
),
IntegrationCatalogEntry(
url=self.COMMUNITY_CATALOG_URL,
name="community",
priority=2,
install_allowed=False,
description="Community-contributed integrations (discovery only)",
),
]
# -- Fetching ---------------------------------------------------------
def _fetch_single_catalog(
self,
entry: IntegrationCatalogEntry,
force_refresh: bool = False,
) -> Dict[str, Any]:
"""Fetch one catalog, with per-URL caching."""
import urllib.error
import urllib.request
url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"catalog-{url_hash}.json"
cache_meta = self.cache_dir / f"catalog-{url_hash}-metadata.json"
if not force_refresh and cache_file.exists() and cache_meta.exists():
try:
meta = json.loads(cache_meta.read_text(encoding="utf-8"))
cached_at = datetime.fromisoformat(meta.get("cached_at", ""))
if cached_at.tzinfo is None:
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age < self.CACHE_DURATION:
return json.loads(cache_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
# Cache is invalid or stale metadata; delete and refetch from source.
try:
cache_file.unlink(missing_ok=True)
cache_meta.unlink(missing_ok=True)
except OSError:
pass # Cache cleanup is best-effort; ignore deletion failures.
try:
with urllib.request.urlopen(entry.url, timeout=10) as resp:
# Validate final URL after redirects
final_url = resp.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(resp.read())
if not isinstance(catalog_data, dict):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}: expected a JSON object"
)
if (
"schema_version" not in catalog_data
or "integrations" not in catalog_data
):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}"
)
if not isinstance(catalog_data.get("integrations"), dict):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
)
try:
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_text(json.dumps(catalog_data, indent=2), encoding="utf-8")
cache_meta.write_text(
json.dumps(
{
"cached_at": datetime.now(timezone.utc).isoformat(),
"catalog_url": entry.url,
},
indent=2,
),
encoding="utf-8",
)
except OSError:
pass # Cache is best-effort; proceed with fetched data
return catalog_data
except urllib.error.URLError as exc:
raise IntegrationCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
)
except json.JSONDecodeError as exc:
raise IntegrationCatalogError(
f"Invalid JSON in catalog from {entry.url}: {exc}"
)
def _get_merged_integrations(
self, force_refresh: bool = False
) -> List[Dict[str, Any]]:
"""Fetch and merge integrations from all active catalogs.
Catalogs are processed in the order returned by
:meth:`get_active_catalogs`. On conflicts, the first catalog in that
order wins (lower numeric priority = higher precedence). Each dict is
annotated with ``_catalog_name`` and ``_install_allowed``.
"""
import sys
active = self.get_active_catalogs()
merged: Dict[str, Dict[str, Any]] = {}
any_success = False
for entry in active:
try:
data = self._fetch_single_catalog(entry, force_refresh)
any_success = True
except IntegrationCatalogError as exc:
print(
f"Warning: Could not fetch catalog '{entry.name}': {exc}",
file=sys.stderr,
)
continue
for integ_id, integ_data in data.get("integrations", {}).items():
if not isinstance(integ_data, dict):
continue
if integ_id not in merged:
merged[integ_id] = {
**integ_data,
"id": integ_id,
"_catalog_name": entry.name,
"_install_allowed": entry.install_allowed,
}
if not any_success and active:
raise IntegrationCatalogError(
"Failed to fetch any integration catalog"
)
return list(merged.values())
# -- Search / info ----------------------------------------------------
def search(
self,
query: Optional[str] = None,
tag: Optional[str] = None,
author: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Search catalogs for integrations matching the given filters."""
results: List[Dict[str, Any]] = []
for item in self._get_merged_integrations():
author_val = item.get("author", "")
if not isinstance(author_val, str):
author_val = str(author_val) if author_val is not None else ""
if author and author_val.lower() != author.lower():
continue
if tag:
raw_tags = item.get("tags", [])
tags_list = raw_tags if isinstance(raw_tags, list) else []
if tag.lower() not in [t.lower() for t in tags_list if isinstance(t, str)]:
continue
if query:
raw_tags = item.get("tags", [])
tags_list = raw_tags if isinstance(raw_tags, list) else []
name_val = item.get("name", "")
desc_val = item.get("description", "")
id_val = item.get("id", "")
haystack = " ".join(
[
str(name_val) if name_val else "",
str(desc_val) if desc_val else "",
str(id_val) if id_val else "",
]
+ [t for t in tags_list if isinstance(t, str)]
).lower()
if query.lower() not in haystack:
continue
results.append(item)
return results
def get_integration_info(
self, integration_id: str
) -> Optional[Dict[str, Any]]:
"""Return catalog metadata for a single integration, or None."""
for item in self._get_merged_integrations():
if item["id"] == integration_id:
return item
return None
# -- Cache management -------------------------------------------------
def clear_cache(self) -> None:
"""Remove all cached catalog files."""
if self.cache_dir.exists():
for pattern in ("catalog-*.json", "catalog-*-metadata.json"):
for f in self.cache_dir.glob(pattern):
f.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# IntegrationDescriptor (integration.yml)
# ---------------------------------------------------------------------------
class IntegrationDescriptor:
"""Loads and validates an ``integration.yml`` descriptor.
The descriptor mirrors ``extension.yml`` and ``preset.yml``::
schema_version: "1.0"
integration:
id: "my-agent"
name: "My Agent"
version: "1.0.0"
description: "Integration for My Agent"
author: "my-org"
requires:
speckit_version: ">=0.6.0"
tools: [...]
provides:
commands: [...]
scripts: [...]
"""
SCHEMA_VERSION = "1.0"
REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"]
def __init__(self, descriptor_path: Path) -> None:
self.path = descriptor_path
self.data = self._load(descriptor_path)
self._validate()
# -- Loading ----------------------------------------------------------
@staticmethod
def _load(path: Path) -> dict:
try:
with open(path, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
except yaml.YAMLError as exc:
raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}")
except FileNotFoundError:
raise IntegrationDescriptorError(f"Descriptor not found: {path}")
except (OSError, UnicodeError) as exc:
raise IntegrationDescriptorError(
f"Unable to read descriptor {path}: {exc}"
)
# -- Validation -------------------------------------------------------
def _validate(self) -> None:
if not isinstance(self.data, dict):
raise IntegrationDescriptorError(
f"Descriptor root must be a YAML mapping, got {type(self.data).__name__}"
)
for field in self.REQUIRED_TOP_LEVEL:
if field not in self.data:
raise IntegrationDescriptorError(
f"Missing required field: {field}"
)
if self.data["schema_version"] != self.SCHEMA_VERSION:
raise IntegrationDescriptorError(
f"Unsupported schema version: {self.data['schema_version']} "
f"(expected {self.SCHEMA_VERSION})"
)
integ = self.data["integration"]
if not isinstance(integ, dict):
raise IntegrationDescriptorError(
"'integration' must be a mapping"
)
for field in ("id", "name", "version", "description"):
if field not in integ:
raise IntegrationDescriptorError(
f"Missing integration.{field}"
)
if not isinstance(integ[field], str):
raise IntegrationDescriptorError(
f"integration.{field} must be a string, got {type(integ[field]).__name__}"
)
if not re.match(r"^[a-z0-9-]+$", integ["id"]):
raise IntegrationDescriptorError(
f"Invalid integration ID '{integ['id']}': "
"must be lowercase alphanumeric with hyphens only"
)
try:
pkg_version.Version(integ["version"])
except (pkg_version.InvalidVersion, TypeError):
raise IntegrationDescriptorError(
f"Invalid version '{integ['version']}'"
)
requires = self.data["requires"]
if not isinstance(requires, dict):
raise IntegrationDescriptorError(
"'requires' must be a mapping"
)
if "speckit_version" not in requires:
raise IntegrationDescriptorError(
"Missing requires.speckit_version"
)
if not isinstance(requires["speckit_version"], str) or not requires["speckit_version"].strip():
raise IntegrationDescriptorError(
"requires.speckit_version must be a non-empty string"
)
tools = requires.get("tools")
if tools is not None:
if not isinstance(tools, list):
raise IntegrationDescriptorError(
"requires.tools must be a list"
)
for tool in tools:
if not isinstance(tool, dict):
raise IntegrationDescriptorError(
"Each requires.tools entry must be a mapping"
)
tool_name = tool.get("name")
if not isinstance(tool_name, str) or not tool_name.strip():
raise IntegrationDescriptorError(
"requires.tools entry 'name' must be a non-empty string"
)
provides = self.data["provides"]
if not isinstance(provides, dict):
raise IntegrationDescriptorError(
"'provides' must be a mapping"
)
commands = provides.get("commands", [])
scripts = provides.get("scripts", [])
if "commands" in provides and not isinstance(commands, list):
raise IntegrationDescriptorError(
"Invalid provides.commands: expected a list"
)
if "scripts" in provides and not isinstance(scripts, list):
raise IntegrationDescriptorError(
"Invalid provides.scripts: expected a list"
)
if not commands and not scripts:
raise IntegrationDescriptorError(
"Integration must provide at least one command or script"
)
for cmd in commands:
if not isinstance(cmd, dict):
raise IntegrationDescriptorError(
"Each command entry must be a mapping"
)
if "name" not in cmd or "file" not in cmd:
raise IntegrationDescriptorError(
"Command entry missing 'name' or 'file'"
)
cmd_name = cmd["name"]
cmd_file = cmd["file"]
if not isinstance(cmd_name, str) or not cmd_name.strip():
raise IntegrationDescriptorError(
"Command entry 'name' must be a non-empty string"
)
if not isinstance(cmd_file, str) or not cmd_file.strip():
raise IntegrationDescriptorError(
"Command entry 'file' must be a non-empty string"
)
if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor:
raise IntegrationDescriptorError(
f"Command entry 'file' must be a relative path without '..': {cmd_file}"
)
for script_entry in scripts:
if not isinstance(script_entry, str) or not script_entry.strip():
raise IntegrationDescriptorError(
"Script entry must be a non-empty string"
)
if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor:
raise IntegrationDescriptorError(
f"Script entry must be a relative path without '..': {script_entry}"
)
# -- Property accessors -----------------------------------------------
@property
def id(self) -> str:
return self.data["integration"]["id"]
@property
def name(self) -> str:
return self.data["integration"]["name"]
@property
def version(self) -> str:
return self.data["integration"]["version"]
@property
def description(self) -> str:
return self.data["integration"]["description"]
@property
def requires_speckit_version(self) -> str:
return self.data["requires"]["speckit_version"]
@property
def commands(self) -> List[Dict[str, Any]]:
return self.data.get("provides", {}).get("commands", [])
@property
def scripts(self) -> List[str]:
return self.data.get("provides", {}).get("scripts", [])
@property
def tools(self) -> List[Dict[str, Any]]:
return self.data.get("requires", {}).get("tools") or []
def get_hash(self) -> str:
"""SHA-256 hash of the descriptor file."""
with open(self.path, "rb") as fh:
return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}"

View File

@@ -5,35 +5,11 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
import re
import yaml
from ..base import SkillsIntegration
from ..manifest import IntegrationManifest
# Note injected into hook sections so Claude maps dot-notation command
# names (from extensions.yml) to the hyphenated skill names it uses.
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
# Mapping of command template stem → argument-hint text shown inline
# when a user invokes the slash command in Claude Code.
ARGUMENT_HINTS: dict[str, str] = {
"specify": "Describe the feature you want to specify",
"plan": "Optional guidance for the planning phase",
"tasks": "Optional task generation constraints",
"implement": "Optional implementation guidance or task filter",
"analyze": "Optional focus areas for analysis",
"clarify": "Optional areas to clarify in the spec",
"constitution": "Principles or values for the project constitution",
"checklist": "Domain or focus area for the checklist",
"taskstoissues": "Optional filter or label for GitHub issues",
}
class ClaudeIntegration(SkillsIntegration):
"""Integration for Claude Code skills."""
@@ -54,53 +30,10 @@ class ClaudeIntegration(SkillsIntegration):
}
context_file = "CLAUDE.md"
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
Skips injection if ``argument-hint:`` already exists in the
frontmatter to avoid duplicate keys.
"""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if argument-hint already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith("argument-hint:"):
return content # already present
out: list[str] = []
in_fm = False
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
in_fm = dash_count == 1
out.append(line)
continue
if in_fm and not injected and stripped.startswith("description:"):
out.append(line)
# Preserve the exact line-ending style (\r\n vs \n)
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
out.append(f'argument-hint: "{escaped}"{eol}')
injected = True
continue
out.append(line)
return "".join(out)
def command_filename(self, template_name: str) -> str:
"""Claude skills live at .claude/skills/<name>/SKILL.md."""
skill_name = f"speckit-{template_name.replace('.', '-')}"
return f"{skill_name}/SKILL.md"
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
"""Render a processed command template as a Claude skill."""
@@ -121,80 +54,6 @@ class ClaudeIntegration(SkillsIntegration):
self.key, name, description, source
)
@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""Insert ``key: value`` before the closing ``---`` if not already present."""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content
# Inject before the closing --- of frontmatter
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
injected = True
out.append(line)
return "".join(out)
@staticmethod
def _inject_hook_command_note(content: str) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
Targets the line ``- For each executable hook, output the following``
and inserts the note on the line before it, matching its indentation.
Skips if the note is already present.
"""
if "replace dots" in content:
return content
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
eol = m.group(3)
return (
indent
+ _HOOK_COMMAND_NOTE.rstrip("\n")
+ eol
+ indent
+ instruction
+ eol
)
return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)
def post_process_skill_content(self, content: str) -> str:
"""Inject Claude-specific frontmatter flags and hook notes."""
updated = self._inject_frontmatter_flag(content, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
updated = self._inject_hook_command_note(updated)
return updated
def setup(
self,
project_root: Path,
@@ -202,37 +61,49 @@ class ClaudeIntegration(SkillsIntegration):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
created = super().setup(project_root, manifest, parsed_options, **opts)
"""Install Claude skills into .claude/skills."""
templates = self.list_command_templates()
if not templates:
return []
# Post-process generated skill files
skills_dir = self.skills_dest(project_root).resolve()
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
for path in created:
# Only touch SKILL.md files under the skills directory
try:
path.resolve().relative_to(skills_dir)
except ValueError:
continue
if path.name != "SKILL.md":
continue
dest = self.skills_dest(project_root).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
from specify_cli.agents import CommandRegistrar
registrar = CommandRegistrar()
created: list[Path] = []
updated = self.post_process_skill_content(content)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
frontmatter, body = registrar.parse_frontmatter(processed)
if not isinstance(frontmatter, dict):
frontmatter = {}
# Inject argument-hint if available for this skill
skill_dir_name = path.parent.name # e.g. "speckit-plan"
stem = skill_dir_name
if stem.startswith("speckit-"):
stem = stem[len("speckit-"):]
hint = ARGUMENT_HINTS.get(stem, "")
if hint:
updated = self.inject_argument_hint(updated, hint)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
rendered = self._render_skill(src_file.stem, frontmatter, body)
dst_file = self.write_file_and_record(
rendered,
dest / self.command_filename(src_file.stem),
project_root,
manifest,
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created

View File

@@ -28,21 +28,6 @@ class CodexIntegration(SkillsIntegration):
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
args: list[str] = ["codex", "exec", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.append("--json")
return args
@classmethod
def options(cls) -> list[IntegrationOption]:
return [

View File

@@ -19,19 +19,14 @@ from ..manifest import IntegrationManifest
class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately.
"""
"""Integration for GitHub Copilot in VS Code."""
key = "copilot"
config = {
"name": "GitHub Copilot",
"folder": ".github/",
"commands_subdir": "agents",
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
@@ -42,101 +37,6 @@ class CopilotIntegration(IntegrationBase):
}
context_file = ".github/copilot-instructions.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# GitHub Copilot CLI uses ``copilot -p "prompt"`` for
# non-interactive mode. --allow-all-tools is required for the
# agent to perform file edits and shell commands. Controlled
# by SPECKIT_ALLOW_ALL_TOOLS env var (default: enabled).
import os
args = ["copilot", "-p", prompt]
if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0":
args.append("--allow-all-tools")
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Copilot agents are not slash-commands — just return the args as prompt."""
return args or ""
def dispatch_command(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
model: str | None = None,
timeout: int = 600,
stream: bool = True,
) -> dict[str, Any]:
"""Dispatch via ``--agent speckit.<stem>`` instead of slash-commands.
Copilot ``.agent.md`` files are agents, not skills. The CLI
selects them with ``--agent <name>`` and the prompt is just
the user's arguments.
"""
import subprocess
stem = command_name
if "." in stem:
stem = stem.rsplit(".", 1)[-1]
agent_name = f"speckit.{stem}"
prompt = args or ""
import os
cli_args = [
"copilot", "-p", prompt,
"--agent", agent_name,
]
if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0":
cli_args.append("--allow-all-tools")
if model:
cli_args.extend(["--model", model])
if not stream:
cli_args.extend(["--output-format", "json"])
cwd = str(project_root) if project_root else None
if stream:
try:
result = subprocess.run(
cli_args,
text=True,
cwd=cwd,
)
except KeyboardInterrupt:
return {
"exit_code": 130,
"stdout": "",
"stderr": "Interrupted by user",
}
return {
"exit_code": result.returncode,
"stdout": "",
"stderr": "",
}
result = subprocess.run(
cli_args,
capture_output=True,
text=True,
cwd=cwd,
timeout=timeout,
)
return {
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
def command_filename(self, template_name: str) -> str:
"""Copilot commands use ``.agent.md`` extension."""
return f"speckit.{template_name}.agent.md"

View File

@@ -1,39 +1,21 @@
"""Cursor IDE integration.
"""Cursor IDE integration."""
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
from ..base import MarkdownIntegration
class CursorAgentIntegration(SkillsIntegration):
class CursorAgentIntegration(MarkdownIntegration):
key = "cursor-agent"
config = {
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "skills",
"commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".cursor/skills",
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
"extension": ".md",
}
context_file = ".cursor/rules/specify-rules.mdc"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (recommended for Cursor)",
),
]

View File

@@ -1,203 +0,0 @@
"""Forge integration — forgecode.dev AI coding agent.
Forge has several unique behaviors compared to standard markdown agents:
- Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing
- Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang)
- Injects `name` field into frontmatter when missing
- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..base import MarkdownIntegration
from ..manifest import IntegrationManifest
def format_forge_command_name(cmd_name: str) -> str:
"""Convert command name to Forge-compatible hyphenated format.
Forge requires command names to use hyphens instead of dots for
compatibility with ZSH and other shells. This function converts
dot-notation command names to hyphenated format.
The function is idempotent: already-formatted names are returned unchanged.
Examples:
>>> format_forge_command_name("plan")
'speckit-plan'
>>> format_forge_command_name("speckit.plan")
'speckit-plan'
>>> format_forge_command_name("speckit-plan")
'speckit-plan'
>>> format_forge_command_name("speckit.my-extension.example")
'speckit-my-extension-example'
>>> format_forge_command_name("speckit-my-extension-example")
'speckit-my-extension-example'
>>> format_forge_command_name("speckit.jira.sync-status")
'speckit-jira-sync-status'
Args:
cmd_name: Command name in dot notation (speckit.foo.bar),
hyphenated format (speckit-foo-bar), or plain name (foo)
Returns:
Hyphenated command name with 'speckit-' prefix
"""
# Already in hyphenated format - return as-is (idempotent)
if cmd_name.startswith("speckit-"):
return cmd_name
# Strip 'speckit.' prefix if present
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
# Replace all dots with hyphens
short_name = short_name.replace(".", "-")
# Return with 'speckit-' prefix
return f"speckit-{short_name}"
class ForgeIntegration(MarkdownIntegration):
"""Integration for Forge (forgecode.dev).
Extends MarkdownIntegration to add Forge-specific processing:
- Replaces $ARGUMENTS with {{parameters}}
- Strips 'handoffs' frontmatter key (incompatible with Forge)
- Injects 'name' field into frontmatter when missing
"""
key = "forge"
config = {
"name": "Forge",
"folder": ".forge/",
"commands_subdir": "commands",
"install_url": "https://forgecode.dev/docs/",
"requires_cli": True,
}
registrar_config = {
"dir": ".forge/commands",
"format": "markdown",
"args": "{{parameters}}",
"extension": ".md",
"strip_frontmatter_keys": ["handoffs"],
"inject_name": True,
"format_name": format_forge_command_name, # Custom name formatter
}
context_file = "AGENTS.md"
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Forge commands with custom processing.
Extends MarkdownIntegration.setup() to inject Forge-specific transformations
after standard template processing.
"""
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = self.commands_dest(project_root).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Process template with standard MarkdownIntegration logic
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
# FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are
# converted to {{parameters}}
processed = processed.replace("$ARGUMENTS", arg_placeholder)
# FORGE-SPECIFIC: Apply frontmatter transformations
processed = self._apply_forge_transformations(processed, src_file.stem)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
)
created.append(dst_file)
# Install integration-specific update-context scripts
created.extend(self.install_scripts(project_root, manifest))
return created
def _apply_forge_transformations(self, content: str, template_name: str) -> str:
"""Apply Forge-specific transformations to processed content.
1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge)
2. Inject 'name' field if missing (using hyphenated format)
"""
# Parse frontmatter
lines = content.split('\n')
if not lines or lines[0].strip() != '---':
return content
# Find end of frontmatter
frontmatter_end = -1
for i in range(1, len(lines)):
if lines[i].strip() == '---':
frontmatter_end = i
break
if frontmatter_end == -1:
return content
frontmatter_lines = lines[1:frontmatter_end]
body_lines = lines[frontmatter_end + 1:]
# 1. Strip 'handoffs' key
filtered_frontmatter = []
skip_until_outdent = False
for line in frontmatter_lines:
if skip_until_outdent:
# Skip indented lines under handoffs:
if line and (line[0] == ' ' or line[0] == '\t'):
continue
else:
skip_until_outdent = False
if line.strip().startswith('handoffs:'):
skip_until_outdent = True
continue
filtered_frontmatter.append(line)
# 2. Inject 'name' field if missing (using centralized formatter)
has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter)
if not has_name:
# Use centralized formatter to ensure consistent hyphenated format
cmd_name = format_forge_command_name(template_name)
filtered_frontmatter.insert(0, f'name: {cmd_name}')
# Reconstruct content
result = ['---'] + filtered_frontmatter + ['---'] + body_lines
return '\n'.join(result)

View File

@@ -1,33 +0,0 @@
# update-context.ps1 — Forge integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
$ErrorActionPreference = 'Stop'
# Derive repo root from script location (walks up to find .specify/)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
# If git did not return a repo root, or the git root does not contain .specify,
# fall back to walking up from the script directory to find the initialized project root.
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
}
}
$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if (-not (Test-Path $sharedScript)) {
Write-Error "Error: shared agent context updater not found: $sharedScript"
Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1."
exit 1
}
& $sharedScript -AgentType forge
exit $LASTEXITCODE

View File

@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# update-context.sh — Forge integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
set -euo pipefail
# Derive repo root from script location (walks up to find .specify/)
_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
shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if [ ! -x "$shared_script" ]; then
echo "Error: shared agent context updater not found or not executable:" >&2
echo " $shared_script" >&2
echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2
exit 1
fi
exec "$shared_script" forge

View File

@@ -1,21 +0,0 @@
"""Goose integration — Block's open source AI agent."""
from ..base import YamlIntegration
class GooseIntegration(YamlIntegration):
key = "goose"
config = {
"name": "Goose",
"folder": ".goose/",
"commands_subdir": "recipes",
"install_url": "https://block.github.io/goose/docs/getting-started/installation",
"requires_cli": True,
}
registrar_config = {
"dir": ".goose/recipes",
"format": "yaml",
"args": "{{args}}",
"extension": ".yaml",
}
context_file = "AGENTS.md"

View File

@@ -1,33 +0,0 @@
# update-context.ps1 — Goose integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
$ErrorActionPreference = 'Stop'
# Derive repo root from script location (walks up to find .specify/)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
# If git did not return a repo root, or the git root does not contain .specify,
# fall back to walking up from the script directory to find the initialized project root.
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
}
}
$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if (-not (Test-Path $sharedScript)) {
Write-Error "Error: shared agent context updater not found: $sharedScript"
Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1."
exit 1
}
& $sharedScript -AgentType goose
exit $LASTEXITCODE

View File

@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# update-context.sh — Goose integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
set -euo pipefail
# Derive repo root from script location (walks up to find .specify/)
_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
shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if [ ! -x "$shared_script" ]; then
echo "Error: shared agent context updater not found or not executable:" >&2
echo " $shared_script" >&2
echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2
exit 1
fi
exec "$shared_script" goose

View File

@@ -1,40 +1,21 @@
"""Trae IDE integration. — skills-based agent.
"""Trae IDE integration."""
Trae IDE uses ``.trae/skills/speckit-<name>/SKILL.md`` layout.
In the Specify CLI Trae integration, explicit command support was deprecated
since v0.5.1; ``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
from ..base import MarkdownIntegration
class TraeIntegration(SkillsIntegration):
"""Integration for Trae IDE."""
class TraeIntegration(MarkdownIntegration):
key = "trae"
config = {
"name": "Trae",
"folder": ".trae/",
"commands_subdir": "skills",
"commands_subdir": "rules",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".trae/skills",
"dir": ".trae/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
"extension": ".md",
}
context_file = ".trae/rules/project_rules.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for trae since v0.5.1)",
),
]
context_file = ".trae/rules/AGENTS.md"

View File

@@ -1,4 +1,4 @@
# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md
# update-context.ps1 — Trae integration: create/update .trae/rules/AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md
# update-context.sh — Trae integration: create/update .trae/rules/AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.

View File

@@ -707,7 +707,6 @@ class PresetManager:
from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
init_opts = load_init_options(self.project_root)
if not isinstance(init_opts, dict):
@@ -717,9 +716,8 @@ class PresetManager:
return []
ai_skills_enabled = bool(init_opts.get("ai_skills"))
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
# Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new
# Native skill agents (e.g. codex/kimi/agy) materialize brand-new
# preset skills in _register_commands() because their detected agent
# directory is already the skills directory. This flag is only for
# command-backed agents that also mirror commands into skills.
@@ -791,10 +789,6 @@ class PresetManager:
f"# Speckit {skill_title} Skill\n\n"
f"{body}\n"
)
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(
skill_content
)
skill_file = skill_subdir / "SKILL.md"
skill_file.write_text(skill_content, encoding="utf-8")
@@ -822,7 +816,6 @@ class PresetManager:
from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
# Locate core command templates from the project's installed templates
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
@@ -831,7 +824,6 @@ class PresetManager:
init_opts = {}
selected_ai = init_opts.get("ai")
registrar = CommandRegistrar()
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
extension_restore_index = self._build_extension_skill_restore_index()
for skill_name in skill_names:
@@ -885,10 +877,6 @@ class PresetManager:
f"# Speckit {skill_title} Skill\n\n"
f"{body}\n"
)
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(
skill_content
)
skill_file.write_text(skill_content, encoding="utf-8")
continue
@@ -918,10 +906,6 @@ class PresetManager:
f"# {title_name} Skill\n\n"
f"{body}\n"
)
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(
skill_content
)
skill_file.write_text(skill_content, encoding="utf-8")
else:
# No core or extension template — remove the skill entirely
@@ -1603,16 +1587,6 @@ class PresetCatalog:
f"Preset '{pack_id}' not found in catalog"
)
# Bundled presets without a download URL must be installed locally
if pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
raise PresetError(
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Use 'specify preset add {pack_id}' to install from the bundled package, "
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
raise PresetError(

View File

@@ -1,68 +0,0 @@
"""Workflow engine for multi-step, resumable automation workflows.
Provides:
- ``StepBase`` — abstract base every step type must implement.
- ``StepContext`` — execution context passed to each step.
- ``StepResult`` — return value from step execution.
- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances.
- ``WorkflowEngine`` — orchestrator that loads, validates, and executes
workflow YAML definitions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import StepBase
# Maps step type_key → StepBase instance.
STEP_REGISTRY: dict[str, StepBase] = {}
def _register_step(step: StepBase) -> None:
"""Register a step type instance in the global registry.
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = step.type_key
if not key:
raise ValueError("Cannot register step type with an empty type_key.")
if key in STEP_REGISTRY:
raise KeyError(f"Step type with key {key!r} is already registered.")
STEP_REGISTRY[key] = step
def get_step_type(type_key: str) -> StepBase | None:
"""Return the step type for *type_key*, or ``None`` if not registered."""
return STEP_REGISTRY.get(type_key)
# -- Register built-in step types ----------------------------------------
def _register_builtin_steps() -> None:
"""Register all built-in step types."""
from .steps.command import CommandStep
from .steps.do_while import DoWhileStep
from .steps.fan_in import FanInStep
from .steps.fan_out import FanOutStep
from .steps.gate import GateStep
from .steps.if_then import IfThenStep
from .steps.prompt import PromptStep
from .steps.shell import ShellStep
from .steps.switch import SwitchStep
from .steps.while_loop import WhileStep
_register_step(CommandStep())
_register_step(DoWhileStep())
_register_step(FanInStep())
_register_step(FanOutStep())
_register_step(GateStep())
_register_step(IfThenStep())
_register_step(PromptStep())
_register_step(ShellStep())
_register_step(SwitchStep())
_register_step(WhileStep())
_register_builtin_steps()

View File

@@ -1,132 +0,0 @@
"""Base classes for workflow step types.
Provides:
- ``StepBase`` — abstract base every step type must implement.
- ``StepContext`` — execution context passed to each step.
- ``StepResult`` — return value from step execution.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class StepStatus(str, Enum):
"""Status of a step execution."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
PAUSED = "paused"
class RunStatus(str, Enum):
"""Status of a workflow run."""
CREATED = "created"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
@dataclass
class StepContext:
"""Execution context passed to each step.
Contains everything the step needs to resolve expressions, dispatch
commands, and record results.
"""
#: Resolved workflow inputs (from user prompts / defaults).
inputs: dict[str, Any] = field(default_factory=dict)
#: Accumulated step results keyed by step ID.
#: Each entry is ``{"integration": ..., "model": ..., "options": ...,
#: "input": ..., "output": ...}``.
steps: dict[str, dict[str, Any]] = field(default_factory=dict)
#: Current fan-out item (set only inside fan-out iterations).
item: Any = None
#: Fan-in aggregated results (set only for fan-in steps).
fan_in: dict[str, Any] = field(default_factory=dict)
#: Workflow-level default integration key.
default_integration: str | None = None
#: Workflow-level default model.
default_model: str | None = None
#: Workflow-level default options.
default_options: dict[str, Any] = field(default_factory=dict)
#: Project root path.
project_root: str | None = None
#: Current run ID.
run_id: str | None = None
@dataclass
class StepResult:
"""Return value from a step execution."""
#: Step status.
status: StepStatus = StepStatus.COMPLETED
#: Output data (stored as ``steps.<id>.output``).
output: dict[str, Any] = field(default_factory=dict)
#: Nested steps to execute (for control-flow steps like if/then).
next_steps: list[dict[str, Any]] = field(default_factory=list)
#: Error message if step failed.
error: str | None = None
class StepBase(ABC):
"""Abstract base class for workflow step types.
Every step type — built-in or extension-provided — implements this
interface and registers in ``STEP_REGISTRY``.
"""
#: Matches the ``type:`` value in workflow YAML.
type_key: str = ""
@abstractmethod
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
"""Execute the step with the given config and context.
Parameters
----------
config:
The step configuration from workflow YAML.
context:
The execution context with inputs, accumulated step results, etc.
Returns
-------
StepResult with status, output data, and optional nested steps.
"""
def validate(self, config: dict[str, Any]) -> list[str]:
"""Validate step configuration and return a list of error messages.
An empty list means the configuration is valid.
"""
errors: list[str] = []
if "id" not in config:
errors.append("Step is missing required 'id' field.")
return errors
def can_resume(self, state: dict[str, Any]) -> bool:
"""Return whether this step can be resumed from the given state."""
return True

View File

@@ -1,540 +0,0 @@
"""Workflow catalog — discovery, install, and management of workflows.
Mirrors the existing extension/preset catalog pattern with:
- Multi-catalog stack (env var → project → user → built-in)
- SHA256-hashed per-URL caching with 1-hour TTL
- Workflow registry for installed workflow tracking
- Search across all configured catalog sources
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class WorkflowCatalogError(Exception):
"""Base error for workflow catalog operations."""
class WorkflowValidationError(WorkflowCatalogError):
"""Validation error for catalog config or workflow data."""
# ---------------------------------------------------------------------------
# CatalogEntry
# ---------------------------------------------------------------------------
@dataclass
class WorkflowCatalogEntry:
"""Represents a single catalog source in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
# ---------------------------------------------------------------------------
# WorkflowRegistry
# ---------------------------------------------------------------------------
class WorkflowRegistry:
"""Manages the registry of installed workflows.
Tracks installed workflows and their metadata in
``.specify/workflows/workflow-registry.json``.
"""
REGISTRY_FILE = "workflow-registry.json"
SCHEMA_VERSION = "1.0"
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.workflows_dir = project_root / ".specify" / "workflows"
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
self.data = self._load()
def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default."""
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError):
# Corrupted registry file — reset to default
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
def save(self) -> None:
"""Persist registry to disk."""
self.workflows_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed workflow entry."""
from datetime import datetime, timezone
existing = self.data["workflows"].get(workflow_id, {})
metadata["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
)
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["workflows"][workflow_id] = metadata
self.save()
def remove(self, workflow_id: str) -> bool:
"""Remove an installed workflow entry. Returns True if found."""
if workflow_id in self.data["workflows"]:
del self.data["workflows"][workflow_id]
self.save()
return True
return False
def get(self, workflow_id: str) -> dict[str, Any] | None:
"""Get metadata for an installed workflow."""
return self.data["workflows"].get(workflow_id)
def list(self) -> dict[str, dict[str, Any]]:
"""Return all installed workflows."""
return dict(self.data["workflows"])
def is_installed(self, workflow_id: str) -> bool:
"""Check if a workflow is installed."""
return workflow_id in self.data["workflows"]
# ---------------------------------------------------------------------------
# WorkflowCatalog
# ---------------------------------------------------------------------------
class WorkflowCatalog:
"""Manages workflow catalog fetching, caching, and searching.
Resolution order for catalog sources:
1. ``SPECKIT_WORKFLOW_CATALOG_URL`` env var (overrides all)
2. Project-level ``.specify/workflow-catalogs.yml``
3. User-level ``~/.specify/workflow-catalogs.yml``
4. Built-in defaults (official + community)
"""
DEFAULT_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"workflows/catalog.json"
)
COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"workflows/catalog.community.json"
)
CACHE_DURATION = 3600 # 1 hour
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.workflows_dir = project_root / ".specify" / "workflows"
self.cache_dir = self.workflows_dir / ".cache"
# -- Catalog resolution -----------------------------------------------
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise WorkflowValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host."
)
def _load_catalog_config(
self, config_path: Path
) -> list[WorkflowCatalogEntry] | None:
"""Load catalog stack configuration from a YAML file."""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise WorkflowValidationError(
f"Failed to read catalog config {config_path}: {exc}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
# Empty catalogs list (e.g. after removing last entry)
# is valid — fall back to built-in defaults.
return None
if not isinstance(catalogs_data, list):
raise WorkflowValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
entries: list[WorkflowCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise WorkflowValidationError(
f"Invalid catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise WorkflowValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in (
"true",
"yes",
"1",
)
else:
install_allowed = bool(raw_install)
entries.append(
WorkflowCatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
)
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise WorkflowValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs."
)
return entries
def get_active_catalogs(self) -> list[WorkflowCatalogEntry]:
"""Get the ordered list of active catalogs."""
# 1. Environment variable override
env_url = os.environ.get("SPECKIT_WORKFLOW_CATALOG_URL", "").strip()
if env_url:
self._validate_catalog_url(env_url)
return [
WorkflowCatalogEntry(
url=env_url,
name="env-override",
priority=1,
install_allowed=True,
description="From SPECKIT_WORKFLOW_CATALOG_URL",
)
]
# 2. Project-level config
project_config = self.project_root / ".specify" / "workflow-catalogs.yml"
project_entries = self._load_catalog_config(project_config)
if project_entries is not None:
return project_entries
# 3. User-level config
home = Path.home()
user_config = home / ".specify" / "workflow-catalogs.yml"
user_entries = self._load_catalog_config(user_config)
if user_entries is not None:
return user_entries
# 4. Built-in defaults
return [
WorkflowCatalogEntry(
url=self.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
description="Official workflows",
),
WorkflowCatalogEntry(
url=self.COMMUNITY_CATALOG_URL,
name="community",
priority=2,
install_allowed=False,
description="Community-contributed workflows (discovery only)",
),
]
# -- Caching ----------------------------------------------------------
def _get_cache_paths(self, url: str) -> tuple[Path, Path]:
"""Get cache file paths for a URL (hash-based)."""
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"workflow-catalog-{url_hash}.json"
meta_file = self.cache_dir / f"workflow-catalog-{url_hash}-meta.json"
return cache_file, meta_file
def _is_url_cache_valid(self, url: str) -> bool:
"""Check if cached data for a URL is still fresh."""
_, meta_file = self._get_cache_paths(url)
if not meta_file.exists():
return False
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
fetched_at = meta.get("fetched_at", 0)
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError):
return False
def _fetch_single_catalog(
self, entry: WorkflowCatalogEntry, force_refresh: bool = False
) -> dict[str, Any]:
"""Fetch a single catalog, using cache when possible."""
cache_file, meta_file = self._get_cache_paths(entry.url)
if not force_refresh and self._is_url_cache_valid(entry.url):
try:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
# Fetch from URL — validate scheme before opening and after redirects
from urllib.parse import urlparse
from urllib.request import urlopen
def _validate_catalog_url(url: str) -> None:
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
_validate_catalog_url(entry.url)
try:
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
# Fall back to cache if available
if cache_file.exists():
try:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError, OSError):
pass
raise WorkflowCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
) from exc
if not isinstance(data, dict):
raise WorkflowCatalogError(
f"Catalog from {entry.url} is not a valid JSON object."
)
# Write cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
with open(meta_file, "w", encoding="utf-8") as f:
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
return data
def _get_merged_workflows(
self, force_refresh: bool = False
) -> dict[str, dict[str, Any]]:
"""Merge workflows from all active catalogs (lower priority number wins)."""
catalogs = self.get_active_catalogs()
merged: dict[str, dict[str, Any]] = {}
fetch_errors = 0
# Process later/higher-numbered entries first so earlier/lower-numbered
# entries overwrite them on workflow ID conflicts.
for entry in reversed(catalogs):
try:
data = self._fetch_single_catalog(entry, force_refresh)
except WorkflowCatalogError:
fetch_errors += 1
continue
workflows = data.get("workflows", {})
# Handle both dict and list formats
if isinstance(workflows, dict):
for wf_id, wf_data in workflows.items():
if not isinstance(wf_data, dict):
continue
wf_data["_catalog_name"] = entry.name
wf_data["_install_allowed"] = entry.install_allowed
merged[wf_id] = wf_data
elif isinstance(workflows, list):
for wf_data in workflows:
if not isinstance(wf_data, dict):
continue
wf_id = wf_data.get("id", "")
if wf_id:
wf_data["_catalog_name"] = entry.name
wf_data["_install_allowed"] = entry.install_allowed
merged[wf_id] = wf_data
if fetch_errors == len(catalogs) and catalogs:
raise WorkflowCatalogError(
"All configured catalogs failed to fetch."
)
return merged
# -- Public API -------------------------------------------------------
def search(
self,
query: str | None = None,
tag: str | None = None,
) -> list[dict[str, Any]]:
"""Search workflows across all configured catalogs."""
merged = self._get_merged_workflows()
results: list[dict[str, Any]] = []
for wf_id, wf_data in merged.items():
wf_data.setdefault("id", wf_id)
if query:
q = query.lower()
searchable = " ".join(
[
wf_data.get("name", ""),
wf_data.get("description", ""),
wf_data.get("id", ""),
]
).lower()
if q not in searchable:
continue
if tag:
raw_tags = wf_data.get("tags", [])
tags = raw_tags if isinstance(raw_tags, list) else []
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
if tag.lower() not in normalized_tags:
continue
results.append(wf_data)
return results
def get_workflow_info(self, workflow_id: str) -> dict[str, Any] | None:
"""Get details for a specific workflow from the catalog."""
merged = self._get_merged_workflows()
wf = merged.get(workflow_id)
if wf:
wf.setdefault("id", workflow_id)
return wf
def get_catalog_configs(self) -> list[dict[str, Any]]:
"""Return current catalog configuration as a list of dicts."""
entries = self.get_active_catalogs()
return [
{
"name": e.name,
"url": e.url,
"priority": e.priority,
"install_allowed": e.install_allowed,
"description": e.description,
}
for e in entries
]
def add_catalog(self, url: str, name: str | None = None) -> None:
"""Add a catalog source to the project-level config."""
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
data = raw
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise WorkflowValidationError(
"Catalog config 'catalogs' must be a list."
)
# Check for duplicate URL (guard against non-dict entries)
for cat in catalogs:
if isinstance(cat, dict) and cat.get("url") == url:
raise WorkflowValidationError(
f"Catalog URL already configured: {url}"
)
# Derive priority from the highest existing priority + 1
max_priority = max(
(cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)),
default=0,
)
catalogs.append(
{
"name": name or f"catalog-{len(catalogs) + 1}",
"url": url,
"priority": max_priority + 1,
"install_allowed": True,
"description": "",
}
)
data["catalogs"] = catalogs
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by index (0-based). Returns the removed name."""
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"
if not config_path.exists():
raise WorkflowValidationError("No catalog config file found.")
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise WorkflowValidationError(
"Catalog config 'catalogs' must be a list."
)
if index < 0 or index >= len(catalogs):
raise WorkflowValidationError(
f"Catalog index {index} out of range (0-{len(catalogs) - 1})."
)
removed = catalogs.pop(index)
data["catalogs"] = catalogs
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
if isinstance(removed, dict):
return removed.get("name", f"catalog-{index + 1}")
return f"catalog-{index + 1}"

View File

@@ -1,778 +0,0 @@
"""Workflow engine — loads, validates, and executes workflow YAML definitions.
The engine is the orchestrator that:
- Parses workflow YAML definitions
- Validates step configurations and requirements
- Executes steps sequentially, dispatching to the correct step type
- Manages state persistence for resume capability
- Handles control flow (branching, loops, fan-out/fan-in)
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from .base import RunStatus, StepContext, StepResult, StepStatus
# -- Workflow Definition --------------------------------------------------
class WorkflowDefinition:
"""Parsed and validated workflow YAML definition."""
def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> None:
self.data = data
self.source_path = source_path
workflow = data.get("workflow", {})
self.id: str = workflow.get("id", "")
self.name: str = workflow.get("name", "")
self.version: str = workflow.get("version", "0.0.0")
self.author: str = workflow.get("author", "")
self.description: str = workflow.get("description", "")
self.schema_version: str = data.get("schema_version", "1.0")
# Defaults
self.default_integration: str | None = workflow.get("integration")
self.default_model: str | None = workflow.get("model")
self.default_options: dict[str, Any] = workflow.get("options") or {}
if not isinstance(self.default_options, dict):
self.default_options = {}
# Requirements (declared but not yet enforced at runtime;
# enforcement is a planned enhancement)
self.requires: dict[str, Any] = data.get("requires", {})
# Inputs
self.inputs: dict[str, Any] = data.get("inputs", {})
# Steps
self.steps: list[dict[str, Any]] = data.get("steps", [])
@classmethod
def from_yaml(cls, path: Path) -> WorkflowDefinition:
"""Load a workflow definition from a YAML file."""
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
return cls(data, source_path=path)
@classmethod
def from_string(cls, content: str) -> WorkflowDefinition:
"""Load a workflow definition from a YAML string."""
data = yaml.safe_load(content)
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
return cls(data)
# -- Workflow Validation --------------------------------------------------
# ID format: lowercase alphanumeric with hyphens
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
# Valid step types (matching STEP_REGISTRY keys)
def _get_valid_step_types() -> set[str]:
"""Return valid step types from the registry, with a built-in fallback."""
from . import STEP_REGISTRY
if STEP_REGISTRY:
return set(STEP_REGISTRY.keys())
return {
"command", "shell", "prompt", "gate", "if",
"switch", "while", "do-while", "fan-out", "fan-in",
}
def validate_workflow(definition: WorkflowDefinition) -> list[str]:
"""Validate a workflow definition and return a list of error messages.
An empty list means the workflow is valid.
"""
errors: list[str] = []
# -- Schema version ---------------------------------------------------
if definition.schema_version not in ("1.0", "1"):
errors.append(
f"Unsupported schema_version {definition.schema_version!r}. "
f"Expected '1.0'."
)
# -- Top-level fields -------------------------------------------------
if not definition.id:
errors.append("Workflow is missing 'workflow.id'.")
elif not _ID_PATTERN.match(definition.id):
errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens."
)
if not definition.name:
errors.append("Workflow is missing 'workflow.name'.")
if not definition.version:
errors.append("Workflow is missing 'workflow.version'.")
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"Workflow version {definition.version!r} is not valid "
f"semantic versioning (expected X.Y.Z)."
)
# -- Inputs -----------------------------------------------------------
if not isinstance(definition.inputs, dict):
errors.append("'inputs' must be a mapping (or omitted).")
else:
for input_name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
errors.append(f"Input {input_name!r} must be a mapping.")
continue
input_type = input_def.get("type")
if input_type and input_type not in ("string", "number", "boolean"):
errors.append(
f"Input {input_name!r} has invalid type {input_type!r}. "
f"Must be 'string', 'number', or 'boolean'."
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")
return errors
if not definition.steps:
errors.append("Workflow has no steps defined.")
seen_ids: set[str] = set()
_validate_steps(definition.steps, seen_ids, errors)
return errors
def _validate_steps(
steps: list[dict[str, Any]],
seen_ids: set[str],
errors: list[str],
) -> None:
"""Recursively validate a list of steps."""
from . import STEP_REGISTRY
for step_config in steps:
if not isinstance(step_config, dict):
errors.append(f"Step must be a mapping, got {type(step_config).__name__}.")
continue
step_id = step_config.get("id")
if not step_id:
errors.append("Step is missing 'id' field.")
continue
if ":" in step_id:
errors.append(
f"Step ID {step_id!r} contains ':' which is reserved "
f"for engine-generated nested IDs (parentId:childId)."
)
if step_id in seen_ids:
errors.append(f"Duplicate step ID {step_id!r}.")
seen_ids.add(step_id)
# Determine step type
step_type = step_config.get("type", "command")
if step_type not in _get_valid_step_types():
errors.append(
f"Step {step_id!r} has invalid type {step_type!r}."
)
continue
# Delegate to step-specific validation
step_impl = STEP_REGISTRY.get(step_type)
if step_impl:
step_errors = step_impl.validate(step_config)
errors.extend(step_errors)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
if isinstance(nested, list):
_validate_steps(nested, seen_ids, errors)
# Validate switch cases
cases = step_config.get("cases")
if isinstance(cases, dict):
for _case_key, case_steps in cases.items():
if isinstance(case_steps, list):
_validate_steps(case_steps, seen_ids, errors)
# Validate switch default
default = step_config.get("default")
if isinstance(default, list):
_validate_steps(default, seen_ids, errors)
# Validate fan-out nested step (template — not added to seen_ids
# since the engine generates parentId:templateId:index at runtime)
fan_step = step_config.get("step")
if isinstance(fan_step, dict):
fan_errors: list[str] = []
_validate_steps([fan_step], set(), fan_errors)
errors.extend(fan_errors)
# -- Run State Persistence ------------------------------------------------
class RunState:
"""Manages workflow run state for persistence and resume."""
def __init__(
self,
run_id: str | None = None,
workflow_id: str = "",
project_root: Path | None = None,
) -> None:
self.run_id = run_id or str(uuid.uuid4())[:8]
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id):
msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only."
raise ValueError(msg)
self.workflow_id = workflow_id
self.project_root = project_root or Path(".")
self.status = RunStatus.CREATED
self.current_step_index = 0
self.current_step_id: str | None = None
self.step_results: dict[str, dict[str, Any]] = {}
self.inputs: dict[str, Any] = {}
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
@property
def runs_dir(self) -> Path:
return self.project_root / ".specify" / "workflows" / "runs" / self.run_id
def save(self) -> None:
"""Persist current state to disk."""
self.updated_at = datetime.now(timezone.utc).isoformat()
runs_dir = self.runs_dir
runs_dir.mkdir(parents=True, exist_ok=True)
state_data = {
"run_id": self.run_id,
"workflow_id": self.workflow_id,
"status": self.status.value,
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
"step_results": self.step_results,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
with open(runs_dir / "state.json", "w", encoding="utf-8") as f:
json.dump(state_data, f, indent=2)
inputs_data = {"inputs": self.inputs}
with open(runs_dir / "inputs.json", "w", encoding="utf-8") as f:
json.dump(inputs_data, f, indent=2)
@classmethod
def load(cls, run_id: str, project_root: Path) -> RunState:
"""Load a run state from disk."""
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
state_path = runs_dir / "state.json"
if not state_path.exists():
msg = f"Run state not found: {state_path}"
raise FileNotFoundError(msg)
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
state = cls(
run_id=state_data["run_id"],
workflow_id=state_data["workflow_id"],
project_root=project_root,
)
state.status = RunStatus(state_data["status"])
state.current_step_index = state_data.get("current_step_index", 0)
state.current_step_id = state_data.get("current_step_id")
state.step_results = state_data.get("step_results", {})
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
inputs_path = runs_dir / "inputs.json"
if inputs_path.exists():
with open(inputs_path, encoding="utf-8") as f:
inputs_data = json.load(f)
state.inputs = inputs_data.get("inputs", {})
return state
def append_log(self, entry: dict[str, Any]) -> None:
"""Append a log entry to the run log."""
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
self.log_entries.append(entry)
runs_dir = self.runs_dir
runs_dir.mkdir(parents=True, exist_ok=True)
with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
# -- Workflow Engine ------------------------------------------------------
class WorkflowEngine:
"""Orchestrator that loads, validates, and executes workflow definitions."""
def __init__(self, project_root: Path | None = None) -> None:
self.project_root = project_root or Path(".")
self.on_step_start: Any = None # Callable[[str, str], None] | None
def load_workflow(self, source: str | Path) -> WorkflowDefinition:
"""Load a workflow from an installed ID or a local YAML path.
Parameters
----------
source:
Either a workflow ID (looked up in the installed workflows
directory) or a path to a YAML file.
Returns
-------
A parsed ``WorkflowDefinition`` (not yet validated; call
``validate_workflow()`` or ``engine.validate()`` separately).
Raises
------
FileNotFoundError:
If the workflow file cannot be found.
ValueError:
If the workflow YAML is invalid.
"""
path = Path(source)
# Try as a direct file path first
if path.suffix in (".yml", ".yaml") and path.exists():
return WorkflowDefinition.from_yaml(path)
# Try as an installed workflow ID
installed_path = (
self.project_root
/ ".specify"
/ "workflows"
/ str(source)
/ "workflow.yml"
)
if installed_path.exists():
return WorkflowDefinition.from_yaml(installed_path)
msg = f"Workflow not found: {source}"
raise FileNotFoundError(msg)
def validate(self, definition: WorkflowDefinition) -> list[str]:
"""Validate a workflow definition."""
return validate_workflow(definition)
def execute(
self,
definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None,
run_id: str | None = None,
) -> RunState:
"""Execute a workflow definition.
Parameters
----------
definition:
The validated workflow definition.
inputs:
User-provided input values.
run_id:
Optional run ID (auto-generated if not provided).
Returns
-------
The final ``RunState`` after execution completes (or pauses).
"""
from . import STEP_REGISTRY
state = RunState(
run_id=run_id,
workflow_id=definition.id,
project_root=self.project_root,
)
# Persist a copy of the workflow definition so resume can
# reload it even if the original source is no longer available
# (e.g. a local YAML path that was moved or deleted).
run_dir = self.project_root / ".specify" / "workflows" / "runs" / state.run_id
run_dir.mkdir(parents=True, exist_ok=True)
workflow_copy = run_dir / "workflow.yml"
import yaml
with open(workflow_copy, "w", encoding="utf-8") as f:
yaml.safe_dump(definition.data, f, sort_keys=False)
# Resolve inputs
resolved_inputs = self._resolve_inputs(definition, inputs or {})
state.inputs = resolved_inputs
state.status = RunStatus.RUNNING
state.save()
context = StepContext(
inputs=resolved_inputs,
default_integration=definition.default_integration,
default_model=definition.default_model,
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
)
# Execute steps
try:
self._execute_steps(definition.steps, context, state, STEP_REGISTRY)
except KeyboardInterrupt:
state.status = RunStatus.PAUSED
state.append_log({"event": "workflow_interrupted"})
state.save()
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.append_log({"event": "workflow_failed", "error": str(exc)})
state.save()
raise
if state.status == RunStatus.RUNNING:
state.status = RunStatus.COMPLETED
state.append_log({"event": "workflow_finished", "status": state.status.value})
state.save()
return state
def resume(self, run_id: str) -> RunState:
"""Resume a paused or failed workflow run."""
state = RunState.load(run_id, self.project_root)
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
raise ValueError(msg)
# Load the workflow definition — try the persisted copy in the
# run directory first so resume works even if the original
# source (e.g. a local YAML path) is no longer available.
run_dir = self.project_root / ".specify" / "workflows" / "runs" / run_id
run_copy = run_dir / "workflow.yml"
if run_copy.exists():
definition = WorkflowDefinition.from_yaml(run_copy)
else:
definition = self.load_workflow(state.workflow_id)
# Restore context
context = StepContext(
inputs=state.inputs,
steps=state.step_results,
default_integration=definition.default_integration,
default_model=definition.default_model,
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
)
from . import STEP_REGISTRY
state.status = RunStatus.RUNNING
state.save()
# Resume from the current step — re-execute it so gates
# can prompt interactively again.
remaining_steps = definition.steps[state.current_step_index :]
step_offset = state.current_step_index
try:
self._execute_steps(
remaining_steps, context, state, STEP_REGISTRY,
step_offset=step_offset,
)
except KeyboardInterrupt:
state.status = RunStatus.PAUSED
state.append_log({"event": "workflow_interrupted"})
state.save()
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.append_log({"event": "resume_failed", "error": str(exc)})
state.save()
raise
if state.status == RunStatus.RUNNING:
state.status = RunStatus.COMPLETED
state.append_log({"event": "workflow_finished", "status": state.status.value})
state.save()
return state
def _execute_steps(
self,
steps: list[dict[str, Any]],
context: StepContext,
state: RunState,
registry: dict[str, Any],
*,
step_offset: int = 0,
) -> None:
"""Execute a list of steps sequentially."""
for i, step_config in enumerate(steps):
step_id = step_config.get("id", f"step-{i}")
step_type = step_config.get("type", "command")
state.current_step_id = step_id
if step_offset >= 0:
state.current_step_index = step_offset + i
state.save()
state.append_log(
{"event": "step_started", "step_id": step_id, "type": step_type}
)
# Log progress — use the engine's on_step_start callback if set,
# otherwise stay silent (library-safe default).
label = step_config.get("command", "") or step_type
if self.on_step_start is not None:
self.on_step_start(step_id, label)
step_impl = registry.get(step_type)
if not step_impl:
state.status = RunStatus.FAILED
state.append_log(
{
"event": "step_failed",
"step_id": step_id,
"error": f"Unknown step type: {step_type!r}",
}
)
state.save()
return
result: StepResult = step_impl.execute(step_config, context)
# Record step results — prefer resolved values from step output
step_data = {
"integration": result.output.get("integration")
or step_config.get("integration")
or context.default_integration,
"model": result.output.get("model")
or step_config.get("model")
or context.default_model,
"options": result.output.get("options")
or step_config.get("options", {}),
"input": result.output.get("input")
or step_config.get("input", {}),
"output": result.output,
"status": result.status.value,
}
context.steps[step_id] = step_data
state.step_results[step_id] = step_data
state.append_log(
{
"event": "step_completed",
"step_id": step_id,
"status": result.status.value,
}
)
# Handle gate pauses
if result.status == StepStatus.PAUSED:
state.status = RunStatus.PAUSED
state.save()
return
# Handle failures
if result.status == StepStatus.FAILED:
# Gate abort (output.aborted) maps to ABORTED status
if result.output.get("aborted"):
state.status = RunStatus.ABORTED
state.append_log(
{
"event": "workflow_aborted",
"step_id": step_id,
}
)
else:
state.status = RunStatus.FAILED
state.append_log(
{
"event": "step_failed",
"step_id": step_id,
"error": result.error,
}
)
state.save()
return
# Execute nested steps (from control flow)
# NOTE: Nested steps run with step_offset=-1 so they don't
# update current_step_index. If a nested step pauses,
# resume will re-run the parent step and its nested body.
# A step-path stack for exact nested resume is a future
# enhancement.
if result.next_steps:
self._execute_steps(
result.next_steps, context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
# Loop iteration: while/do-while re-evaluate after body
if step_type in ("while", "do-while"):
from .expressions import evaluate_condition
max_iters = step_config.get("max_iterations")
if not isinstance(max_iters, int) or max_iters < 1:
max_iters = 10
condition = step_config.get("condition", False)
for _loop_iter in range(max_iters - 1):
if not evaluate_condition(condition, context):
break
# Namespace nested step IDs per iteration
iter_steps = []
for ns in result.next_steps:
ns_copy = dict(ns)
if "id" in ns_copy:
ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}"
iter_steps.append(ns_copy)
self._execute_steps(
iter_steps, context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
# Fan-out: execute nested step template per item with unique IDs
if step_type == "fan-out":
items = result.output.get("items", [])
template = result.output.get("step_template", {})
if template and items:
fan_out_results = []
for item_idx, item_val in enumerate(result.output["items"]):
context.item = item_val
# Per-item ID: parentId:templateId:index
item_step = dict(template)
base_id = item_step.get("id", "item")
item_step["id"] = f"{step_id}:{base_id}:{item_idx}"
self._execute_steps(
[item_step], context, state, registry,
step_offset=-1,
)
# Collect per-item result for fan-in
item_result = context.steps.get(item_step["id"], {})
fan_out_results.append(item_result.get("output", {}))
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
break
context.item = None
# Preserve original output and add collected results
fan_out_output = dict(result.output)
fan_out_output["results"] = fan_out_results
context.steps[step_id]["output"] = fan_out_output
state.step_results[step_id]["output"] = fan_out_output
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
else:
# Empty items or no template — normalize output
result.output["results"] = []
context.steps[step_id]["output"] = result.output
state.step_results[step_id]["output"] = result.output
def _resolve_inputs(
self,
definition: WorkflowDefinition,
provided: dict[str, Any],
) -> dict[str, Any]:
"""Resolve workflow inputs against definitions and provided values."""
resolved: dict[str, Any] = {}
for name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
continue
if name in provided:
resolved[name] = self._coerce_input(
name, provided[name], input_def
)
elif "default" in input_def:
resolved[name] = input_def["default"]
elif input_def.get("required", False):
msg = f"Required input {name!r} not provided."
raise ValueError(msg)
return resolved
@staticmethod
def _coerce_input(
name: str, value: Any, input_def: dict[str, Any]
) -> Any:
"""Coerce a provided input value to the declared type."""
input_type = input_def.get("type", "string")
enum_values = input_def.get("enum")
if input_type == "number":
try:
value = float(value)
if value == int(value):
value = int(value)
except (ValueError, TypeError):
msg = f"Input {name!r} expected a number, got {value!r}."
raise ValueError(msg) from None
elif input_type == "boolean":
if isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
value = True
elif value.lower() in ("false", "0", "no"):
value = False
else:
msg = f"Input {name!r} expected a boolean, got {value!r}."
raise ValueError(msg)
if enum_values is not None and value not in enum_values:
msg = (
f"Input {name!r} value {value!r} not in allowed "
f"values: {enum_values}."
)
raise ValueError(msg)
return value
def list_runs(self) -> list[dict[str, Any]]:
"""List all workflow runs in the project."""
runs_dir = self.project_root / ".specify" / "workflows" / "runs"
if not runs_dir.exists():
return []
runs: list[dict[str, Any]] = []
for run_dir in sorted(runs_dir.iterdir()):
if not run_dir.is_dir():
continue
state_path = run_dir / "state.json"
if state_path.exists():
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
runs.append(state_data)
return runs
class WorkflowAbortError(Exception):
"""Raised when a workflow is aborted (e.g., gate rejection)."""

View File

@@ -1,300 +0,0 @@
"""Sandboxed expression evaluator for workflow templates.
Provides a safe Jinja2 subset for evaluating expressions in workflow YAML.
No file I/O, no imports, no arbitrary code execution.
"""
from __future__ import annotations
import re
from typing import Any
# -- Custom filters -------------------------------------------------------
def _filter_default(value: Any, default_value: Any = "") -> Any:
"""Return *default_value* when *value* is ``None`` or empty string."""
if value is None or value == "":
return default_value
return value
def _filter_join(value: Any, separator: str = ", ") -> str:
"""Join a list into a string with *separator*."""
if isinstance(value, list):
return separator.join(str(v) for v in value)
return str(value)
def _filter_map(value: Any, attr: str) -> list[Any]:
"""Map a list of dicts to a specific attribute."""
if isinstance(value, list):
result = []
for item in value:
if isinstance(item, dict):
# Support dot notation: "result.status" → item["result"]["status"]
parts = attr.split(".")
v = item
for part in parts:
if isinstance(v, dict):
v = v.get(part)
else:
v = None
break
result.append(v)
else:
result.append(item)
return result
return []
def _filter_contains(value: Any, substring: str) -> bool:
"""Check if a string or list contains *substring*."""
if isinstance(value, str):
return substring in value
if isinstance(value, list):
return substring in value
return False
# -- Expression resolution ------------------------------------------------
_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}")
def _resolve_dot_path(obj: Any, path: str) -> Any:
"""Resolve a dotted path like ``steps.specify.output.file`` against *obj*.
Supports dict key access and list indexing (e.g., ``task_list[0]``).
"""
parts = path.split(".")
current = obj
for part in parts:
# Handle list indexing: name[0]
idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part)
if idx_match:
key, idx = idx_match.group(1), int(idx_match.group(2))
if isinstance(current, dict):
current = current.get(key)
else:
return None
if isinstance(current, list) and 0 <= idx < len(current):
current = current[idx]
else:
return None
elif isinstance(current, dict):
current = current.get(part)
else:
return None
if current is None:
return None
return current
def _build_namespace(context: Any) -> dict[str, Any]:
"""Build the variable namespace from a StepContext."""
ns: dict[str, Any] = {}
if hasattr(context, "inputs"):
ns["inputs"] = context.inputs or {}
if hasattr(context, "steps"):
ns["steps"] = context.steps or {}
if hasattr(context, "item"):
ns["item"] = context.item
if hasattr(context, "fan_in"):
ns["fan_in"] = context.fan_in or {}
return ns
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
Supports:
- Dot-path access: ``steps.specify.output.file``
- Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=``
- Boolean operators: ``and``, ``or``, ``not``
- ``in``, ``not in``
- Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')``
- String and numeric literals
"""
expr = expr.strip()
# String literal — check before pipes and operators so quoted strings
# containing | or operator keywords are not mis-parsed.
if (expr.startswith("'") and expr.endswith("'")) or (
expr.startswith('"') and expr.endswith('"')
):
return expr[1:-1]
# Handle pipe filters
if "|" in expr:
parts = expr.split("|", 1)
value = _evaluate_simple_expression(parts[0].strip(), namespace)
filter_expr = parts[1].strip()
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
if fname == "default":
return _filter_default(value, farg)
if fname == "join":
return _filter_join(value, farg)
if fname == "map":
return _filter_map(value, farg)
if fname == "contains":
return _filter_contains(value, farg)
# Filter without args
filter_name = filter_expr.strip()
if filter_name == "default":
return _filter_default(value)
return value
# Boolean operators — parse 'or' first (lower precedence) so that
# 'a or b and c' is evaluated as 'a or (b and c)'.
if " or " in expr:
parts = expr.split(" or ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
return bool(left) or bool(right)
if " and " in expr:
parts = expr.split(" and ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
return bool(left) and bool(right)
if expr.startswith("not "):
inner = _evaluate_simple_expression(expr[4:].strip(), namespace)
return not bool(inner)
# Comparison operators (order matters — check multi-char ops first)
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
if op in expr:
parts = expr.split(op, 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
if op == "==":
return left == right
if op == "!=":
return left != right
if op == ">":
return _safe_compare(left, right, ">")
if op == "<":
return _safe_compare(left, right, "<")
if op == ">=":
return _safe_compare(left, right, ">=")
if op == "<=":
return _safe_compare(left, right, "<=")
if op == " in ":
return left in right if right is not None else False
if op == " not in ":
return left not in right if right is not None else True
# Numeric literal
try:
if "." in expr:
return float(expr)
return int(expr)
except (ValueError, TypeError):
pass
# Boolean literal
if expr.lower() == "true":
return True
if expr.lower() == "false":
return False
# Null
if expr.lower() in ("none", "null"):
return None
# List literal (simple)
if expr.startswith("[") and expr.endswith("]"):
inner = expr[1:-1].strip()
if not inner:
return []
items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")]
return items
# Variable reference (dot-path)
return _resolve_dot_path(namespace, expr)
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Safely compare two values, coercing types when possible."""
try:
if isinstance(left, str):
left = float(left) if "." in left else int(left)
if isinstance(right, str):
right = float(right) if "." in right else int(right)
except (ValueError, TypeError):
return False
try:
if op == ">":
return left > right # type: ignore[operator]
if op == "<":
return left < right # type: ignore[operator]
if op == ">=":
return left >= right # type: ignore[operator]
if op == "<=":
return left <= right # type: ignore[operator]
except TypeError:
return False
return False
def evaluate_expression(template: str, context: Any) -> Any:
"""Evaluate a template string with ``{{ ... }}`` expressions.
If the entire string is a single expression, returns the raw value
(preserving type). Otherwise, substitutes each expression inline
and returns a string.
Parameters
----------
template:
The template string (e.g., ``"{{ steps.plan.output.task_count }}"``
or ``"Processed {{ inputs.spec }}"``.
context:
A ``StepContext`` or compatible object.
Returns
-------
The resolved value (any type for single-expression templates,
string for multi-expression or mixed templates).
"""
if not isinstance(template, str):
return template
namespace = _build_namespace(context)
# Single expression: return typed value
match = _EXPR_PATTERN.fullmatch(template.strip())
if match:
return _evaluate_simple_expression(match.group(1).strip(), namespace)
# Multi-expression: string interpolation
def _replacer(m: re.Match[str]) -> str:
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
return str(val) if val is not None else ""
return _EXPR_PATTERN.sub(_replacer, template)
def evaluate_condition(condition: str, context: Any) -> bool:
"""Evaluate a condition expression and return a boolean.
Convenience wrapper around ``evaluate_expression`` that coerces
the result to bool.
"""
result = evaluate_expression(condition, context)
# Treat plain "false"/"true" strings as booleans so that
# condition: "false" (without {{ }}) behaves as expected.
if isinstance(result, str):
lower = result.lower()
if lower == "false":
return False
if lower == "true":
return True
return bool(result)

View File

@@ -1 +0,0 @@
"""Auto-discovery for built-in step types."""

View File

@@ -1,155 +0,0 @@
"""Command step — dispatches a Spec Kit command to an integration CLI."""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class CommandStep(StepBase):
"""Default step type — invokes a Spec Kit command via the integration CLI.
The command files (skills, markdown, TOML) are already installed in
the integration's directory on disk. This step tells the CLI to
execute the command by name (e.g. ``/speckit.specify`` or
``/speckit-specify``) rather than reading the file contents.
.. note::
CLI output is streamed to the terminal for live progress.
``output.exit_code`` is always captured and can be referenced
by later steps (e.g. ``{{ steps.specify.output.exit_code }}``).
Full ``stdout``/``stderr`` capture is a planned enhancement.
"""
type_key = "command"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "")
input_data = config.get("input", {})
# Resolve expressions in input
resolved_input: dict[str, Any] = {}
for key, value in input_data.items():
resolved_input[key] = evaluate_expression(value, context)
# Resolve integration (step → workflow default → project default)
integration = config.get("integration") or context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model
model = config.get("model") or context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# Merge options (workflow defaults ← step overrides)
options = dict(context.default_options)
step_options = config.get("options", {})
if step_options:
options.update(step_options)
# Attempt CLI dispatch
args_str = str(resolved_input.get("args", ""))
dispatch_result = self._try_dispatch(
command, integration, model, args_str, context
)
output: dict[str, Any] = {
"command": command,
"integration": integration,
"model": model,
"options": options,
"input": resolved_input,
}
if dispatch_result is not None:
output["exit_code"] = dispatch_result["exit_code"]
output["stdout"] = dispatch_result["stdout"]
output["stderr"] = dispatch_result["stderr"]
output["dispatched"] = True
if dispatch_result["exit_code"] != 0:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=dispatch_result["stderr"] or f"Command exited with code {dispatch_result['exit_code']}",
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
)
else:
output["exit_code"] = 1
output["dispatched"] = False
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Cannot dispatch command {command!r}: "
f"integration {integration!r} CLI not found or not installed. "
f"Install the CLI tool or check 'specify integration list'."
),
)
@staticmethod
def _try_dispatch(
command: str,
integration_key: str | None,
model: str | None,
args: str,
context: StepContext,
) -> dict[str, Any] | None:
"""Invoke *command* by name through the integration CLI.
The integration's ``dispatch_command`` builds the native
slash-command invocation (e.g. ``/speckit.specify`` for
markdown agents, ``/speckit-specify`` for skills agents),
then executes the CLI non-interactively.
Returns the dispatch result dict, or ``None`` if dispatch is
not possible (integration not found, CLI not installed, or
dispatch not supported).
"""
if not integration_key:
return None
try:
from specify_cli.integrations import get_integration
except ImportError:
return None
impl = get_integration(integration_key)
if impl is None:
return None
# Check if the integration supports CLI dispatch
if impl.build_exec_args("test") is None:
return None
# Check if the CLI tool is actually installed
if not shutil.which(impl.key):
return None
project_root = Path(context.project_root) if context.project_root else None
try:
return impl.dispatch_command(
command,
args=args,
project_root=project_root,
model=model,
)
except (NotImplementedError, OSError):
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "command" not in config:
errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field."
)
return errors

View File

@@ -1,61 +0,0 @@
"""Do-While loop step — execute at least once, then repeat while condition is truthy."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
class DoWhileStep(StepBase):
"""Execute body at least once, then check condition.
Continues while condition is truthy. ``max_iterations`` is an
optional safety cap (defaults to 10 if omitted).
The first invocation always returns the nested steps for execution.
The engine re-evaluates ``step_config['condition']`` after each
iteration to decide whether to loop again.
"""
type_key = "do-while"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
max_iterations = config.get("max_iterations")
if max_iterations is None:
max_iterations = 10
nested_steps = config.get("steps", [])
condition = config.get("condition", "false")
# Always execute body at least once; the engine layer evaluates
# `condition` after each iteration to decide whether to loop.
return StepResult(
status=StepStatus.COMPLETED,
output={
"condition": condition,
"max_iterations": max_iterations,
"loop_type": "do-while",
},
next_steps=nested_steps,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "condition" not in config:
errors.append(
f"Do-while step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
if not isinstance(max_iter, int) or max_iter < 1:
errors.append(
f"Do-while step {config.get('id', '?')!r}: "
f"'max_iterations' must be an integer >= 1."
)
nested = config.get("steps", [])
if not isinstance(nested, list):
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'steps' must be a list."
)
return errors

View File

@@ -1,61 +0,0 @@
"""Fan-in step — join point for parallel steps."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class FanInStep(StepBase):
"""Join point that aggregates results from ``wait_for:`` steps.
Reads completed step outputs from ``context.steps`` and collects
them into ``output.results``. Does not block; relies on the
engine executing steps sequentially.
"""
type_key = "fan-in"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
wait_for = config.get("wait_for", [])
output_config = config.get("output") or {}
if not isinstance(output_config, dict):
output_config = {}
# Collect results from referenced steps
results = []
for step_id in wait_for:
step_data = context.steps.get(step_id, {})
results.append(step_data.get("output", {}))
# Resolve output expressions with fan_in in context
prev_fan_in = getattr(context, "fan_in", None)
context.fan_in = {"results": results}
resolved_output: dict[str, Any] = {"results": results}
try:
for key, expr in output_config.items():
if isinstance(expr, str) and "{{" in expr:
resolved_output[key] = evaluate_expression(expr, context)
else:
resolved_output[key] = expr
finally:
# Restore previous fan_in state even if evaluation fails
context.fan_in = prev_fan_in
return StepResult(
status=StepStatus.COMPLETED,
output=resolved_output,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
wait_for = config.get("wait_for", [])
if not isinstance(wait_for, list) or not wait_for:
errors.append(
f"Fan-in step {config.get('id', '?')!r}: "
f"'wait_for' must be a non-empty list of step IDs."
)
return errors

View File

@@ -1,58 +0,0 @@
"""Fan-out step — dispatch a step template over a collection."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class FanOutStep(StepBase):
"""Dispatch a step template for each item in a collection.
The engine executes the nested ``step:`` template once per item,
setting ``context.item`` for each iteration. Execution is
currently sequential; ``max_concurrency`` is accepted but not
enforced.
"""
type_key = "fan-out"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
items_expr = config.get("items", "[]")
items = evaluate_expression(items_expr, context)
if not isinstance(items, list):
items = []
max_concurrency = config.get("max_concurrency", 1)
step_template = config.get("step", {})
return StepResult(
status=StepStatus.COMPLETED,
output={
"items": items,
"max_concurrency": max_concurrency,
"step_template": step_template,
"item_count": len(items),
},
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "items" not in config:
errors.append(
f"Fan-out step {config.get('id', '?')!r} is missing "
f"'items' field."
)
if "step" not in config:
errors.append(
f"Fan-out step {config.get('id', '?')!r} is missing "
f"'step' field (nested step template)."
)
step = config.get("step")
if step is not None and not isinstance(step, dict):
errors.append(
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping."
)
return errors

View File

@@ -1,121 +0,0 @@
"""Gate step — human review gate."""
from __future__ import annotations
import sys
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class GateStep(StepBase):
"""Interactive review gate.
When running in an interactive terminal, prompts the user to choose
an option (e.g. approve / reject). Falls back to ``PAUSED`` when
stdin is not a TTY (CI, piped input) so the run can be resumed
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip behaviour.
"""
type_key = "gate"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
message = config.get("message", "Review required.")
if isinstance(message, str) and "{{" in message:
message = evaluate_expression(message, context)
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
show_file = config.get("show_file")
if show_file and isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
output = {
"message": message,
"options": options,
"on_reject": on_reject,
"show_file": show_file,
"choice": None,
}
# Non-interactive: pause for later resume
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
# Interactive: prompt the user
choice = self._prompt(message, options)
output["choice"] = choice
if choice in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(
status=StepStatus.FAILED,
output=output,
error=f"Gate rejected by user at step {config.get('id', '?')!r}",
)
if on_reject == "retry":
# Pause so the next resume re-executes this gate
return StepResult(status=StepStatus.PAUSED, output=output)
# on_reject == "skip" → completed, downstream steps decide
return StepResult(status=StepStatus.COMPLETED, output=output)
return StepResult(status=StepStatus.COMPLETED, output=output)
@staticmethod
def _prompt(message: str, options: list[str]) -> str:
"""Display gate message and prompt for a choice."""
print("\n ┌─ Gate ─────────────────────────────────────")
print(f"{message}")
print("")
for i, opt in enumerate(options, 1):
print(f" │ [{i}] {opt}")
print(" └────────────────────────────────────────────")
while True:
try:
raw = input(f" Choose [1-{len(options)}]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return options[-1] # default to last (usually reject)
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
# Also accept the option name directly
if raw.lower() in [o.lower() for o in options]:
return next(o for o in options if o.lower() == raw.lower())
print(f" Invalid choice. Enter 1-{len(options)} or an option name.")
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "message" not in config:
errors.append(
f"Gate step {config.get('id', '?')!r} is missing 'message' field."
)
options = config.get("options", ["approve", "reject"])
if not isinstance(options, list) or not options:
errors.append(
f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list."
)
elif not all(isinstance(o, str) for o in options):
errors.append(
f"Gate step {config.get('id', '?')!r}: all options must be strings."
)
on_reject = config.get("on_reject", "abort")
if on_reject not in ("abort", "skip", "retry"):
errors.append(
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
if on_reject in ("abort", "retry") and isinstance(options, list):
reject_choices = {"reject", "abort"}
if not any(o.lower() in reject_choices for o in options):
errors.append(
f"Gate step {config.get('id', '?')!r}: on_reject={on_reject!r} "
f"but options has no 'reject' or 'abort' choice."
)
return errors

View File

@@ -1,55 +0,0 @@
"""If/Then/Else step — conditional branching."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_condition
class IfThenStep(StepBase):
"""Branch based on a boolean condition expression.
Both ``then:`` and ``else:`` contain inline step arrays — full step
definitions, not ID references.
"""
type_key = "if"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
condition = config.get("condition", False)
result = evaluate_condition(condition, context)
if result:
branch = config.get("then", [])
else:
branch = config.get("else", [])
return StepResult(
status=StepStatus.COMPLETED,
output={"condition_result": result},
next_steps=branch,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "condition" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'condition' field."
)
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."
)
then_branch = config.get("then", [])
if not isinstance(then_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
)
else_branch = config.get("else", [])
if else_branch and not isinstance(else_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
)
return errors

View File

@@ -1,156 +0,0 @@
"""Prompt step — sends an arbitrary prompt to an integration CLI."""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class PromptStep(StepBase):
"""Send a free-form prompt to an integration CLI.
Unlike ``CommandStep`` which invokes an installed Spec Kit command
by name (e.g. ``/speckit.specify`` or ``/speckit-specify``),
``PromptStep`` sends an arbitrary inline ``prompt:`` string
directly to the CLI. This is useful for ad-hoc instructions
that don't map to a registered command.
.. note::
CLI output is streamed to the terminal for live progress.
``output.exit_code`` is always captured and can be referenced
by later steps. Full response text capture is a planned
enhancement.
Example YAML::
- id: review-security
type: prompt
prompt: "Review {{ inputs.file }} for security vulnerabilities"
integration: claude
"""
type_key = "prompt"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
prompt_template = config.get("prompt", "")
prompt = evaluate_expression(prompt_template, context)
if not isinstance(prompt, str):
prompt = str(prompt)
# Resolve integration (step → workflow default)
integration = config.get("integration") or context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model
model = config.get("model") or context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# Attempt CLI dispatch
dispatch_result = self._try_dispatch(
prompt, integration, model, context
)
output: dict[str, Any] = {
"prompt": prompt,
"integration": integration,
"model": model,
}
if dispatch_result is not None:
output["exit_code"] = dispatch_result["exit_code"]
output["stdout"] = dispatch_result["stdout"]
output["stderr"] = dispatch_result["stderr"]
output["dispatched"] = True
if dispatch_result["exit_code"] != 0:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
dispatch_result["stderr"]
or f"Prompt exited with code {dispatch_result['exit_code']}"
),
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
)
else:
output["exit_code"] = 1
output["dispatched"] = False
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Cannot dispatch prompt: "
f"integration {integration!r} "
f"CLI not found or not installed."
),
)
@staticmethod
def _try_dispatch(
prompt: str,
integration_key: str | None,
model: str | None,
context: StepContext,
) -> dict[str, Any] | None:
"""Dispatch *prompt* directly through the integration CLI."""
if not integration_key or not prompt:
return None
try:
from specify_cli.integrations import get_integration
except ImportError:
return None
impl = get_integration(integration_key)
if impl is None:
return None
exec_args = impl.build_exec_args(prompt, model=model, output_json=False)
if exec_args is None:
return None
if not shutil.which(impl.key):
return None
import subprocess
project_root = (
Path(context.project_root) if context.project_root else Path.cwd()
)
try:
result = subprocess.run(
exec_args,
text=True,
cwd=str(project_root),
)
return {
"exit_code": result.returncode,
"stdout": "",
"stderr": "",
}
except KeyboardInterrupt:
return {
"exit_code": 130,
"stdout": "",
"stderr": "Interrupted by user",
}
except OSError:
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "prompt" not in config:
errors.append(
f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field."
)
return errors

View File

@@ -1,75 +0,0 @@
"""Shell step — run a local shell command."""
from __future__ import annotations
import subprocess
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class ShellStep(StepBase):
"""Run a local shell command (non-agent).
Captures exit code and stdout/stderr.
"""
type_key = "shell"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
run_cmd = config.get("run", "")
if isinstance(run_cmd, str) and "{{" in run_cmd:
run_cmd = evaluate_expression(run_cmd, context)
run_cmd = str(run_cmd)
cwd = context.project_root or "."
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
# before use (see PUBLISHING.md for security guidance).
try:
proc = subprocess.run(
run_cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
timeout=300,
)
output = {
"exit_code": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
}
if proc.returncode != 0:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell command exited with code {proc.returncode}.",
output=output,
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
)
except subprocess.TimeoutExpired:
return StepResult(
status=StepStatus.FAILED,
error="Shell command timed out after 300 seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
)
except OSError as exc:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell command failed: {exc}",
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "run" not in config:
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
return errors

View File

@@ -1,70 +0,0 @@
"""Switch step — multi-branch dispatch."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class SwitchStep(StepBase):
"""Multi-branch dispatch on an expression.
Evaluates ``expression:`` once, matches against ``cases:`` keys
(exact match, string-coerced). Falls through to ``default:`` if
no case matches.
"""
type_key = "switch"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
expression = config.get("expression", "")
value = evaluate_expression(expression, context)
# String-coerce for matching
str_value = str(value) if value is not None else ""
cases = config.get("cases", {})
for case_key, case_steps in cases.items():
if str(case_key) == str_value:
return StepResult(
status=StepStatus.COMPLETED,
output={"matched_case": str(case_key), "expression_value": value},
next_steps=case_steps,
)
# Default fallback
default_steps = config.get("default", [])
return StepResult(
status=StepStatus.COMPLETED,
output={"matched_case": "__default__", "expression_value": value},
next_steps=default_steps,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "expression" not in config:
errors.append(
f"Switch step {config.get('id', '?')!r} is missing "
f"'expression' field."
)
cases = config.get("cases", {})
if not isinstance(cases, dict):
errors.append(
f"Switch step {config.get('id', '?')!r}: 'cases' must be a mapping."
)
else:
for key, val in cases.items():
if not isinstance(val, list):
errors.append(
f"Switch step {config.get('id', '?')!r}: "
f"case {key!r} must be a list of steps."
)
default = config.get("default")
if default is not None and not isinstance(default, list):
errors.append(
f"Switch step {config.get('id', '?')!r}: "
f"'default' must be a list of steps."
)
return errors

View File

@@ -1,68 +0,0 @@
"""While loop step — repeat while condition is truthy."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_condition
class WhileStep(StepBase):
"""Repeat nested steps while condition is truthy.
Evaluates condition *before* each iteration. If falsy on first
check, the body never runs. ``max_iterations`` is an optional
safety cap (defaults to 10 if omitted).
"""
type_key = "while"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
condition = config.get("condition", False)
max_iterations = config.get("max_iterations")
if max_iterations is None:
max_iterations = 10
nested_steps = config.get("steps", [])
result = evaluate_condition(condition, context)
if result:
return StepResult(
status=StepStatus.COMPLETED,
output={
"condition_result": True,
"max_iterations": max_iterations,
"loop_type": "while",
},
next_steps=nested_steps,
)
return StepResult(
status=StepStatus.COMPLETED,
output={
"condition_result": False,
"max_iterations": max_iterations,
"loop_type": "while",
},
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "condition" not in config:
errors.append(
f"While step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
if not isinstance(max_iter, int) or max_iter < 1:
errors.append(
f"While step {config.get('id', '?')!r}: "
f"'max_iterations' must be an integer >= 1."
)
nested = config.get("steps", [])
if not isinstance(nested, list):
errors.append(
f"While step {config.get('id', '?')!r}: 'steps' must be a list."
)
return errors

View File

@@ -13,40 +13,6 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before analysis)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Goal.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
@@ -199,37 +165,6 @@ At end of report, output a concise Next Actions block:
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
### 9. Check for extension hooks
After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Operating Principles
### Context Efficiency

View File

@@ -34,40 +34,6 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before checklist generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Execution Steps.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Execution Steps
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
@@ -330,35 +296,3 @@ Sample items:
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"
## Post-Execution Checks
**Check for extension hooks (after checklist generation)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

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