mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b397b02f1 | ||
|
|
697daec733 | ||
|
|
02a1d610df | ||
|
|
8d2797dc03 | ||
|
|
076bb40f2e | ||
|
|
530d1ce514 | ||
|
|
c717cbb42d | ||
|
|
282dd3da56 | ||
|
|
e0fd355dad | ||
|
|
db8131441e | ||
|
|
752683d347 | ||
|
|
9988a46d96 | ||
|
|
27b4fd2e32 | ||
|
|
8fc2bd3489 | ||
|
|
b78a3cdd88 | ||
|
|
2f5417f0ad | ||
|
|
33a28ec8f7 | ||
|
|
f0886bd089 | ||
|
|
39c7b04e5e | ||
|
|
3467d26b1c | ||
|
|
a00e679918 | ||
|
|
c0152e4f3d | ||
|
|
03a9163633 | ||
|
|
4687c33b0f | ||
|
|
de93528fad | ||
|
|
bb7da09b65 | ||
|
|
fe75a45627 | ||
|
|
aa85b2f166 | ||
|
|
e27896e681 | ||
|
|
b67b2856b1 | ||
|
|
52ed84d723 | ||
|
|
cdbea09e1a | ||
|
|
1cb794e516 | ||
|
|
43cb0fa7ab | ||
|
|
74e3f45aa9 | ||
|
|
97ea7cf6a0 | ||
|
|
f43b85096c | ||
|
|
d1b95c2f59 | ||
|
|
8bb08ae1a0 | ||
|
|
5732de60d0 | ||
|
|
b6e19b49ec | ||
|
|
7f1e38491f | ||
|
|
bc0288832e | ||
|
|
e70495c2b8 | ||
|
|
674a66449a | ||
|
|
efeb5489c3 | ||
|
|
8013d0b57e | ||
|
|
0a121b073c | ||
|
|
6af2e64e88 | ||
|
|
66125a80a9 | ||
|
|
55515093a2 | ||
|
|
aa2282ea04 |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -26,6 +26,7 @@ concurrency:
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
if: github.repository == 'github/spec-kit'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -56,6 +57,7 @@ jobs:
|
||||
|
||||
# Deploy job
|
||||
deploy:
|
||||
if: github.repository == 'github/spec-kit'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -27,9 +27,10 @@ jobs:
|
||||
run: uvx ruff check src/
|
||||
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -46,5 +47,9 @@ 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
|
||||
|
||||
593
AGENTS.md
593
AGENTS.md
@@ -10,277 +10,282 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agent Support
|
||||
## Integration Architecture
|
||||
|
||||
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.
|
||||
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()`.
|
||||
|
||||
### Overview
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Specify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for:
|
||||
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.
|
||||
|
||||
- **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.)
|
||||
---
|
||||
|
||||
### Current Supported Agents
|
||||
## Adding a New Integration
|
||||
|
||||
| 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) |
|
||||
| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) |
|
||||
| **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 |
|
||||
### 1. Choose a base class
|
||||
|
||||
### Step-by-Step Integration Guide
|
||||
| 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 |
|
||||
|
||||
Follow these steps to add a new agent (using a hypothetical new agent as an example):
|
||||
Most agents only need `MarkdownIntegration` — a minimal subclass with zero method overrides.
|
||||
|
||||
#### 1. Add to AGENT_CONFIG
|
||||
### 2. Create the subpackage
|
||||
|
||||
**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version.
|
||||
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.
|
||||
|
||||
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:
|
||||
**Minimal example — Markdown agent (Windsurf):**
|
||||
|
||||
```python
|
||||
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
|
||||
},
|
||||
}
|
||||
"""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"
|
||||
```
|
||||
|
||||
**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:
|
||||
**TOML agent (Gemini):**
|
||||
|
||||
```python
|
||||
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"),
|
||||
"""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"
|
||||
```
|
||||
|
||||
Also update any function docstrings, examples, and error messages that list available agents.
|
||||
**Skills agent (Codex):**
|
||||
|
||||
#### 3. Update README Documentation
|
||||
```python
|
||||
"""Codex CLI integration — skills-based agent."""
|
||||
|
||||
Update the **Supported AI Agents** section in `README.md` to include the new agent:
|
||||
from __future__ import annotations
|
||||
|
||||
- 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
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
#### 4. Update Release Package Script
|
||||
|
||||
Modify `.github/workflows/scripts/create-release-packages.sh`:
|
||||
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"
|
||||
|
||||
##### Add to ALL_AGENTS array
|
||||
@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`:**
|
||||
|
||||
```bash
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli)
|
||||
#!/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>
|
||||
```
|
||||
|
||||
##### 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:
|
||||
**`update-context.ps1`:**
|
||||
|
||||
```powershell
|
||||
$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md'
|
||||
```
|
||||
# update-context.ps1 — <Agent Name> integration: create/update <context_file>
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
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",
|
||||
# ...
|
||||
$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
|
||||
}
|
||||
}
|
||||
|
||||
# Then you need special cases everywhere:
|
||||
cli_tool = agent_key
|
||||
if agent_key == "cursor":
|
||||
cli_tool = "cursor-agent" # Map to the real tool name
|
||||
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType <key>
|
||||
```
|
||||
|
||||
✅ **Correct approach** (no mapping needed):
|
||||
Replace `<key>` with your integration key and `<Agent Name>` / `<context_file>` with the appropriate values.
|
||||
|
||||
```python
|
||||
AGENT_CONFIG = {
|
||||
"cursor-agent": { # Matches the actual executable name
|
||||
"name": "Cursor",
|
||||
# ...
|
||||
}
|
||||
}
|
||||
You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key:
|
||||
|
||||
# No special cases needed - just use agent_key directly!
|
||||
- **`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>
|
||||
```
|
||||
|
||||
**Benefits of this approach:**
|
||||
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:
|
||||
|
||||
- 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
|
||||
```bash
|
||||
pytest tests/integrations/test_integration_<key_with_underscores>.py -v
|
||||
```
|
||||
|
||||
#### 7. Update Devcontainer files (Optional)
|
||||
### 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)
|
||||
|
||||
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`:
|
||||
|
||||
```json
|
||||
```jsonc
|
||||
{
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
// ... existing extensions ...
|
||||
// [New Agent Name]
|
||||
"[New Agent Extension ID]"
|
||||
]
|
||||
}
|
||||
@@ -288,7 +293,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`:
|
||||
|
||||
@@ -298,63 +303,16 @@ 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" # Example for node-based CLI
|
||||
# or other installation instructions (must be non-interactive and compatible with Linux Debian "Trixie" or later)...
|
||||
# run_command "npm install -g [agent-cli-package]@latest"
|
||||
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
|
||||
- **Forge**: `forge` 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, Forge
|
||||
|
||||
**Standard format:**
|
||||
|
||||
```markdown
|
||||
@@ -378,8 +336,6 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders.
|
||||
|
||||
### TOML Format
|
||||
|
||||
Used by: Gemini, Tabnine
|
||||
|
||||
```toml
|
||||
description = "Command description"
|
||||
|
||||
@@ -388,40 +344,33 @@ Command content with {SCRIPT} and {{args}} placeholders.
|
||||
"""
|
||||
```
|
||||
|
||||
## Directory Conventions
|
||||
### YAML Format
|
||||
|
||||
- **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)
|
||||
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.
|
||||
```
|
||||
|
||||
## Argument Patterns
|
||||
|
||||
Different agents use different argument placeholders:
|
||||
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:
|
||||
|
||||
- **Markdown/prompt-based**: `$ARGUMENTS`
|
||||
- **TOML-based**: `{{args}}`
|
||||
- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax)
|
||||
- **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}}`)
|
||||
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
|
||||
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
|
||||
|
||||
@@ -455,42 +404,30 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
|
||||
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
|
||||
4. Strips `handoffs` frontmatter key
|
||||
5. Injects missing `name` fields
|
||||
6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` (similar to `opencode`/`codex`/`pi`) and lists `forge` in their usage/help text
|
||||
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
|
||||
|
||||
### Standard Markdown Agents
|
||||
### Goose Integration
|
||||
|
||||
Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`:
|
||||
- Simple subclass with just `key`, `config`, `registrar_config` set
|
||||
- Inherits standard processing from `MarkdownIntegration.setup()`
|
||||
- No custom processing needed
|
||||
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
|
||||
|
||||
## Testing New Agent Integration
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.*
|
||||
*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.*
|
||||
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -2,6 +2,87 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
106
CONTRIBUTING.md
106
CONTRIBUTING.md
@@ -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-agents)
|
||||
1. Have an [AI coding agent available](README.md#-supported-ai-coding-agent-integrations)
|
||||
|
||||
<details>
|
||||
<summary><b>💡 Hint if you are using <code>VSCode</code> or <code>GitHub Codespaces</code> as your IDE</b></summary>
|
||||
@@ -44,8 +44,7 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
|
||||
|
||||
@@ -69,34 +68,99 @@ 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 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.
|
||||
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.
|
||||
|
||||
### Testing template and command changes locally
|
||||
### Automated checks
|
||||
|
||||
Running `uv run specify init` pulls released packages, which won’t include your local changes.
|
||||
To test your templates, commands, and other changes locally, follow these steps:
|
||||
#### Agent configuration and wiring consistency
|
||||
|
||||
1. **Create release packages**
|
||||
```bash
|
||||
uv run python -m pytest tests/test_agent_config_consistency.py -q
|
||||
```
|
||||
|
||||
Run the following command to generate the local packages:
|
||||
Run this when you change agent metadata, context update scripts, or integration wiring.
|
||||
|
||||
```bash
|
||||
./.github/workflows/scripts/create-release-packages.sh v1.0.0
|
||||
```
|
||||
### Manual testing
|
||||
|
||||
2. **Copy the relevant package to your test project**
|
||||
#### Testing setup
|
||||
|
||||
```bash
|
||||
cp -r .genreleases/sdd-copilot-package-sh/. <path-to-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.
|
||||
|
||||
3. **Open and test the agent**
|
||||
# Initialize a test project using your local changes
|
||||
uv run specify init <temp-dir>/speckit-test --ai <agent> --offline
|
||||
cd <temp-dir>/speckit-test
|
||||
|
||||
Navigate to your test project folder and open the agent to verify your implementation.
|
||||
# Open in your agent
|
||||
```
|
||||
|
||||
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.
|
||||
#### 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)
|
||||
~~~
|
||||
|
||||
## AI contributions in Spec Kit
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ Spec Kit is a toolkit for spec-driven development. At its core, it is a coordina
|
||||
| [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, and required development practices. |
|
||||
| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, testing, and required development practices. |
|
||||
|
||||
**Main repository components:**
|
||||
|
||||
|
||||
270
README.md
270
README.md
@@ -26,7 +26,7 @@
|
||||
- [🎨 Community Presets](#-community-presets)
|
||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
||||
- [🛠️ Community Friends](#️-community-friends)
|
||||
- [🤖 Supported AI Agents](#-supported-ai-agents)
|
||||
- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations)
|
||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
||||
- [📚 Core Philosophy](#-core-philosophy)
|
||||
@@ -50,6 +50,8 @@ 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):
|
||||
@@ -62,16 +64,22 @@ 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 use the tool directly:
|
||||
Then verify the correct version is installed:
|
||||
|
||||
```bash
|
||||
specify version
|
||||
```
|
||||
|
||||
And use the tool directly:
|
||||
|
||||
```bash
|
||||
# Create new project
|
||||
specify init <PROJECT_NAME>
|
||||
|
||||
# Or initialize in existing project
|
||||
specify init . --ai claude
|
||||
specify init . --ai copilot
|
||||
# or
|
||||
specify init --here --ai claude
|
||||
specify init --here --ai copilot
|
||||
|
||||
# Check installed tools
|
||||
specify check
|
||||
@@ -92,9 +100,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 claude
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot
|
||||
# or
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude
|
||||
uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot
|
||||
```
|
||||
|
||||
**Benefits of persistent installation:**
|
||||
@@ -182,11 +190,17 @@ 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) |
|
||||
@@ -196,6 +210,8 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
@@ -206,9 +222,11 @@ 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) |
|
||||
@@ -222,16 +240,23 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| 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).
|
||||
|
||||
@@ -247,6 +272,8 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| 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) |
|
||||
@@ -285,208 +312,61 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
|
||||
- **[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.
|
||||
|
||||
## 🤖 Supported AI Agents
|
||||
- **[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.
|
||||
|
||||
| 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/) | ✅ | |
|
||||
| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` |
|
||||
| [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 |
|
||||
## 🤖 Supported AI Coding Agent Integrations
|
||||
|
||||
## 🔧 Specify CLI Reference
|
||||
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.
|
||||
|
||||
The `specify` command supports the following options:
|
||||
Run `specify integration list` to see all available integrations in your installed version.
|
||||
|
||||
### Commands
|
||||
## Available Slash Commands
|
||||
|
||||
| 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`, `forge`, 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`, `forge`, 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`, …, `1000`, … — expands beyond 3 digits automatically) 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 Forge support
|
||||
specify init my-project --ai forge
|
||||
|
||||
# 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`.
|
||||
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`.
|
||||
|
||||
#### Core Commands
|
||||
|
||||
Essential commands for the Spec-Driven Development workflow:
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
#### Optional Commands
|
||||
|
||||
Additional commands for enhanced quality and validation:
|
||||
|
||||
| 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") |
|
||||
| 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") |
|
||||
|
||||
### Environment Variables
|
||||
## 🔧 Specify CLI Reference
|
||||
|
||||
| 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. |
|
||||
For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html).
|
||||
|
||||
## 🧩 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:
|
||||
|
||||
```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"]
|
||||
| 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/` |
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
### Extensions — Add New Capabilities
|
||||
|
||||
@@ -502,7 +382,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 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.
|
||||
See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available.
|
||||
|
||||
### Presets — Customize Existing Workflows
|
||||
|
||||
@@ -518,7 +398,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 README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own.
|
||||
See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking.
|
||||
|
||||
### When to Use Which
|
||||
|
||||
@@ -576,7 +456,7 @@ Our research and experimentation focus on:
|
||||
## 🔧 Prerequisites
|
||||
|
||||
- **Linux/macOS/Windows**
|
||||
- [Supported](#-supported-ai-agents) AI coding agent.
|
||||
- [Supported](#-supported-ai-coding-agent-integrations) 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)
|
||||
@@ -618,29 +498,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 claude
|
||||
specify init <project_name> --ai copilot
|
||||
specify init <project_name> --ai gemini
|
||||
specify init <project_name> --ai copilot
|
||||
|
||||
# Or in current directory:
|
||||
specify init . --ai claude
|
||||
specify init . --ai copilot
|
||||
specify init . --ai codex --ai-skills
|
||||
|
||||
# or use --here flag
|
||||
specify init --here --ai claude
|
||||
specify init --here --ai copilot
|
||||
specify init --here --ai codex --ai-skills
|
||||
|
||||
# Force merge into a non-empty current directory
|
||||
specify init . --force --ai claude
|
||||
specify init . --force --ai copilot
|
||||
|
||||
# or
|
||||
specify init --here --force --ai claude
|
||||
specify init --here --force --ai copilot
|
||||
```
|
||||
|
||||
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, 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, 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:
|
||||
|
||||
```bash
|
||||
specify init <project_name> --ai claude --ignore-agent-tools
|
||||
specify init <project_name> --ai copilot --ignore-agent-tools
|
||||
```
|
||||
|
||||
### **STEP 1:** Establish project principles
|
||||
|
||||
133
TESTING.md
133
TESTING.md
@@ -1,133 +0,0 @@
|
||||
# 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)
|
||||
~~~
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
## 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):
|
||||
@@ -69,6 +71,14 @@ 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
|
||||
|
||||
@@ -128,16 +128,14 @@ 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 Skips
|
||||
## 9. Debug Network / TLS Issues
|
||||
|
||||
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.)
|
||||
> **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`.
|
||||
|
||||
## 10. Rapid Edit Loop Summary
|
||||
|
||||
@@ -166,7 +164,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 | Try `--skip-tls` (not for production) |
|
||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
|
||||
## 13. Next Steps
|
||||
|
||||
|
||||
79
docs/reference/core.md
Normal file
79
docs/reference/core.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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
|
||||
```
|
||||
201
docs/reference/extensions.md
Normal file
201
docs/reference/extensions.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
140
docs/reference/integrations.md
Normal file
140
docs/reference/integrations.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
33
docs/reference/overview.md
Normal file
33
docs/reference/overview.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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)
|
||||
224
docs/reference/presets.md
Normal file
224
docs/reference/presets.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# 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.
|
||||
289
docs/reference/workflows.md
Normal file
289
docs/reference/workflows.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 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.
|
||||
16
docs/toc.yml
16
docs/toc.yml
@@ -12,6 +12,22 @@
|
||||
- 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:
|
||||
|
||||
@@ -76,7 +76,7 @@ Run this inside your project directory:
|
||||
specify init --here --force --ai <your-agent>
|
||||
```
|
||||
|
||||
Replace `<your-agent>` with your AI assistant. Refer to this list of [Supported AI Agents](../README.md#-supported-ai-agents)
|
||||
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
|
||||
|
||||
**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/commands/ # Cursor
|
||||
ls -la .cursor/skills/ # 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 assistant 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 coding agent reads these command files directly—no need to run `specify` again.
|
||||
|
||||
**If your agent isn't recognizing slash commands:**
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-08T00:00:00Z",
|
||||
"updated_at": "2026-04-16T18:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -36,6 +36,70 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"agent-assign": {
|
||||
"name": "Agent Assign",
|
||||
"id": "agent-assign",
|
||||
"description": "Assign specialized Claude Code agents to spec-kit tasks for targeted execution",
|
||||
"author": "xuyang",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/xymelon/spec-kit-agent-assign/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/xymelon/spec-kit-agent-assign",
|
||||
"homepage": "https://github.com/xymelon/spec-kit-agent-assign",
|
||||
"documentation": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/README.md",
|
||||
"changelog": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.3.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"agent",
|
||||
"automation",
|
||||
"implementation",
|
||||
"multi-agent",
|
||||
"task-routing"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-31T00:00:00Z",
|
||||
"updated_at": "2026-03-31T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
"description": "Predicts architectural impact, complexity, and risks of proposed changes before implementation.",
|
||||
"author": "Umme Habiba",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview",
|
||||
"homepage": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview",
|
||||
"documentation": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/README.md",
|
||||
"changelog": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"analysis",
|
||||
"risk-assessment",
|
||||
"planning",
|
||||
"preview"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-14T00:00:00Z",
|
||||
"updated_at": "2026-04-14T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
"id": "archive",
|
||||
@@ -138,6 +202,70 @@
|
||||
"created_at": "2026-04-08T00:00:00Z",
|
||||
"updated_at": "2026-04-08T00:00:00Z"
|
||||
},
|
||||
"brownfield": {
|
||||
"name": "Brownfield Bootstrap",
|
||||
"id": "brownfield",
|
||||
"description": "Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-brownfield/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-brownfield",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-brownfield",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"brownfield",
|
||||
"bootstrap",
|
||||
"existing-project",
|
||||
"migration",
|
||||
"onboarding"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
},
|
||||
"bugfix": {
|
||||
"name": "Bugfix Workflow",
|
||||
"id": "bugfix",
|
||||
"description": "Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-bugfix/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-bugfix",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-bugfix",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"bugfix",
|
||||
"debugging",
|
||||
"workflow",
|
||||
"traceability",
|
||||
"maintenance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"canon": {
|
||||
"name": "Canon",
|
||||
"id": "canon",
|
||||
@@ -173,6 +301,71 @@
|
||||
"created_at": "2026-03-29T00:00:00Z",
|
||||
"updated_at": "2026-03-29T00:00:00Z"
|
||||
},
|
||||
"catalog-ci": {
|
||||
"name": "Catalog CI",
|
||||
"id": "catalog-ci",
|
||||
"description": "Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"ci",
|
||||
"validation",
|
||||
"catalog",
|
||||
"quality",
|
||||
"automation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-16T00:00:00Z",
|
||||
"updated_at": "2026-04-16T00:00:00Z"
|
||||
},
|
||||
"ci-guard": {
|
||||
"name": "CI Guard",
|
||||
"id": "ci-guard",
|
||||
"description": "Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-ci-guard",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-ci-guard",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"ci-cd",
|
||||
"compliance",
|
||||
"governance",
|
||||
"quality-gate",
|
||||
"drift-detection",
|
||||
"automation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-10T17:00:00Z",
|
||||
"updated_at": "2026-04-10T17:00:00Z"
|
||||
},
|
||||
"checkpoint": {
|
||||
"name": "Checkpoint Extension",
|
||||
"id": "checkpoint",
|
||||
@@ -552,6 +745,78 @@
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-31T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
"id": "github-issues",
|
||||
"description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability",
|
||||
"author": "Fatima367",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Fatima367/spec-kit-github-issues/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Fatima367/spec-kit-github-issues",
|
||||
"homepage": "https://github.com/Fatima367/spec-kit-github-issues",
|
||||
"documentation": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/README.md",
|
||||
"changelog": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "gh",
|
||||
"version": ">=2.0.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"integration",
|
||||
"github",
|
||||
"issues",
|
||||
"import",
|
||||
"sync",
|
||||
"traceability"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-12T15:30:00Z",
|
||||
"updated_at": "2026-04-13T14:39:00Z"
|
||||
},
|
||||
"issue": {
|
||||
"name": "GitHub Issues Integration 2",
|
||||
"id": "issue",
|
||||
"description": "Creates and syncs local specs based on an existing issue in GitHub",
|
||||
"author": "aaronrsun",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/aaronrsun/spec-kit-issue/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/aaronrsun/spec-kit-issue",
|
||||
"homepage": "https://github.com/aaronrsun/spec-kit-issue",
|
||||
"documentation": "https://github.com/aaronrsun/spec-kit-issue/blob/main/README.md",
|
||||
"changelog": "https://github.com/aaronrsun/spec-kit-issue/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"issue",
|
||||
"integration",
|
||||
"github",
|
||||
"issues",
|
||||
"sync"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-04T00:00:00Z",
|
||||
"updated_at": "2026-04-04T00:00:00Z"
|
||||
},
|
||||
"iterate": {
|
||||
"name": "Iterate",
|
||||
"id": "iterate",
|
||||
@@ -870,6 +1135,38 @@
|
||||
"created_at": "2026-03-26T00:00:00Z",
|
||||
"updated_at": "2026-03-26T00:00:00Z"
|
||||
},
|
||||
"memorylint": {
|
||||
"name": "MemoryLint",
|
||||
"id": "memorylint",
|
||||
"description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.",
|
||||
"author": "RbBtSn0w",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.0/memorylint.zip",
|
||||
"repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
|
||||
"homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint",
|
||||
"documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md",
|
||||
"changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.5.1"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"memory",
|
||||
"governance",
|
||||
"constitution",
|
||||
"agents-md",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-16T13:10:26Z"
|
||||
},
|
||||
"onboard": {
|
||||
"name": "Onboard",
|
||||
"id": "onboard",
|
||||
@@ -966,6 +1263,38 @@
|
||||
"created_at": "2026-03-27T08:22:30Z",
|
||||
"updated_at": "2026-03-27T08:22:30Z"
|
||||
},
|
||||
"pr-bridge": {
|
||||
"name": "PR Bridge",
|
||||
"id": "pr-bridge",
|
||||
"description": "Auto-generate pull request descriptions, checklists, and summaries from spec artifacts.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"pull-request",
|
||||
"automation",
|
||||
"traceability",
|
||||
"workflow",
|
||||
"review"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
},
|
||||
"presetify": {
|
||||
"name": "Presetify",
|
||||
"id": "presetify",
|
||||
@@ -1064,8 +1393,8 @@
|
||||
"id": "ralph",
|
||||
"description": "Autonomous implementation loop using AI agent CLI.",
|
||||
"author": "Rubiss",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/Rubiss/spec-kit-ralph",
|
||||
"homepage": "https://github.com/Rubiss/spec-kit-ralph",
|
||||
"documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md",
|
||||
@@ -1098,7 +1427,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-09T00:00:00Z",
|
||||
"updated_at": "2026-03-09T00:00:00Z"
|
||||
"updated_at": "2026-04-12T19:00:00Z"
|
||||
},
|
||||
"reconcile": {
|
||||
"name": "Reconcile Extension",
|
||||
@@ -1267,8 +1596,8 @@
|
||||
"id": "review",
|
||||
"description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.",
|
||||
"author": "ismaelJimenez",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/ismaelJimenez/spec-kit-review",
|
||||
"homepage": "https://github.com/ismaelJimenez/spec-kit-review",
|
||||
"documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md",
|
||||
@@ -1294,7 +1623,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-06T00:00:00Z",
|
||||
"updated_at": "2026-03-06T00:00:00Z"
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"security-review": {
|
||||
"name": "Security Review",
|
||||
@@ -1328,6 +1657,50 @@
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-04-03T04:15:00Z"
|
||||
},
|
||||
"sf": {
|
||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||
"id": "sf",
|
||||
"description": "Enterprise-Grade Spec-Driven Development (SDD) Framework for Salesforce.",
|
||||
"author": "Sumanth Yanamala",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/ysumanth06/spec-kit-sf/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/ysumanth06/spec-kit-sf",
|
||||
"homepage": "https://ysumanth06.github.io/spec-kit-sf/",
|
||||
"documentation": "https://ysumanth06.github.io/spec-kit-sf/introduction.html",
|
||||
"changelog": "https://github.com/ysumanth06/spec-kit-sf/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "sf",
|
||||
"version": ">=2.0.0",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "gh",
|
||||
"version": ">=2.0.0",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 18,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"salesforce",
|
||||
"enterprise",
|
||||
"sdlc",
|
||||
"apex",
|
||||
"devops"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-13T22:11:30Z",
|
||||
"updated_at": "2026-04-13T22:11:30Z"
|
||||
},
|
||||
"ship": {
|
||||
"name": "Ship Release Extension",
|
||||
"id": "ship",
|
||||
@@ -1390,6 +1763,39 @@
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"spectest": {
|
||||
"name": "SpecTest",
|
||||
"id": "spectest",
|
||||
"description": "Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-spectest/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-spectest",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-spectest",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"testing",
|
||||
"test-generation",
|
||||
"coverage",
|
||||
"quality",
|
||||
"automation",
|
||||
"traceability"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-10T16:00:00Z",
|
||||
"updated_at": "2026-04-10T16:00:00Z"
|
||||
},
|
||||
"staff-review": {
|
||||
"name": "Staff Review Extension",
|
||||
"id": "staff-review",
|
||||
@@ -1452,13 +1858,43 @@
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
},
|
||||
"status-report": {
|
||||
"name": "Status Report",
|
||||
"id": "status-report",
|
||||
"description": "Project status, feature progress, and next-action recommendations for spec-driven workflows.",
|
||||
"author": "Open-Agent-Tools",
|
||||
"version": "1.2.5",
|
||||
"download_url": "https://github.com/Open-Agent-Tools/spec-kit-status/archive/refs/tags/v1.2.5.zip",
|
||||
"repository": "https://github.com/Open-Agent-Tools/spec-kit-status",
|
||||
"homepage": "https://github.com/Open-Agent-Tools/spec-kit-status",
|
||||
"documentation": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/README.md",
|
||||
"changelog": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"workflow",
|
||||
"project-management",
|
||||
"status"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-08T15:05:14Z",
|
||||
"updated_at": "2026-04-08T15:05:14Z"
|
||||
},
|
||||
"superb": {
|
||||
"name": "Superpowers Bridge",
|
||||
"id": "superb",
|
||||
"description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.",
|
||||
"author": "rbbtsn0w",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.0.0/superpowers-bridge.zip",
|
||||
"version": "1.3.0",
|
||||
"download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.0/superpowers-bridge.zip",
|
||||
"repository": "https://github.com/RbBtSn0w/spec-kit-extensions",
|
||||
"homepage": "https://github.com/RbBtSn0w/spec-kit-extensions",
|
||||
"documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md",
|
||||
@@ -1493,7 +1929,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-03-30T00:00:00Z"
|
||||
"updated_at": "2026-04-16T14:08:23Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
@@ -1527,6 +1963,38 @@
|
||||
"created_at": "2026-03-02T00:00:00Z",
|
||||
"updated_at": "2026-03-02T00:00:00Z"
|
||||
},
|
||||
"tinyspec": {
|
||||
"name": "TinySpec",
|
||||
"id": "tinyspec",
|
||||
"description": "Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-tinyspec",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-tinyspec",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"lightweight",
|
||||
"small-tasks",
|
||||
"workflow",
|
||||
"productivity",
|
||||
"efficiency"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
},
|
||||
"v-model": {
|
||||
"name": "V-Model Extension Pack",
|
||||
"id": "v-model",
|
||||
@@ -1564,8 +2032,8 @@
|
||||
"id": "verify",
|
||||
"description": "Post-implementation quality gate that validates implemented code against specification artifacts.",
|
||||
"author": "ismaelJimenez",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.0.3",
|
||||
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip",
|
||||
"repository": "https://github.com/ismaelJimenez/spec-kit-verify",
|
||||
"homepage": "https://github.com/ismaelJimenez/spec-kit-verify",
|
||||
"documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md",
|
||||
@@ -1589,7 +2057,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-03T00:00:00Z"
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
@@ -1621,6 +2089,98 @@
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
},
|
||||
"whatif": {
|
||||
"name": "What-if Analysis",
|
||||
"id": "whatif",
|
||||
"description": "Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them.",
|
||||
"author": "DevAbdullah90",
|
||||
"version": "1.0.0",
|
||||
"repository": "https://github.com/DevAbdullah90/spec-kit-whatif",
|
||||
"homepage": "https://github.com/DevAbdullah90/spec-kit-whatif",
|
||||
"documentation": "https://github.com/DevAbdullah90/spec-kit-whatif/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.6.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"analysis",
|
||||
"planning",
|
||||
"simulation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
},
|
||||
"worktree": {
|
||||
"name": "Worktree Isolation",
|
||||
"id": "worktree",
|
||||
"description": "Spawn isolated git worktrees for parallel feature development without checkout switching.",
|
||||
"author": "Quratulain-bilal",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/Quratulain-bilal/spec-kit-worktree/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/Quratulain-bilal/spec-kit-worktree",
|
||||
"homepage": "https://github.com/Quratulain-bilal/spec-kit-worktree",
|
||||
"documentation": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/README.md",
|
||||
"changelog": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"worktree",
|
||||
"git",
|
||||
"parallel",
|
||||
"isolation",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"worktrees": {
|
||||
"name": "Worktrees",
|
||||
"id": "worktrees",
|
||||
"description": "Default-on worktree isolation for parallel agents — sibling or nested layout",
|
||||
"author": "dango85",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/dango85/spec-kit-worktree-parallel/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/dango85/spec-kit-worktree-parallel",
|
||||
"homepage": "https://github.com/dango85/spec-kit-worktree-parallel",
|
||||
"documentation": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/README.md",
|
||||
"changelog": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.4.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"worktree",
|
||||
"git",
|
||||
"parallel",
|
||||
"isolation",
|
||||
"agents"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-13T00:00:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-06T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
|
||||
"extensions": {
|
||||
"git": {
|
||||
@@ -10,27 +10,13 @@
|
||||
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
|
||||
"bundled": true,
|
||||
"tags": [
|
||||
"git",
|
||||
"branching",
|
||||
"workflow",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"selftest": {
|
||||
"name": "Spec Kit Self-Test Utility",
|
||||
"id": "selftest",
|
||||
"version": "1.0.0",
|
||||
"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",
|
||||
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
|
||||
"tags": [
|
||||
"testing",
|
||||
"core",
|
||||
"utility"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,4 +137,4 @@ fi
|
||||
_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 "✓ Changes committed ${_phase} ${_command_name}" >&2
|
||||
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
||||
|
||||
@@ -11,10 +11,22 @@ 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
|
||||
}
|
||||
|
||||
# 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 branch="$1"
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
@@ -23,19 +35,20 @@ check_feature_branch() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug)
|
||||
if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
||||
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
|
||||
|
||||
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -146,4 +146,4 @@ try {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Changes committed $phase $commandName"
|
||||
Write-Host "[OK] Changes committed $phase $commandName"
|
||||
|
||||
@@ -15,6 +15,14 @@ function Test-HasGit {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
@@ -27,24 +35,17 @@ function Test-FeatureBranch {
|
||||
return $true
|
||||
}
|
||||
|
||||
# Reject malformed timestamps (7-digit date or no trailing slug)
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or
|
||||
($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
if ($hasMalformedTimestamp) {
|
||||
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
||||
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
|
||||
$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
|
||||
}
|
||||
|
||||
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
$isTimestamp = $Branch -match '^\d{8}-\d{6}-'
|
||||
|
||||
if ($isSequential -or $isTimestamp) {
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
||||
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
|
||||
return $false
|
||||
return $true
|
||||
}
|
||||
|
||||
142
integrations/CONTRIBUTING.md
Normal file
142
integrations/CONTRIBUTING.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# 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
|
||||
```
|
||||
129
integrations/README.md
Normal file
129
integrations/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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.
|
||||
6
integrations/catalog.community.json
Normal file
6
integrations/catalog.community.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
259
integrations/catalog.json
Normal file
259
integrations/catalog.json
Normal file
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-06T06:30:00Z",
|
||||
"updated_at": "2026-04-13T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"aide-in-place": {
|
||||
@@ -53,6 +53,33 @@
|
||||
"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",
|
||||
@@ -78,6 +105,67 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
|
||||
"presets": {}
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
presets/lean/commands/speckit.constitution.md
Normal file
15
presets/lean/commands/speckit.constitution.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
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)
|
||||
22
presets/lean/commands/speckit.implement.md
Normal file
22
presets/lean/commands/speckit.implement.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
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.
|
||||
19
presets/lean/commands/speckit.plan.md
Normal file
19
presets/lean/commands/speckit.plan.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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
|
||||
23
presets/lean/commands/speckit.specify.md
Normal file
23
presets/lean/commands/speckit.specify.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
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
|
||||
19
presets/lean/commands/speckit.tasks.md
Normal file
19
presets/lean/commands/speckit.tasks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
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
|
||||
50
presets/lean/preset.yml
Normal file
50
presets/lean/preset.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
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"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.5.1"
|
||||
version = "0.7.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -41,6 +41,10 @@ packages = ["src/specify_cli"]
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
# 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 = [
|
||||
|
||||
@@ -114,8 +114,19 @@ 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 branch="$1"
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
@@ -124,6 +135,9 @@ 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
|
||||
@@ -131,7 +145,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: $branch" >&2
|
||||
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
|
||||
@@ -139,13 +153,12 @@ 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="$2"
|
||||
local branch_name
|
||||
branch_name=$(spec_kit_effective_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)
|
||||
|
||||
@@ -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, 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, Forge, Goose, 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|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|forge|goose|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, and Forge all share AGENTS.md — use AGENTS_FILE to avoid
|
||||
# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose 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"
|
||||
@@ -710,12 +710,15 @@ update_specific_agent() {
|
||||
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|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|forge|goose|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -759,7 +762,7 @@ 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" || _all_ok=false
|
||||
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _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
|
||||
@@ -800,7 +803,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|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|forge|goose|generic]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -127,6 +127,16 @@ 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,
|
||||
@@ -138,22 +148,69 @@ 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}-') {
|
||||
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"
|
||||
[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
|
||||
}
|
||||
|
||||
function Get-FeatureDir {
|
||||
param([string]$RepoRoot, [string]$Branch)
|
||||
Join-Path $RepoRoot "specs/$Branch"
|
||||
# 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-FeaturePathsEnv {
|
||||
@@ -164,7 +221,7 @@ function Get-FeaturePathsEnv {
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
||||
# 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback)
|
||||
# 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
|
||||
@@ -173,22 +230,24 @@ function Get-FeaturePathsEnv {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
$featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json
|
||||
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-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
}
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
[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-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
|
||||
@@ -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, 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, forge, goose, 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','generic')]
|
||||
[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')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
@@ -68,6 +68,7 @@ $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
|
||||
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.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,8 +418,9 @@ function Update-SpecificAgent {
|
||||
'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|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|forge|goose|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +462,7 @@ function Update-AllExistingAgents {
|
||||
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')) { $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 }
|
||||
@@ -490,7 +492,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|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|forge|goose|generic]'
|
||||
}
|
||||
|
||||
function Main {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ 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":
|
||||
@@ -75,7 +76,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 {}
|
||||
@@ -100,7 +101,9 @@ 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:
|
||||
@@ -146,16 +149,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.
|
||||
|
||||
@@ -172,12 +175,7 @@ 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:
|
||||
@@ -192,7 +190,7 @@ class CommandRegistrar:
|
||||
|
||||
if "description" in frontmatter:
|
||||
toml_lines.append(
|
||||
f'description = {self._render_basic_toml_string(frontmatter["description"])}'
|
||||
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
|
||||
)
|
||||
toml_lines.append("")
|
||||
|
||||
@@ -226,6 +224,41 @@ class CommandRegistrar:
|
||||
)
|
||||
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,
|
||||
@@ -252,9 +285,13 @@ 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,
|
||||
@@ -280,15 +317,12 @@ class CommandRegistrar:
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
if agent_name == "claude":
|
||||
# Claude skills should be user-invocable (accessible via /command)
|
||||
# and only run when explicitly invoked (not auto-triggered by the model).
|
||||
skill_frontmatter["user-invocable"] = True
|
||||
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
|
||||
@@ -312,7 +346,9 @@ 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:
|
||||
@@ -334,7 +370,9 @@ 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)
|
||||
@@ -342,7 +380,9 @@ 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:
|
||||
@@ -356,14 +396,16 @@ 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}"
|
||||
@@ -375,7 +417,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.
|
||||
|
||||
@@ -432,12 +474,24 @@ 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']}")
|
||||
|
||||
@@ -451,34 +505,68 @@ 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)
|
||||
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
|
||||
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
|
||||
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)
|
||||
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)
|
||||
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']}")
|
||||
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
|
||||
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 = (
|
||||
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_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
alias_file.write_text(alias_output, encoding="utf-8")
|
||||
if agent_name == "copilot":
|
||||
@@ -506,7 +594,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.
|
||||
|
||||
@@ -529,8 +617,12 @@ 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
|
||||
@@ -540,9 +632,7 @@ 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.
|
||||
|
||||
@@ -559,13 +649,26 @@ 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()
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ _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.
|
||||
@@ -130,6 +132,7 @@ class ExtensionManifest:
|
||||
ValidationError: If manifest is invalid
|
||||
"""
|
||||
self.path = manifest_path
|
||||
self.warnings: List[str] = []
|
||||
self.data = self._load_yaml(manifest_path)
|
||||
self._validate()
|
||||
|
||||
@@ -215,17 +218,98 @@ class ExtensionManifest:
|
||||
f"Hook '{hook_name}' missing required 'command' field"
|
||||
)
|
||||
|
||||
# Validate commands (if present)
|
||||
# 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"
|
||||
)
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
|
||||
# Validate command name format
|
||||
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
|
||||
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):
|
||||
raise ValidationError(
|
||||
f"Invalid command name '{cmd['name']}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
f"Aliases for command '{cmd['name']}' must be a list"
|
||||
)
|
||||
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:
|
||||
@@ -766,6 +850,7 @@ class ExtensionManager:
|
||||
|
||||
from . import load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
import yaml
|
||||
|
||||
written: List[str] = []
|
||||
@@ -776,6 +861,7 @@ 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"]
|
||||
@@ -855,6 +941,10 @@ 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)
|
||||
@@ -1870,6 +1960,14 @@ 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")
|
||||
@@ -2170,6 +2268,7 @@ 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:
|
||||
@@ -2178,6 +2277,8 @@ 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}"
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ def get_integration(key: str) -> IntegrationBase | None:
|
||||
|
||||
# -- Register built-in integrations --------------------------------------
|
||||
|
||||
|
||||
def _register_builtins() -> None:
|
||||
"""Register all built-in integrations.
|
||||
|
||||
@@ -51,13 +52,14 @@ def _register_builtins() -> None:
|
||||
from .auggie import AuggieIntegration
|
||||
from .bob import BobIntegration
|
||||
from .claude import ClaudeIntegration
|
||||
from .codex import CodexIntegration
|
||||
from .codebuddy import CodebuddyIntegration
|
||||
from .codex import CodexIntegration
|
||||
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
|
||||
@@ -80,13 +82,14 @@ def _register_builtins() -> None:
|
||||
_register(AuggieIntegration())
|
||||
_register(BobIntegration())
|
||||
_register(ClaudeIntegration())
|
||||
_register(CodexIntegration())
|
||||
_register(CodebuddyIntegration())
|
||||
_register(CodexIntegration())
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(ForgeIntegration())
|
||||
_register(GeminiIntegration())
|
||||
_register(GenericIntegration())
|
||||
_register(GooseIntegration())
|
||||
_register(IflowIntegration())
|
||||
_register(JunieIntegration())
|
||||
_register(KilocodeIntegration())
|
||||
|
||||
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
||||
# IntegrationOption
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationOption:
|
||||
"""Declares an option that an integration accepts via ``--integration-options``.
|
||||
@@ -51,6 +52,7 @@ class IntegrationOption:
|
||||
# IntegrationBase — abstract base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class IntegrationBase(ABC):
|
||||
"""Abstract base class every integration must implement.
|
||||
|
||||
@@ -89,6 +91,123 @@ 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:
|
||||
@@ -275,7 +394,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}`` with *arg_placeholder*
|
||||
5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
|
||||
6. Replace ``__AGENT__`` with *agent_name*
|
||||
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
||||
"""
|
||||
@@ -348,8 +467,9 @@ class IntegrationBase(ABC):
|
||||
output_lines.append(line)
|
||||
content = "".join(output_lines)
|
||||
|
||||
# 5. Replace {ARGS}
|
||||
# 5. Replace {ARGS} and $ARGUMENTS
|
||||
content = content.replace("{ARGS}", arg_placeholder)
|
||||
content = content.replace("$ARGUMENTS", arg_placeholder)
|
||||
|
||||
# 6. Replace __AGENT__
|
||||
content = content.replace("__AGENT__", agent_name)
|
||||
@@ -358,6 +478,7 @@ 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
|
||||
@@ -433,9 +554,7 @@ 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,
|
||||
@@ -452,6 +571,7 @@ class IntegrationBase(ABC):
|
||||
# MarkdownIntegration — covers ~20 standard agents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MarkdownIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use standard Markdown commands.
|
||||
|
||||
@@ -463,6 +583,22 @@ 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,
|
||||
@@ -492,12 +628,18 @@ 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
|
||||
@@ -512,6 +654,7 @@ class MarkdownIntegration(IntegrationBase):
|
||||
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TomlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use TOML command format.
|
||||
|
||||
@@ -524,6 +667,22 @@ 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"
|
||||
@@ -603,13 +762,17 @@ class TomlIntegration(IntegrationBase):
|
||||
if "'''" not in value and not value.endswith("'"):
|
||||
return "'''\n" + value + "'''"
|
||||
|
||||
return '"' + (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
) + '"'
|
||||
return (
|
||||
'"'
|
||||
+ (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
+ '"'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_toml(description: str, body: str) -> str:
|
||||
@@ -628,7 +791,9 @@ class TomlIntegration(IntegrationBase):
|
||||
toml_lines: list[str] = []
|
||||
|
||||
if description:
|
||||
toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}")
|
||||
toml_lines.append(
|
||||
f"description = {TomlIntegration._render_toml_string(description)}"
|
||||
)
|
||||
toml_lines.append("")
|
||||
|
||||
body = body.rstrip("\n")
|
||||
@@ -665,13 +830,19 @@ 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)
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder
|
||||
)
|
||||
_, body = self._split_frontmatter(processed)
|
||||
toml_content = self._render_toml(description, body)
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
@@ -684,6 +855,188 @@ 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -704,6 +1057,22 @@ 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.
|
||||
|
||||
@@ -713,9 +1082,7 @@ 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(
|
||||
@@ -724,6 +1091,27 @@ 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,
|
||||
|
||||
626
src/specify_cli/integrations/catalog.py
Normal file
626
src/specify_cli/integrations/catalog.py
Normal file
@@ -0,0 +1,626 @@
|
||||
"""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()}"
|
||||
@@ -5,11 +5,21 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
# Note injected into hook sections so Claude maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated skill names it uses.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
# Mapping of command template stem → argument-hint text shown inline
|
||||
# when a user invokes the slash command in Claude Code.
|
||||
ARGUMENT_HINTS: dict[str, str] = {
|
||||
@@ -148,6 +158,43 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
eol = m.group(3)
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Claude-specific frontmatter flags and hook notes."""
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||
updated = self._inject_hook_command_note(updated)
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -155,7 +202,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
|
||||
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Post-process generated skill files
|
||||
@@ -173,11 +220,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
# Inject user-invocable: true (Claude skills are accessible via /command)
|
||||
updated = self._inject_frontmatter_flag(content, "user-invocable")
|
||||
|
||||
# Inject disable-model-invocation: true (Claude skills run only when invoked)
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
|
||||
updated = self.post_process_skill_content(content)
|
||||
|
||||
# Inject argument-hint if available for this skill
|
||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||
|
||||
@@ -28,6 +28,21 @@ 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 [
|
||||
|
||||
@@ -19,14 +19,19 @@ from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
class CopilotIntegration(IntegrationBase):
|
||||
"""Integration for GitHub Copilot in VS Code."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
config = {
|
||||
"name": "GitHub Copilot",
|
||||
"folder": ".github/",
|
||||
"commands_subdir": "agents",
|
||||
"install_url": None,
|
||||
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
@@ -37,6 +42,101 @@ 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"
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
"""Cursor IDE integration."""
|
||||
"""Cursor IDE integration.
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
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
|
||||
|
||||
|
||||
class CursorAgentIntegration(MarkdownIntegration):
|
||||
class CursorAgentIntegration(SkillsIntegration):
|
||||
key = "cursor-agent"
|
||||
config = {
|
||||
"name": "Cursor",
|
||||
"folder": ".cursor/",
|
||||
"commands_subdir": "commands",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".cursor/commands",
|
||||
"dir": ".cursor/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"extension": "/SKILL.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)",
|
||||
),
|
||||
]
|
||||
|
||||
21
src/specify_cli/integrations/goose/__init__.py
Normal file
21
src/specify_cli/integrations/goose/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
38
src/specify_cli/integrations/goose/scripts/update-context.sh
Executable file
38
src/specify_cli/integrations/goose/scripts/update-context.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/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
|
||||
@@ -707,6 +707,7 @@ 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):
|
||||
@@ -716,6 +717,7 @@ 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
|
||||
# preset skills in _register_commands() because their detected agent
|
||||
@@ -789,6 +791,10 @@ 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")
|
||||
@@ -816,6 +822,7 @@ 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"
|
||||
@@ -824,6 +831,7 @@ 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:
|
||||
@@ -877,6 +885,10 @@ 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
|
||||
|
||||
@@ -906,6 +918,10 @@ 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
|
||||
@@ -1587,6 +1603,16 @@ 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(
|
||||
|
||||
68
src/specify_cli/workflows/__init__.py
Normal file
68
src/specify_cli/workflows/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""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()
|
||||
132
src/specify_cli/workflows/base.py
Normal file
132
src/specify_cli/workflows/base.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""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
|
||||
540
src/specify_cli/workflows/catalog.py
Normal file
540
src/specify_cli/workflows/catalog.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""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}"
|
||||
778
src/specify_cli/workflows/engine.py
Normal file
778
src/specify_cli/workflows/engine.py
Normal file
@@ -0,0 +1,778 @@
|
||||
"""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)."""
|
||||
300
src/specify_cli/workflows/expressions.py
Normal file
300
src/specify_cli/workflows/expressions.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""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)
|
||||
1
src/specify_cli/workflows/steps/__init__.py
Normal file
1
src/specify_cli/workflows/steps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Auto-discovery for built-in step types."""
|
||||
155
src/specify_cli/workflows/steps/command/__init__.py
Normal file
155
src/specify_cli/workflows/steps/command/__init__.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""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
|
||||
61
src/specify_cli/workflows/steps/do_while/__init__.py
Normal file
61
src/specify_cli/workflows/steps/do_while/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""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
|
||||
61
src/specify_cli/workflows/steps/fan_in/__init__.py
Normal file
61
src/specify_cli/workflows/steps/fan_in/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""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
|
||||
58
src/specify_cli/workflows/steps/fan_out/__init__.py
Normal file
58
src/specify_cli/workflows/steps/fan_out/__init__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""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
|
||||
121
src/specify_cli/workflows/steps/gate/__init__.py
Normal file
121
src/specify_cli/workflows/steps/gate/__init__.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""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
|
||||
55
src/specify_cli/workflows/steps/if_then/__init__.py
Normal file
55
src/specify_cli/workflows/steps/if_then/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""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
|
||||
156
src/specify_cli/workflows/steps/prompt/__init__.py
Normal file
156
src/specify_cli/workflows/steps/prompt/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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
|
||||
75
src/specify_cli/workflows/steps/shell/__init__.py
Normal file
75
src/specify_cli/workflows/steps/shell/__init__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""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
|
||||
70
src/specify_cli/workflows/steps/switch/__init__.py
Normal file
70
src/specify_cli/workflows/steps/switch/__init__.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""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
|
||||
68
src/specify_cli/workflows/steps/while_loop/__init__.py
Normal file
68
src/specify_cli/workflows/steps/while_loop/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""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
|
||||
@@ -1,10 +1,68 @@
|
||||
"""Shared test helpers for the Spec Kit test suite."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _has_working_bash() -> bool:
|
||||
"""Check whether a functional native bash is available.
|
||||
|
||||
On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
|
||||
which searches System32 *before* PATH — so it may find the WSL
|
||||
launcher even when Git-for-Windows bash appears first in PATH via
|
||||
``shutil.which``. We therefore probe with bare ``"bash"`` (the
|
||||
same way test helpers invoke it) to get an accurate result.
|
||||
|
||||
On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
|
||||
The WSL launcher is rejected because it runs in a separate Linux
|
||||
filesystem and cannot handle native Windows paths used by the
|
||||
test fixtures.
|
||||
|
||||
Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
|
||||
"""
|
||||
if os.environ.get("SPECKIT_TEST_BASH") == "1":
|
||||
return True
|
||||
if shutil.which("bash") is None:
|
||||
return False
|
||||
# Probe with bare "bash" — same as the test helpers — so that
|
||||
# Windows CreateProcess resolution order is respected.
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["bash", "-c", "echo ok"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if r.returncode != 0 or "ok" not in r.stdout:
|
||||
return False
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
|
||||
# not the WSL launcher which can't handle native paths.
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
u = subprocess.run(
|
||||
["bash", "-c", "uname -s"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
kernel = u.stdout.strip().upper()
|
||||
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
|
||||
return False
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
requires_bash = pytest.mark.skipif(
|
||||
not _has_working_bash(), reason="working bash not available"
|
||||
)
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
@@ -18,6 +18,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "git"
|
||||
EXT_BASH = EXT_DIR / "scripts" / "bash"
|
||||
@@ -211,6 +213,7 @@ class TestGitExtensionInstall:
|
||||
# ── initialize-repo.sh Tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestInitializeRepoBash:
|
||||
def test_initializes_git_repo(self, tmp_path: Path):
|
||||
"""initialize-repo.sh creates a git repo with initial commit."""
|
||||
@@ -269,6 +272,7 @@ class TestInitializeRepoPowerShell:
|
||||
# ── create-new-feature.sh Tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCreateFeatureBash:
|
||||
def test_creates_branch_sequential(self, tmp_path: Path):
|
||||
"""Extension create-new-feature.sh creates sequential branch."""
|
||||
@@ -376,6 +380,7 @@ class TestCreateFeaturePowerShell:
|
||||
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestAutoCommitBash:
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
"""auto-commit.sh exits silently when config is all false."""
|
||||
@@ -491,6 +496,34 @@ class TestAutoCommitBash:
|
||||
result = _run_bash("auto-commit.sh", project)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_success_message_uses_ok_prefix(self, tmp_path: Path):
|
||||
"""auto-commit.sh success message uses [OK] (not Unicode)."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "[OK] Changes committed" in result.stderr
|
||||
|
||||
def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
|
||||
"""auto-commit.sh must not use Unicode checkmark in output."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_plan:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_plan")
|
||||
assert result.returncode == 0
|
||||
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShell:
|
||||
@@ -523,10 +556,39 @@ class TestAutoCommitPowerShell:
|
||||
)
|
||||
assert "ps commit" in log.stdout
|
||||
|
||||
def test_success_message_uses_ok_prefix(self, tmp_path: Path):
|
||||
"""auto-commit.ps1 success message uses [OK] (not Unicode)."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "[OK] Changes committed" in result.stdout
|
||||
|
||||
def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
|
||||
"""auto-commit.ps1 must not use Unicode checkmark in output."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_plan:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_plan")
|
||||
assert result.returncode == 0
|
||||
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
# ── git-common.sh Tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestGitCommonBash:
|
||||
def test_has_git_true(self, tmp_path: Path):
|
||||
"""has_git returns 0 in a git repo."""
|
||||
@@ -587,3 +649,40 @@ class TestGitCommonBash:
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path):
|
||||
"""git-common check_feature_branch matches core: one optional path prefix."""
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/001-my-feature" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestGitCommonPowerShell:
|
||||
def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path):
|
||||
project = _setup_project(tmp_path)
|
||||
script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pwsh",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}',
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
@@ -5,6 +5,14 @@ import os
|
||||
|
||||
import yaml
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
def _normalize_cli_output(output: str) -> str:
|
||||
output = strip_ansi(output)
|
||||
output = " ".join(output.split())
|
||||
return output.strip()
|
||||
|
||||
|
||||
class TestInitIntegrationFlag:
|
||||
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
|
||||
@@ -77,6 +85,59 @@ class TestInitIntegrationFlag:
|
||||
assert result.exit_code == 0
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "warn-ai"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Deprecation Warning" in normalized_output
|
||||
assert "--ai" in normalized_output
|
||||
assert "deprecated" in normalized_output
|
||||
assert "no longer be available" in normalized_output
|
||||
assert "1.0.0" in normalized_output
|
||||
assert "--integration copilot" in normalized_output
|
||||
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "warn-generic"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Deprecation Warning" in normalized_output
|
||||
assert "--integration generic" in normalized_output
|
||||
assert "--integration-options" in normalized_output
|
||||
assert ".myagent/commands" in normalized_output
|
||||
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
||||
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
|
||||
|
||||
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -245,6 +245,9 @@ class MarkdownIntegrationTests:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
|
||||
@@ -347,6 +347,11 @@ class SkillsIntegrationTests:
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
]
|
||||
# Bundled workflow
|
||||
files += [
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
]
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
|
||||
@@ -84,7 +84,9 @@ class TomlIntegrationTests:
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.commands_dest(tmp_path)
|
||||
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
|
||||
assert expected_dir.exists(), (
|
||||
f"Expected directory {expected_dir} was not created"
|
||||
)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0, "No command files were created"
|
||||
for f in cmd_files:
|
||||
@@ -134,6 +136,12 @@ class TomlIntegrationTests:
|
||||
# At least one file should contain {{args}} from the {ARGS} placeholder
|
||||
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
|
||||
assert has_args, "No TOML command file contains {{args}} placeholder"
|
||||
has_dollar_args = any(
|
||||
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
|
||||
)
|
||||
assert not has_dollar_args, (
|
||||
"TOML command still contains $ARGUMENTS instead of {{args}}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frontmatter", "expected"),
|
||||
@@ -156,19 +164,13 @@ class TomlIntegrationTests:
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected):
|
||||
def test_toml_extract_description_supports_block_scalars(
|
||||
self, frontmatter, expected
|
||||
):
|
||||
assert TomlIntegration._extract_description(frontmatter) == expected
|
||||
|
||||
def test_split_frontmatter_ignores_indented_delimiters(self):
|
||||
content = (
|
||||
"---\n"
|
||||
"description: |\n"
|
||||
" line one\n"
|
||||
" ---\n"
|
||||
" line two\n"
|
||||
"---\n"
|
||||
"Body\n"
|
||||
)
|
||||
content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
|
||||
|
||||
frontmatter, body = TomlIntegration._split_frontmatter(content)
|
||||
|
||||
@@ -205,7 +207,7 @@ class TomlIntegrationTests:
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
|
||||
"""Multiline body ending with `"` must not produce `""""` (#2113)."""
|
||||
"""Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113)."""
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
@@ -230,7 +232,9 @@ class TomlIntegrationTests:
|
||||
assert '"""\n' in raw, "body must use multiline basic string"
|
||||
parsed = tomllib.loads(raw)
|
||||
assert parsed["prompt"].endswith('specified?"')
|
||||
assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline"
|
||||
assert not parsed["prompt"].endswith("\n"), (
|
||||
"parsed value must not gain a trailing newline"
|
||||
)
|
||||
|
||||
def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch):
|
||||
"""Body containing `\"\"\"` and ending with `'` falls back to escaped basic string."""
|
||||
@@ -254,11 +258,15 @@ class TomlIntegrationTests:
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
assert "''''" not in raw, "literal string must not produce ambiguous closing quotes"
|
||||
assert "''''" not in raw, (
|
||||
"literal string must not produce ambiguous closing quotes"
|
||||
)
|
||||
parsed = tomllib.loads(raw)
|
||||
assert parsed["prompt"].endswith("'single'")
|
||||
assert '"""triple"""' in parsed["prompt"]
|
||||
assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline"
|
||||
assert not parsed["prompt"].endswith("\n"), (
|
||||
"parsed value must not gain a trailing newline"
|
||||
)
|
||||
|
||||
def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch):
|
||||
"""Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline)."""
|
||||
@@ -284,8 +292,9 @@ class TomlIntegrationTests:
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
parsed = tomllib.loads(raw)
|
||||
assert parsed["prompt"] == "Line one\nPlain body content"
|
||||
assert raw.rstrip().endswith('content"""'), \
|
||||
assert raw.rstrip().endswith('content"""'), (
|
||||
"closing delimiter should be inline when body does not end with a quote"
|
||||
)
|
||||
|
||||
def test_toml_is_valid(self, tmp_path):
|
||||
"""Every generated TOML file must parse without errors."""
|
||||
@@ -354,7 +363,14 @@ class TomlIntegrationTests:
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
|
||||
sh = (
|
||||
tmp_path
|
||||
/ ".specify"
|
||||
/ "integrations"
|
||||
/ self.KEY
|
||||
/ "scripts"
|
||||
/ "update-context.sh"
|
||||
)
|
||||
assert os.access(sh, os.X_OK)
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
@@ -369,10 +385,20 @@ class TomlIntegrationTests:
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--ai",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
@@ -390,13 +416,25 @@ class TomlIntegrationTests:
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
assert result.exit_code == 0, (
|
||||
f"init --integration {self.KEY} failed: {result.output}"
|
||||
)
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
|
||||
@@ -406,8 +444,15 @@ class TomlIntegrationTests:
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
@@ -425,26 +470,44 @@ class TomlIntegrationTests:
|
||||
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
|
||||
|
||||
# Framework files
|
||||
files.append(f".specify/integration.json")
|
||||
files.append(f".specify/init-options.json")
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(f".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
"setup-plan.sh", "update-agent-context.sh"]:
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"update-agent-context.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
|
||||
"setup-plan.ps1", "update-agent-context.ps1"]:
|
||||
for name in [
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"update-agent-context.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in ["agent-file-template.md", "checklist-template.md",
|
||||
"constitution-template.md", "plan-template.md",
|
||||
"spec-template.md", "tasks-template.md"]:
|
||||
for name in [
|
||||
"agent-file-template.md",
|
||||
"checklist-template.md",
|
||||
"constitution-template.md",
|
||||
"plan-template.md",
|
||||
"spec-template.md",
|
||||
"tasks-template.md",
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
@@ -457,15 +520,26 @@ class TomlIntegrationTests:
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
@@ -482,15 +556,26 @@ class TomlIntegrationTests:
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file())
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
|
||||
462
tests/integrations/test_integration_base_yaml.py
Normal file
462
tests/integrations/test_integration_base_yaml.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""Reusable test mixin for standard YamlIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
|
||||
logic from ``YamlIntegrationTests``.
|
||||
|
||||
Mirrors ``TomlIntegrationTests`` closely — same test structure,
|
||||
adapted for YAML recipe output format.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import YamlIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class YamlIntegrationTests:
|
||||
"""Mixin — set class-level constants and inherit these tests.
|
||||
|
||||
Required class attrs on subclass::
|
||||
|
||||
KEY: str — integration registry key
|
||||
FOLDER: str — e.g. ".goose/"
|
||||
COMMANDS_SUBDIR: str — e.g. "recipes"
|
||||
REGISTRAR_DIR: str — e.g. ".goose/recipes"
|
||||
CONTEXT_FILE: str — e.g. "AGENTS.md"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
CONTEXT_FILE: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
assert self.KEY in INTEGRATION_REGISTRY
|
||||
assert get_integration(self.KEY) is not None
|
||||
|
||||
def test_is_yaml_integration(self):
|
||||
assert isinstance(get_integration(self.KEY), YamlIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["folder"] == self.FOLDER
|
||||
|
||||
def test_config_commands_subdir(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
||||
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "yaml"
|
||||
assert i.registrar_config["args"] == "{{args}}"
|
||||
assert i.registrar_config["extension"] == ".yaml"
|
||||
|
||||
def test_context_file(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.context_file == self.CONTEXT_FILE
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".yaml")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.commands_dest(tmp_path)
|
||||
assert expected_dir.exists(), (
|
||||
f"Expected directory {expected_dir} was not created"
|
||||
)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0, "No command files were created"
|
||||
for f in cmd_files:
|
||||
assert f.resolve().parent == expected_dir.resolve(), (
|
||||
f"{f} is not under {expected_dir}"
|
||||
)
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
"""Command files must have placeholders replaced."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0
|
||||
for f in cmd_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||||
|
||||
def test_yaml_has_title(self, tmp_path):
|
||||
"""Every YAML recipe should have a title field."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "title:" in content, f"{f.name} missing title field"
|
||||
|
||||
def test_yaml_has_prompt(self, tmp_path):
|
||||
"""Every YAML recipe should have a prompt block scalar."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "prompt: |" in content, f"{f.name} missing prompt block scalar"
|
||||
|
||||
def test_yaml_uses_correct_arg_placeholder(self, tmp_path):
|
||||
"""YAML recipes must use {{args}} placeholder."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
|
||||
assert has_args, "No YAML recipe contains {{args}} placeholder"
|
||||
has_dollar_args = any(
|
||||
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
|
||||
)
|
||||
assert not has_dollar_args, (
|
||||
"YAML recipe still contains $ARGUMENTS instead of {{args}}"
|
||||
)
|
||||
|
||||
def test_yaml_is_valid(self, tmp_path):
|
||||
"""Every generated YAML file must parse without errors."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Strip trailing source comment before parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
|
||||
try:
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
except Exception as exc:
|
||||
raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc
|
||||
assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key"
|
||||
assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key"
|
||||
|
||||
def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Summary line one\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/example.sh\n"
|
||||
"---\n"
|
||||
"Body line one\n"
|
||||
"Body line two\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
content = cmd_files[0].read_text(encoding="utf-8")
|
||||
# Strip source comment for parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
|
||||
assert "description:" not in parsed["prompt"]
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"{rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Scripts ----------------------------------------------------------
|
||||
|
||||
def test_setup_installs_update_context_scripts(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
|
||||
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
|
||||
assert (scripts_dir / "update-context.sh").exists()
|
||||
assert (scripts_dir / "update-context.ps1").exists()
|
||||
|
||||
def test_scripts_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
script_rels = [k for k in m.files if "update-context" in k]
|
||||
assert len(script_rels) >= 2
|
||||
|
||||
def test_sh_script_is_executable(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
sh = (
|
||||
tmp_path
|
||||
/ ".specify"
|
||||
/ "integrations"
|
||||
/ self.KEY
|
||||
/ "scripts"
|
||||
/ "update-context.sh"
|
||||
)
|
||||
assert os.access(sh, os.X_OK)
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
|
||||
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"promote-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--ai",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, (
|
||||
f"init --integration {self.KEY} failed: {result.output}"
|
||||
)
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
|
||||
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Build the expected file list for this integration + script variant."""
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.registrar_config["dir"]
|
||||
files = []
|
||||
|
||||
# Command files (.yaml)
|
||||
for stem in self.COMMAND_STEMS:
|
||||
files.append(f"{cmd_dir}/speckit.{stem}.yaml")
|
||||
|
||||
# Integration scripts
|
||||
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
|
||||
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"update-agent-context.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in [
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"update-agent-context.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in [
|
||||
"agent-file-template.md",
|
||||
"checklist-template.md",
|
||||
"constitution-template.md",
|
||||
"plan-template.md",
|
||||
"spec-template.md",
|
||||
"tasks-template.md",
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(
|
||||
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
656
tests/integrations/test_integration_catalog.py
Normal file
656
tests/integrations/test_integration_catalog.py
Normal file
@@ -0,0 +1,656 @@
|
||||
"""Tests for the integration catalog system (catalog.py)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogEntry,
|
||||
IntegrationCatalogError,
|
||||
IntegrationDescriptor,
|
||||
IntegrationDescriptorError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalogEntry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIntegrationCatalogEntry:
|
||||
def test_create_entry(self):
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
description="Test catalog",
|
||||
)
|
||||
assert entry.url == "https://example.com/catalog.json"
|
||||
assert entry.name == "test"
|
||||
assert entry.priority == 1
|
||||
assert entry.install_allowed is True
|
||||
assert entry.description == "Test catalog"
|
||||
|
||||
def test_default_description(self):
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=False,
|
||||
)
|
||||
assert entry.description == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog — URL validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCatalogURLValidation:
|
||||
def test_https_allowed(self):
|
||||
IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json")
|
||||
|
||||
def test_http_rejected(self):
|
||||
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
|
||||
IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json")
|
||||
|
||||
def test_http_localhost_allowed(self):
|
||||
IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json")
|
||||
IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json")
|
||||
|
||||
def test_missing_host_rejected(self):
|
||||
with pytest.raises(IntegrationCatalogError, match="valid URL"):
|
||||
IntegrationCatalog._validate_catalog_url("https:///no-host")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog — active catalogs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestActiveCatalogs:
|
||||
def test_defaults_when_no_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
active = cat.get_active_catalogs()
|
||||
assert len(active) == 2
|
||||
assert active[0].name == "default"
|
||||
assert active[1].name == "community"
|
||||
|
||||
def test_env_var_override(self, tmp_path, monkeypatch):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"https://custom.example.com/catalog.json",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
active = cat.get_active_catalogs()
|
||||
assert len(active) == 1
|
||||
assert active[0].name == "custom"
|
||||
|
||||
def test_project_config_overrides_defaults(self, tmp_path):
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({
|
||||
"catalogs": [
|
||||
{"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True},
|
||||
]
|
||||
}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
active = cat.get_active_catalogs()
|
||||
assert len(active) == 1
|
||||
assert active[0].name == "mine"
|
||||
|
||||
def test_empty_config_raises(self, tmp_path):
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": []}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
|
||||
cat.get_active_catalogs()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
def _patch_urlopen(self, monkeypatch, catalog_data):
|
||||
"""Patch urllib.request.urlopen to return *catalog_data*."""
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(url, timeout=10):
|
||||
return FakeResponse(catalog_data, url)
|
||||
|
||||
import urllib.request
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
catalog = {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"integrations": {
|
||||
"acme-coder": {
|
||||
"id": "acme-coder",
|
||||
"name": "Acme Coder",
|
||||
"version": "2.0.0",
|
||||
"description": "Community integration for Acme Coder",
|
||||
"author": "acme-org",
|
||||
"tags": ["cli"],
|
||||
},
|
||||
},
|
||||
}
|
||||
self._patch_urlopen(monkeypatch, catalog)
|
||||
|
||||
results = cat.search()
|
||||
assert len(results) >= 1
|
||||
ids = [r["id"] for r in results]
|
||||
assert "acme-coder" in ids
|
||||
|
||||
def test_search_by_tag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
catalog = {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"integrations": {
|
||||
"a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]},
|
||||
"b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]},
|
||||
},
|
||||
}
|
||||
self._patch_urlopen(monkeypatch, catalog)
|
||||
|
||||
results = cat.search(tag="cli")
|
||||
assert all("cli" in r.get("tags", []) for r in results)
|
||||
|
||||
def test_search_by_query(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
catalog = {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"integrations": {
|
||||
"claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []},
|
||||
"gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []},
|
||||
},
|
||||
}
|
||||
self._patch_urlopen(monkeypatch, catalog)
|
||||
|
||||
results = cat.search(query="claude")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "claude"
|
||||
|
||||
def test_get_integration_info(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
catalog = {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"integrations": {
|
||||
"claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
self._patch_urlopen(monkeypatch, catalog)
|
||||
|
||||
info = cat.get_integration_info("claude")
|
||||
assert info is not None
|
||||
assert info["name"] == "Claude Code"
|
||||
|
||||
assert cat.get_integration_info("nonexistent") is None
|
||||
|
||||
def test_invalid_catalog_format(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations"
|
||||
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_clear_cache(self, tmp_path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cat.cache_dir / "catalog-abc123.json").write_text("{}")
|
||||
cat.clear_cache()
|
||||
assert not list(cat.cache_dir.glob("catalog-*.json"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationDescriptor (integration.yml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_DESCRIPTOR = {
|
||||
"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",
|
||||
},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{"name": "speckit.specify", "file": "templates/speckit.specify.md"},
|
||||
],
|
||||
"scripts": ["update-context.sh"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestIntegrationDescriptor:
|
||||
def _write(self, tmp_path, data):
|
||||
p = tmp_path / "integration.yml"
|
||||
p.write_text(yaml.dump(data))
|
||||
return p
|
||||
|
||||
def test_valid_descriptor(self, tmp_path):
|
||||
p = self._write(tmp_path, VALID_DESCRIPTOR)
|
||||
desc = IntegrationDescriptor(p)
|
||||
assert desc.id == "my-agent"
|
||||
assert desc.name == "My Agent"
|
||||
assert desc.version == "1.0.0"
|
||||
assert desc.description == "Integration for My Agent"
|
||||
assert desc.requires_speckit_version == ">=0.6.0"
|
||||
assert len(desc.commands) == 1
|
||||
assert desc.scripts == ["update-context.sh"]
|
||||
|
||||
def test_missing_schema_version(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR}
|
||||
del data["schema_version"]
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_unsupported_schema_version(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "schema_version": "99.0"}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_missing_integration_id(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_invalid_id_format(self, tmp_path):
|
||||
integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"}
|
||||
data = {**VALID_DESCRIPTOR, "integration": integ}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_invalid_version(self, tmp_path):
|
||||
integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"}
|
||||
data = {**VALID_DESCRIPTOR, "integration": integ}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="Invalid version"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_missing_speckit_version(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "requires": {}}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_no_commands_or_scripts(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "provides": {}}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="at least one command or script"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_command_missing_name(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_commands_not_a_list(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_scripts_not_a_list(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}}
|
||||
p = self._write(tmp_path, data)
|
||||
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_file_not_found(self, tmp_path):
|
||||
with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"):
|
||||
IntegrationDescriptor(tmp_path / "nonexistent.yml")
|
||||
|
||||
def test_invalid_yaml(self, tmp_path):
|
||||
p = tmp_path / "integration.yml"
|
||||
p.write_text(": : :")
|
||||
with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"):
|
||||
IntegrationDescriptor(p)
|
||||
|
||||
def test_get_hash(self, tmp_path):
|
||||
p = self._write(tmp_path, VALID_DESCRIPTOR)
|
||||
desc = IntegrationDescriptor(p)
|
||||
h = desc.get_hash()
|
||||
assert h.startswith("sha256:")
|
||||
|
||||
def test_tools_accessor(self, tmp_path):
|
||||
data = {**VALID_DESCRIPTOR, "requires": {
|
||||
"speckit_version": ">=0.6.0",
|
||||
"tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}],
|
||||
}}
|
||||
p = self._write(tmp_path, data)
|
||||
desc = IntegrationDescriptor(p)
|
||||
assert len(desc.tools) == 1
|
||||
assert desc.tools[0]["name"] == "my-agent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: integration list --catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIntegrationListCatalog:
|
||||
"""Test ``specify integration list --catalog``."""
|
||||
|
||||
def _init_project(self, tmp_path):
|
||||
"""Create a minimal spec-kit project."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0, result.output
|
||||
return project
|
||||
|
||||
def test_list_catalog_flag(self, tmp_path, monkeypatch):
|
||||
"""--catalog should show catalog entries."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path)
|
||||
|
||||
catalog = {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"integrations": {
|
||||
"test-agent": {
|
||||
"id": "test-agent",
|
||||
"name": "Test Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A test agent",
|
||||
"tags": ["cli"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
import urllib.request
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url
|
||||
def read(self):
|
||||
return self._data
|
||||
def geturl(self):
|
||||
return self._url
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list", "--catalog"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "test-agent" in result.output
|
||||
assert "Test Agent" in result.output
|
||||
|
||||
def test_list_without_catalog_still_works(self, tmp_path):
|
||||
"""Default list (no --catalog) works as before."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path)
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "list"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "copilot" in result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: integration upgrade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIntegrationUpgrade:
|
||||
"""Test ``specify integration upgrade``."""
|
||||
|
||||
def _init_project(self, tmp_path, integration="copilot"):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here",
|
||||
"--integration", integration,
|
||||
"--script", "sh",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0, result.output
|
||||
return project
|
||||
|
||||
def test_upgrade_requires_speckit_project(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, ["integration", "upgrade"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_upgrade_no_integration_installed(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0
|
||||
assert "No integration is currently installed" in result.output
|
||||
|
||||
def test_upgrade_succeeds(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path, "copilot")
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0
|
||||
assert "upgraded successfully" in result.output
|
||||
|
||||
def test_upgrade_blocks_on_modified_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path, "copilot")
|
||||
|
||||
# Modify a tracked file so the manifest hash won't match
|
||||
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
assert manifest_path.exists(), "Manifest should exist after init"
|
||||
manifest_data = json.loads(manifest_path.read_text())
|
||||
tracked_files = manifest_data.get("files", {})
|
||||
assert tracked_files, "Manifest should track at least one file"
|
||||
first_rel = next(iter(tracked_files))
|
||||
target_file = project / first_rel
|
||||
assert target_file.exists(), f"Tracked file {first_rel} should exist"
|
||||
target_file.write_text("MODIFIED CONTENT\n")
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code != 0
|
||||
assert "modified" in result.output.lower()
|
||||
|
||||
def test_upgrade_force_overwrites_modified(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path, "copilot")
|
||||
|
||||
# Modify a tracked file
|
||||
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
manifest_data = json.loads(manifest_path.read_text())
|
||||
tracked_files = manifest_data.get("files", {})
|
||||
assert tracked_files, "Manifest should track at least one file"
|
||||
first_rel = next(iter(tracked_files))
|
||||
target_file = project / first_rel
|
||||
assert target_file.exists(), f"Tracked file {first_rel} should exist"
|
||||
target_file.write_text("MODIFIED CONTENT\n")
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0
|
||||
assert "upgraded successfully" in result.output
|
||||
|
||||
def test_upgrade_wrong_integration_key(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path, "copilot")
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade", "claude"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code != 0
|
||||
assert "not the currently installed integration" in result.output
|
||||
|
||||
def test_upgrade_no_manifest(self, tmp_path):
|
||||
"""Upgrade with missing manifest suggests fresh install."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path, "copilot")
|
||||
|
||||
# Remove manifest
|
||||
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
if manifest_path.exists():
|
||||
manifest_path.unlink()
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "upgrade"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0
|
||||
assert "Nothing to upgrade" in result.output
|
||||
@@ -59,7 +59,7 @@ class TestClaudeIntegration:
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["name"] == "speckit-plan"
|
||||
assert parsed["user-invocable"] is True
|
||||
assert parsed["disable-model-invocation"] is True
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
||||
|
||||
def test_setup_installs_update_context_scripts(self, tmp_path):
|
||||
@@ -179,7 +179,7 @@ class TestClaudeIntegration:
|
||||
assert skill_file.exists()
|
||||
skill_content = skill_file.read_text(encoding="utf-8")
|
||||
assert "user-invocable: true" in skill_content
|
||||
assert "disable-model-invocation: true" in skill_content
|
||||
assert "disable-model-invocation: false" in skill_content
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
@@ -280,7 +280,7 @@ class TestClaudeIntegration:
|
||||
assert "preset:claude-skill-command" in content
|
||||
assert "name: speckit-research" in content
|
||||
assert "user-invocable: true" in content
|
||||
assert "disable-model-invocation: true" in content
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
metadata = manager.registry.get("claude-skill-command")
|
||||
assert "speckit-research" in metadata.get("registered_skills", [])
|
||||
@@ -400,3 +400,115 @@ class TestClaudeArgumentHints:
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
|
||||
class TestClaudeDisableModelInvocation:
|
||||
"""Verify disable-model-invocation is false for Claude skills."""
|
||||
|
||||
def test_setup_sets_disable_model_invocation_false(self, tmp_path):
|
||||
"""Generated SKILL.md files must have disable-model-invocation: false."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["disable-model-invocation"] is False, (
|
||||
f"{f.parent.name}: expected disable-model-invocation: false"
|
||||
)
|
||||
|
||||
def test_disable_model_invocation_not_true(self, tmp_path):
|
||||
"""No Claude skill should have disable-model-invocation: true."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
for f in created:
|
||||
if f.name != "SKILL.md":
|
||||
continue
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "disable-model-invocation: true" not in content, (
|
||||
f"{f.parent.name}: must not have disable-model-invocation: true"
|
||||
)
|
||||
|
||||
def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path):
|
||||
"""Non-Claude skill agents should not get disable-model-invocation."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
fm = CommandRegistrar.build_skill_frontmatter(
|
||||
"codex", "speckit-plan", "desc", "templates/commands/plan.md"
|
||||
)
|
||||
assert "disable-model-invocation" not in fm
|
||||
assert "user-invocable" not in fm
|
||||
|
||||
def test_non_claude_post_process_is_identity(self, tmp_path):
|
||||
"""Non-Claude integrations should not modify skill content."""
|
||||
codex = get_integration("codex")
|
||||
if codex is None:
|
||||
return # codex not registered in this build
|
||||
content = "---\nname: test\n---\nBody"
|
||||
assert codex.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestClaudeHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
# specify.md has hook sections
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = ClaudeIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self, tmp_path):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = ClaudeIntegration._inject_hook_command_note(content)
|
||||
twice = ClaudeIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self, tmp_path):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = ClaudeIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_post_process_injects_all_claude_flags(self):
|
||||
"""post_process_skill_content should inject all Claude-specific fields."""
|
||||
i = get_integration("claude")
|
||||
content = (
|
||||
"---\nname: test\ndescription: test\n---\n\n"
|
||||
"- For each executable hook, output the following\n"
|
||||
)
|
||||
result = i.post_process_skill_content(content)
|
||||
assert "user-invocable: true" in result
|
||||
assert "disable-model-invocation: false" in result
|
||||
assert "replace dots" in result
|
||||
|
||||
@@ -199,6 +199,8 @@ class TestCopilotIntegration:
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
@@ -259,6 +261,8 @@ class TestCopilotIntegration:
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
"""Tests for CursorAgentIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestCursorAgentIntegration(MarkdownIntegrationTests):
|
||||
class TestCursorAgentIntegration(SkillsIntegrationTests):
|
||||
KEY = "cursor-agent"
|
||||
FOLDER = ".cursor/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".cursor/commands"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".cursor/skills"
|
||||
CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
|
||||
|
||||
|
||||
class TestCursorAgentAutoPromote:
|
||||
"""--ai cursor-agent auto-promotes to integration path."""
|
||||
|
||||
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai cursor-agent should work the same as --integration cursor-agent."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
|
||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
@@ -248,6 +248,8 @@ class TestGenericIntegration:
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
@@ -304,6 +306,8 @@ class TestGenericIntegration:
|
||||
".specify/templates/plan-template.md",
|
||||
".specify/templates/spec-template.md",
|
||||
".specify/templates/tasks-template.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
|
||||
11
tests/integrations/test_integration_goose.py
Normal file
11
tests/integrations/test_integration_goose.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Tests for GooseIntegration."""
|
||||
|
||||
from .test_integration_base_yaml import YamlIntegrationTests
|
||||
|
||||
|
||||
class TestGooseIntegration(YamlIntegrationTests):
|
||||
KEY = "goose"
|
||||
FOLDER = ".goose/"
|
||||
COMMANDS_SUBDIR = "recipes"
|
||||
REGISTRAR_DIR = ".goose/recipes"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -41,8 +42,9 @@ class TestManifestPathTraversal:
|
||||
|
||||
def test_record_file_rejects_absolute_path(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
|
||||
with pytest.raises(ValueError, match="Absolute paths"):
|
||||
m.record_file("/tmp/escape.txt", "bad")
|
||||
m.record_file(abs_path, "bad")
|
||||
|
||||
def test_record_existing_rejects_parent_traversal(self, tmp_path):
|
||||
escape = tmp_path.parent / "escape.txt"
|
||||
|
||||
@@ -50,16 +50,25 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
|
||||
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
|
||||
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(encoding="utf-8")
|
||||
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' in post_create_text
|
||||
assert (
|
||||
'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"'
|
||||
in post_create_text
|
||||
)
|
||||
assert "sha256sum -c -" in post_create_text
|
||||
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
|
||||
|
||||
def test_agent_context_scripts_use_kiro_cli(self):
|
||||
"""Agent context scripts should advertise kiro-cli and not legacy q agent key."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
bash_text = (
|
||||
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
pwsh_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "kiro-cli" in bash_text
|
||||
assert "kiro-cli" in pwsh_text
|
||||
@@ -89,8 +98,12 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_agent_context_scripts_include_tabnine(self):
|
||||
"""Agent context scripts should support tabnine agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
bash_text = (
|
||||
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
pwsh_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "tabnine" in bash_text
|
||||
assert "TABNINE_FILE" in bash_text
|
||||
@@ -121,7 +134,9 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_kimi_in_powershell_validate_set(self):
|
||||
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
|
||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
ps_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||
assert validate_set_match is not None
|
||||
@@ -155,8 +170,12 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_trae_in_agent_context_scripts(self):
|
||||
"""Agent context scripts should support trae agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
bash_text = (
|
||||
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
pwsh_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "trae" in bash_text
|
||||
assert "TRAE_FILE" in bash_text
|
||||
@@ -165,7 +184,9 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_trae_in_powershell_validate_set(self):
|
||||
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
|
||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
ps_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||
assert validate_set_match is not None
|
||||
@@ -200,7 +221,9 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_pi_in_powershell_validate_set(self):
|
||||
"""PowerShell update-agent-context script should include 'pi' in ValidateSet."""
|
||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
ps_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||
assert validate_set_match is not None
|
||||
@@ -210,8 +233,12 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_agent_context_scripts_include_pi(self):
|
||||
"""Agent context scripts should support pi agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
bash_text = (
|
||||
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
pwsh_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "pi" in bash_text
|
||||
assert "Pi Coding Agent" in bash_text
|
||||
@@ -242,8 +269,12 @@ class TestAgentConfigConsistency:
|
||||
|
||||
def test_iflow_in_agent_context_scripts(self):
|
||||
"""Agent context scripts should support iflow agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
bash_text = (
|
||||
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
pwsh_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "iflow" in bash_text
|
||||
assert "IFLOW_FILE" in bash_text
|
||||
@@ -253,3 +284,37 @@ class TestAgentConfigConsistency:
|
||||
def test_ai_help_includes_iflow(self):
|
||||
"""CLI help text for --ai should include iflow."""
|
||||
assert "iflow" in AI_ASSISTANT_HELP
|
||||
|
||||
# --- Goose consistency checks ---
|
||||
|
||||
def test_goose_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include goose with correct folder and commands_subdir."""
|
||||
assert "goose" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["goose"]["folder"] == ".goose/"
|
||||
assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes"
|
||||
assert AGENT_CONFIG["goose"]["requires_cli"] is True
|
||||
|
||||
def test_goose_in_extension_registrar(self):
|
||||
"""Extension command registrar should include goose targeting .goose/recipes."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "goose" in cfg
|
||||
assert cfg["goose"]["dir"] == ".goose/recipes"
|
||||
assert cfg["goose"]["format"] == "yaml"
|
||||
assert cfg["goose"]["args"] == "{{args}}"
|
||||
|
||||
def test_goose_in_agent_context_scripts(self):
|
||||
"""Agent context scripts should support goose agent type."""
|
||||
bash_text = (
|
||||
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
pwsh_text = (
|
||||
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "goose" in bash_text
|
||||
assert "goose" in pwsh_text
|
||||
|
||||
def test_ai_help_includes_goose(self):
|
||||
"""CLI help text for --ai should include goose."""
|
||||
assert "goose" in AI_ASSISTANT_HELP
|
||||
|
||||
35
tests/test_cli_version.py
Normal file
35
tests/test_cli_version.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Tests for the --version CLI flag."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestVersionFlag:
|
||||
"""Test --version / -V flag on the root command."""
|
||||
|
||||
def test_version_long_flag(self):
|
||||
"""specify --version prints version and exits 0."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 1.2.3" in result.output
|
||||
|
||||
def test_version_short_flag(self):
|
||||
"""specify -V prints version and exits 0."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["-V"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 1.2.3" in result.output
|
||||
|
||||
def test_version_flag_takes_precedence_over_subcommand(self):
|
||||
"""--version should work even when a subcommand follows."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="0.7.2"):
|
||||
result = runner.invoke(app, ["--version", "init"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 0.7.2" in result.output
|
||||
@@ -12,6 +12,8 @@ import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
SCRIPT_PATH = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
os.pardir,
|
||||
@@ -73,6 +75,7 @@ class TestScriptFrontmatterPattern:
|
||||
|
||||
|
||||
@requires_git
|
||||
@requires_bash
|
||||
class TestCursorFrontmatterIntegration:
|
||||
"""Integration tests using a real git repo."""
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ class TestExtensionSkillRegistration:
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["name"] == "speckit-test-ext-hello"
|
||||
assert "description" in parsed
|
||||
assert parsed["disable-model-invocation"] is True
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
|
||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||
"""No skills should be created when ai_skills is false."""
|
||||
|
||||
@@ -11,6 +11,7 @@ Tests cover:
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import platform
|
||||
import tempfile
|
||||
import shutil
|
||||
import tomllib
|
||||
@@ -243,7 +244,7 @@ class TestExtensionManifest:
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with invalid command name format."""
|
||||
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
|
||||
@@ -255,6 +256,83 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid command name"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data):
|
||||
"""Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
assert manifest.commands[0]["name"] == "speckit.test-ext.hello"
|
||||
assert len(manifest.warnings) == 1
|
||||
assert "speckit.hello" in manifest.warnings[0]
|
||||
assert "speckit.test-ext.hello" in manifest.warnings[0]
|
||||
|
||||
def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data):
|
||||
"""Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'."""
|
||||
import yaml
|
||||
|
||||
# Set ext_id to match the legacy namespace so correction is valid
|
||||
valid_manifest_data["extension"]["id"] = "docguard"
|
||||
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
assert manifest.commands[0]["name"] == "speckit.docguard.guard"
|
||||
assert len(manifest.warnings) == 1
|
||||
assert "docguard.guard" in manifest.warnings[0]
|
||||
assert "speckit.docguard.guard" in manifest.warnings[0]
|
||||
|
||||
def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data):
|
||||
"""Test that 'X.command' is NOT corrected when X doesn't match ext_id."""
|
||||
import yaml
|
||||
|
||||
# ext_id is "test-ext" but command uses a different namespace
|
||||
valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid command name"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_alias_free_form_accepted(self, temp_dir, valid_manifest_data):
|
||||
"""Aliases are free-form — a 'speckit.command' alias must be accepted unchanged."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"]
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
assert manifest.commands[0]["aliases"] == ["speckit.hello"]
|
||||
assert manifest.warnings == []
|
||||
|
||||
def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data):
|
||||
"""Test that a correctly-named command produces no warnings."""
|
||||
import yaml
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
assert manifest.warnings == []
|
||||
|
||||
def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with no commands and no hooks provided."""
|
||||
import yaml
|
||||
@@ -317,6 +395,19 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid hooks"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data):
|
||||
"""Non-mapping hook entries must raise ValidationError, not silently skip."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_manifest_hash(self, extension_dir):
|
||||
"""Test manifest hash calculation."""
|
||||
manifest_path = extension_dir / "extension.yml"
|
||||
@@ -686,8 +777,8 @@ class TestExtensionManager:
|
||||
with pytest.raises(ValidationError, match="conflicts with core command namespace"):
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
def test_install_accepts_short_alias(self, temp_dir, project_dir):
|
||||
"""Install should accept legacy short aliases for community extension compat."""
|
||||
def test_install_accepts_free_form_alias(self, temp_dir, project_dir):
|
||||
"""Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "alias-shortcut"
|
||||
@@ -718,8 +809,10 @@ class TestExtensionManager:
|
||||
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Should not raise — short aliases are allowed
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
assert manifest.commands[0]["aliases"] == ["speckit.shortcut"]
|
||||
assert manifest.warnings == []
|
||||
|
||||
def test_install_rejects_namespace_squatting(self, temp_dir, project_dir):
|
||||
"""Install should reject commands and aliases outside the extension namespace."""
|
||||
@@ -1360,6 +1453,7 @@ scripts:
|
||||
ps: ../../scripts/powershell/setup-plan.ps1 -Json
|
||||
agent_scripts:
|
||||
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
|
||||
ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
@@ -1381,8 +1475,12 @@ Then {AGENT_SCRIPT}
|
||||
content = skill_file.read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
if platform.system().lower().startswith("win"):
|
||||
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
|
||||
assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content
|
||||
else:
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_registration_handles_non_dict_init_options(
|
||||
self, project_dir, temp_dir
|
||||
@@ -1619,6 +1717,54 @@ Then {AGENT_SCRIPT}
|
||||
prompts_dir = project_dir / ".github" / "prompts"
|
||||
assert not prompts_dir.exists()
|
||||
|
||||
def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir):
|
||||
"""Unregistering a SKILL.md command should remove the empty parent subdirectory."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "cleanup-ext"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "cleanup-ext",
|
||||
"name": "Cleanup Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.cleanup-ext.run",
|
||||
"file": "commands/run.md",
|
||||
"description": "Run",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
(ext_dir / "commands" / "run.md").write_text("---\ndescription: Run\n---\n\nBody")
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
from specify_cli.extensions import ExtensionManifest
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_subdir = skills_dir / "speckit-cleanup-ext-run"
|
||||
assert skill_subdir.exists(), "Skill subdirectory should exist after registration"
|
||||
assert (skill_subdir / "SKILL.md").exists()
|
||||
|
||||
registrar.unregister_commands({"codex": ["speckit.cleanup-ext.run"]}, project_dir)
|
||||
|
||||
assert not (skill_subdir / "SKILL.md").exists(), "SKILL.md should be removed"
|
||||
assert not skill_subdir.exists(), "Empty parent subdirectory should be removed"
|
||||
|
||||
|
||||
# ===== Utility Function Tests =====
|
||||
|
||||
@@ -2995,6 +3141,122 @@ class TestExtensionAddCLI:
|
||||
f"but was called with '{download_called_with[0]}'"
|
||||
)
|
||||
|
||||
def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
|
||||
"""extension add should give a clear error when a bundled extension is not found locally."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Create project structure
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
(project_dir / ".specify" / "extensions").mkdir(parents=True)
|
||||
|
||||
# Mock catalog that returns a bundled extension without download_url
|
||||
mock_catalog = MagicMock()
|
||||
mock_catalog.get_extension_info.return_value = {
|
||||
"id": "git",
|
||||
"name": "Git Branching Workflow",
|
||||
"version": "1.0.0",
|
||||
"description": "Git branching extension",
|
||||
"bundled": True,
|
||||
"_install_allowed": True,
|
||||
}
|
||||
mock_catalog.search.return_value = []
|
||||
|
||||
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
|
||||
patch("specify_cli._locate_bundled_extension", return_value=None), \
|
||||
patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "git"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "bundled with spec-kit" in result.output
|
||||
assert "reinstall" in result.output.lower()
|
||||
|
||||
|
||||
class TestDownloadExtensionBundled:
|
||||
"""Tests for download_extension handling of bundled extensions."""
|
||||
|
||||
def test_download_extension_raises_for_bundled(self, temp_dir):
|
||||
"""download_extension should raise a clear error for bundled extensions without a URL."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
bundled_ext_info = {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
"version": "1.0.0",
|
||||
"description": "Git workflow",
|
||||
"bundled": True,
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
|
||||
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
|
||||
catalog.download_extension("git")
|
||||
|
||||
def test_download_extension_allows_bundled_with_url(self, temp_dir):
|
||||
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
import urllib.request
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
bundled_with_url = {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
"version": "2.0.0",
|
||||
"description": "Git workflow",
|
||||
"bundled": True,
|
||||
"download_url": "https://example.com/git-2.0.0.zip",
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b"fake zip data"
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
|
||||
patch.object(urllib.request, "urlopen", return_value=mock_response):
|
||||
result = catalog.download_extension("git")
|
||||
assert result.name == "git-2.0.0.zip"
|
||||
|
||||
def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
|
||||
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
|
||||
from unittest.mock import patch
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
catalog = ExtensionCatalog(project_dir)
|
||||
|
||||
non_bundled_ext_info = {
|
||||
"name": "Some Extension",
|
||||
"id": "some-ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
|
||||
with pytest.raises(ExtensionError, match="has no download URL"):
|
||||
catalog.download_extension("some-ext")
|
||||
|
||||
|
||||
class TestExtensionUpdateCLI:
|
||||
"""CLI integration tests for extension update command."""
|
||||
@@ -3737,3 +3999,58 @@ class TestHookInvocationRendering:
|
||||
assert "Executing: `/<missing command>`" in message
|
||||
assert "EXECUTE_COMMAND: <missing command>" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /<missing command>" in message
|
||||
|
||||
|
||||
class TestExtensionRemoveCLI:
|
||||
"""CLI tests for `specify extension remove` confirmation prompt wording."""
|
||||
|
||||
def _install_ext(self, project_dir, ext_dir):
|
||||
"""Install extension and return the manager."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
return manager
|
||||
|
||||
def test_remove_confirmation_singular_command(self, tmp_path, extension_dir):
|
||||
"""Confirmation prompt should say '1 command' (singular) when one command registered."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
manager = self._install_ext(project_dir, extension_dir)
|
||||
# Inject registered_commands with 1 entry so cmd_count == 1
|
||||
manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}})
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False
|
||||
)
|
||||
|
||||
assert "1 command" in result.output
|
||||
assert "1 commands" not in result.output
|
||||
|
||||
def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir):
|
||||
"""Confirmation prompt should say '2 commands' (plural) when two commands registered."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
manager = self._install_ext(project_dir, extension_dir)
|
||||
# Inject registered_commands with 2 entries so cmd_count == 2
|
||||
manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}})
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False
|
||||
)
|
||||
|
||||
assert "2 commands" in result.output
|
||||
|
||||
@@ -1175,8 +1175,7 @@ class TestPresetCatalog:
|
||||
"""Test search with cached catalog data."""
|
||||
from unittest.mock import patch
|
||||
|
||||
# Only use the default catalog to prevent fetching the community catalog from the network
|
||||
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL)
|
||||
monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False)
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1976,7 +1975,7 @@ class TestPresetSkills:
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" in content, "Skill should reference preset source"
|
||||
assert "disable-model-invocation: true" in content
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
# Verify it was recorded in registry
|
||||
metadata = manager.registry.get("self-test")
|
||||
@@ -2058,7 +2057,7 @@ class TestPresetSkills:
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" not in content, "Preset content should be gone"
|
||||
assert "templates/commands/specify.md" in content, "Should reference core template"
|
||||
assert "disable-model-invocation: true" in content
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
|
||||
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
|
||||
@@ -2865,3 +2864,182 @@ class TestPresetEnableDisable:
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "corrupted state" in result.output.lower()
|
||||
|
||||
|
||||
# ===== Lean Preset Tests =====
|
||||
|
||||
|
||||
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
|
||||
|
||||
LEAN_COMMAND_NAMES = [
|
||||
"speckit.specify",
|
||||
"speckit.plan",
|
||||
"speckit.tasks",
|
||||
"speckit.implement",
|
||||
"speckit.constitution",
|
||||
]
|
||||
|
||||
|
||||
class TestLeanPreset:
|
||||
"""Tests for the lean preset that ships with the repo."""
|
||||
|
||||
def test_lean_preset_exists(self):
|
||||
"""Verify the lean preset directory and manifest exist."""
|
||||
assert LEAN_PRESET_DIR.exists()
|
||||
assert (LEAN_PRESET_DIR / "preset.yml").exists()
|
||||
|
||||
def test_lean_manifest_valid(self):
|
||||
"""Verify the lean preset manifest is valid."""
|
||||
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
|
||||
assert manifest.id == "lean"
|
||||
assert manifest.name == "Lean Workflow"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert len(manifest.templates) == 5 # 5 commands
|
||||
|
||||
def test_lean_provides_core_workflow_commands(self):
|
||||
"""Verify the lean preset provides overrides for core workflow commands."""
|
||||
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
|
||||
provided_names = {t["name"] for t in manifest.templates}
|
||||
for name in LEAN_COMMAND_NAMES:
|
||||
assert name in provided_names, f"Lean preset missing command: {name}"
|
||||
|
||||
def test_lean_command_files_exist(self):
|
||||
"""Verify that all declared command files actually exist on disk."""
|
||||
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
|
||||
for tmpl in manifest.templates:
|
||||
tmpl_path = LEAN_PRESET_DIR / tmpl["file"]
|
||||
assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}"
|
||||
|
||||
def test_lean_commands_have_no_scripts(self):
|
||||
"""Verify lean commands have no scripts or agent_scripts in frontmatter."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
for name in LEAN_COMMAND_NAMES:
|
||||
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
|
||||
content = cmd_path.read_text()
|
||||
frontmatter, _ = CommandRegistrar.parse_frontmatter(content)
|
||||
assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter"
|
||||
assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter"
|
||||
|
||||
def test_lean_commands_have_no_hooks(self):
|
||||
"""Verify lean commands do not contain extension hook boilerplate."""
|
||||
for name in LEAN_COMMAND_NAMES:
|
||||
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
|
||||
content = cmd_path.read_text()
|
||||
assert "hooks." not in content, f"{name} should not reference extension hooks"
|
||||
assert "extensions.yml" not in content, f"{name} should not reference extensions.yml"
|
||||
|
||||
def test_install_lean_preset(self, project_dir):
|
||||
"""Test installing the lean preset from its directory."""
|
||||
manager = PresetManager(project_dir)
|
||||
manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
|
||||
assert manifest.id == "lean"
|
||||
assert manager.registry.is_installed("lean")
|
||||
|
||||
def test_lean_overrides_commands(self, project_dir):
|
||||
"""Test that lean preset overrides are resolved correctly."""
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
for name in LEAN_COMMAND_NAMES:
|
||||
result = resolver.resolve(name, template_type="command")
|
||||
assert result is not None, f"Lean override for {name} not resolved"
|
||||
|
||||
|
||||
# ===== Bundled Preset Locator Tests =====
|
||||
|
||||
|
||||
class TestBundledPresetLocator:
|
||||
"""Tests for _locate_bundled_preset discovery function."""
|
||||
|
||||
def test_locate_bundled_lean_preset(self):
|
||||
"""_locate_bundled_preset finds the lean preset."""
|
||||
from specify_cli import _locate_bundled_preset
|
||||
|
||||
path = _locate_bundled_preset("lean")
|
||||
assert path is not None
|
||||
assert (path / "preset.yml").is_file()
|
||||
|
||||
def test_locate_bundled_preset_not_found(self):
|
||||
"""_locate_bundled_preset returns None for nonexistent preset."""
|
||||
from specify_cli import _locate_bundled_preset
|
||||
|
||||
path = _locate_bundled_preset("nonexistent-preset")
|
||||
assert path is None
|
||||
|
||||
def test_locate_bundled_preset_rejects_invalid_id(self):
|
||||
"""_locate_bundled_preset rejects IDs with invalid characters."""
|
||||
from specify_cli import _locate_bundled_preset
|
||||
|
||||
assert _locate_bundled_preset("../escape") is None
|
||||
assert _locate_bundled_preset("UPPERCASE") is None
|
||||
assert _locate_bundled_preset("has spaces") is None
|
||||
|
||||
def test_bundled_preset_add_via_cli(self, project_dir):
|
||||
"""Test that 'specify preset add lean' installs the bundled preset."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.get_speckit_version", return_value="0.6.0"):
|
||||
result = runner.invoke(app, ["preset", "add", "lean"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Lean Workflow" in result.output
|
||||
assert "installed" in result.output.lower()
|
||||
|
||||
def test_bundled_preset_in_catalog(self):
|
||||
"""Verify the lean preset is listed in catalog.json with bundled marker."""
|
||||
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
|
||||
catalog = json.loads(catalog_path.read_text())
|
||||
assert "lean" in catalog["presets"]
|
||||
assert catalog["presets"]["lean"]["bundled"] is True
|
||||
assert "download_url" not in catalog["presets"]["lean"]
|
||||
|
||||
def test_bundled_preset_download_raises_error(self, project_dir):
|
||||
"""download_pack raises PresetError for bundled presets without download_url."""
|
||||
catalog = PresetCatalog(project_dir)
|
||||
|
||||
catalog_data = {
|
||||
"test-bundled": {
|
||||
"name": "Test Bundled",
|
||||
"version": "1.0.0",
|
||||
"bundled": True,
|
||||
}
|
||||
}
|
||||
from unittest.mock import patch
|
||||
with patch.object(catalog, "_get_merged_packs", return_value=catalog_data):
|
||||
with pytest.raises(PresetError, match="bundled with spec-kit"):
|
||||
catalog.download_pack("test-bundled")
|
||||
|
||||
def test_bundled_preset_missing_locally_cli_error(self, project_dir):
|
||||
"""CLI shows clear error when bundled preset cannot be found locally."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
# Patch _locate_bundled_preset to return None (simulating missing files)
|
||||
# and mock the catalog to return a bundled entry for "lean"
|
||||
fake_pack_info = {
|
||||
"id": "lean",
|
||||
"name": "Lean Workflow",
|
||||
"version": "1.0.0",
|
||||
"bundled": True,
|
||||
"_install_allowed": True,
|
||||
}
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli._locate_bundled_preset", return_value=None), \
|
||||
patch("specify_cli.presets.PresetCatalog") as MockCatalog:
|
||||
MockCatalog.return_value.get_pack_info.return_value = fake_pack_info
|
||||
result = runner.invoke(app, ["preset", "add", "lean"])
|
||||
|
||||
# Should fail with a helpful error explaining this is a bundled preset
|
||||
# and suggesting how to recover.
|
||||
assert result.exit_code == 1
|
||||
output = strip_ansi(result.output).lower()
|
||||
assert "bundled" in output, result.output
|
||||
assert "reinstall" in output, result.output
|
||||
|
||||
@@ -13,6 +13,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
@@ -26,6 +28,13 @@ COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
|
||||
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
|
||||
|
||||
def _has_pwsh() -> bool:
|
||||
"""Check if pwsh is available."""
|
||||
return HAS_PWSH
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(tmp_path: Path) -> Path:
|
||||
@@ -142,6 +151,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl
|
||||
# ── Timestamp Branch Tests ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestTimestampBranch:
|
||||
def test_timestamp_creates_branch(self, git_repo: Path):
|
||||
"""Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix."""
|
||||
@@ -187,6 +197,7 @@ class TestTimestampBranch:
|
||||
# ── Sequential Branch Tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestSequentialBranch:
|
||||
def test_sequential_default_with_existing_specs(self, git_repo: Path):
|
||||
"""Test 2: Sequential default with existing specs."""
|
||||
@@ -225,6 +236,8 @@ class TestSequentialBranch:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}"
|
||||
|
||||
|
||||
class TestSequentialBranchPowerShell:
|
||||
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
|
||||
"""PowerShell scanner should parse large prefixes without [int] casts."""
|
||||
content = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
@@ -235,6 +248,7 @@ class TestSequentialBranch:
|
||||
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCheckFeatureBranch:
|
||||
def test_accepts_timestamp_branch(self):
|
||||
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||
@@ -271,10 +285,35 @@ class TestCheckFeatureBranch:
|
||||
result = source_and_call('check_feature_branch "2026031-143022" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_accepts_single_prefix_sequential(self):
|
||||
"""Optional gitflow-style prefix: one segment + sequential feature name."""
|
||||
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_accepts_single_prefix_timestamp(self):
|
||||
"""Optional prefix + timestamp-style feature name."""
|
||||
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_rejects_invalid_suffix_with_single_prefix(self):
|
||||
result = source_and_call('check_feature_branch "feat/main" "true"')
|
||||
assert result.returncode != 0
|
||||
assert "feat/main" in result.stderr
|
||||
|
||||
def test_rejects_two_level_prefix_before_feature(self):
|
||||
"""More than one slash: no stripping; whole name must match (fails)."""
|
||||
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
def test_rejects_malformed_timestamp_with_prefix(self):
|
||||
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestFindFeatureDirByPrefix:
|
||||
def test_timestamp_branch(self, tmp_path: Path):
|
||||
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||
@@ -303,10 +342,73 @@ class TestFindFeatureDirByPrefix:
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
|
||||
|
||||
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
|
||||
"""Strip one optional prefix segment before prefix directory lookup."""
|
||||
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
|
||||
|
||||
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
|
||||
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
|
||||
result = source_and_call(
|
||||
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
|
||||
|
||||
|
||||
# ── get_feature_paths + single-prefix integration ───────────────────────────
|
||||
|
||||
|
||||
class TestGetFeaturePathsSinglePrefix:
|
||||
@requires_bash
|
||||
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
|
||||
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
|
||||
(tmp_path / ".specify").mkdir()
|
||||
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
|
||||
cmd = (
|
||||
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
|
||||
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
|
||||
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
|
||||
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
|
||||
spec_dir.mkdir(parents=True)
|
||||
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
|
||||
result = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-Command", ps_cmd],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
val = line.split("=", 1)[1].strip()
|
||||
assert val == str(spec_dir)
|
||||
break
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in PowerShell output")
|
||||
|
||||
|
||||
# ── get_current_branch Tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestGetCurrentBranch:
|
||||
def test_env_var(self):
|
||||
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
|
||||
@@ -317,6 +419,7 @@ class TestGetCurrentBranch:
|
||||
# ── No-git Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestNoGitTimestamp:
|
||||
def test_no_git_timestamp(self, no_git_dir: Path):
|
||||
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
|
||||
@@ -330,6 +433,7 @@ class TestNoGitTimestamp:
|
||||
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestE2EFlow:
|
||||
def test_e2e_timestamp(self, git_repo: Path):
|
||||
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||
@@ -363,6 +467,7 @@ class TestE2EFlow:
|
||||
# ── Allow Existing Branch Tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestAllowExistingBranch:
|
||||
def test_allow_existing_switches_to_branch(self, git_repo: Path):
|
||||
"""T006: Pre-create branch, verify script switches to it."""
|
||||
@@ -563,6 +668,7 @@ class TestGitExtensionParity:
|
||||
# ── Dry-Run Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestDryRun:
|
||||
def test_dry_run_sequential_outputs_name(self, git_repo: Path):
|
||||
"""T009: Dry-run computes correct branch name with existing specs."""
|
||||
@@ -791,15 +897,6 @@ class TestDryRun:
|
||||
# ── PowerShell Dry-Run Tests ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_pwsh() -> bool:
|
||||
"""Check if pwsh is available."""
|
||||
try:
|
||||
subprocess.run(["pwsh", "--version"], capture_output=True, check=True)
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
|
||||
def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Run create-new-feature.ps1 from the temp repo's scripts directory."""
|
||||
script = cwd / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
@@ -901,6 +998,7 @@ class TestPowerShellDryRun:
|
||||
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestGitBranchNameOverrideBash:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||
|
||||
@@ -1005,6 +1103,7 @@ class TestGitBranchNameOverridePowerShell:
|
||||
class TestFeatureDirectoryResolution:
|
||||
"""Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution."""
|
||||
|
||||
@requires_bash
|
||||
def test_env_var_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup."""
|
||||
custom_dir = git_repo / "my-custom-specs" / "my-feature"
|
||||
@@ -1027,6 +1126,7 @@ class TestFeatureDirectoryResolution:
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
@requires_bash
|
||||
def test_feature_json_overrides_branch_lookup(self, git_repo: Path):
|
||||
"""feature.json feature_directory takes priority over branch-based lookup."""
|
||||
custom_dir = git_repo / "specs" / "custom-feature"
|
||||
@@ -1034,7 +1134,7 @@ class TestFeatureDirectoryResolution:
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -1053,6 +1153,7 @@ class TestFeatureDirectoryResolution:
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
@requires_bash
|
||||
def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):
|
||||
"""Env var wins over feature.json."""
|
||||
env_dir = git_repo / "specs" / "env-feature"
|
||||
@@ -1062,7 +1163,7 @@ class TestFeatureDirectoryResolution:
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{json_dir}"}}\n',
|
||||
json.dumps({"feature_directory": str(json_dir)}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -1082,6 +1183,7 @@ class TestFeatureDirectoryResolution:
|
||||
else:
|
||||
pytest.fail("FEATURE_DIR not found in output")
|
||||
|
||||
@requires_bash
|
||||
def test_fallback_to_branch_lookup(self, git_repo: Path):
|
||||
"""Without env var or feature.json, falls back to branch-based lookup."""
|
||||
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
|
||||
@@ -1136,7 +1238,7 @@ class TestFeatureDirectoryResolution:
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
1803
tests/test_workflows.py
Normal file
1803
tests/test_workflows.py
Normal file
File diff suppressed because it is too large
Load Diff
211
workflows/ARCHITECTURE.md
Normal file
211
workflows/ARCHITECTURE.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Workflow System Architecture
|
||||
|
||||
This document describes the internal architecture of the workflow engine — how definitions are parsed, steps are dispatched, state is persisted, and catalogs are resolved.
|
||||
|
||||
For usage instructions, see [README.md](README.md).
|
||||
|
||||
## Execution Model
|
||||
|
||||
When `specify workflow run` is invoked, the engine loads a YAML definition, resolves inputs, and dispatches steps sequentially through the step registry:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["specify workflow run my-workflow"] --> B["WorkflowEngine.load_workflow()"]
|
||||
B --> C["WorkflowDefinition.from_yaml()"]
|
||||
C --> D["_resolve_inputs()"]
|
||||
D --> E["validate_workflow()"]
|
||||
E --> F["RunState.create()"]
|
||||
F --> G["_execute_steps()"]
|
||||
G --> H{Step type?}
|
||||
H -- command --> I["CommandStep.execute()"]
|
||||
H -- shell --> J["ShellStep.execute()"]
|
||||
H -- gate --> K["GateStep.execute()"]
|
||||
H -- "if" --> L["IfThenStep.execute()"]
|
||||
H -- switch --> M["SwitchStep.execute()"]
|
||||
H -- "while/do-while" --> N["Loop steps"]
|
||||
H -- "fan-out/fan-in" --> O["Fan-out/fan-in"]
|
||||
|
||||
I --> P{Result status?}
|
||||
J --> P
|
||||
K --> P
|
||||
L --> P
|
||||
M --> P
|
||||
N --> P
|
||||
O --> P
|
||||
P -- COMPLETED --> Q{Has next_steps?}
|
||||
P -- PAUSED --> R["Save state → exit"]
|
||||
P -- FAILED --> S["Log error → exit"]
|
||||
Q -- Yes --> G
|
||||
Q -- No --> T{More steps?}
|
||||
T -- Yes --> G
|
||||
T -- No --> U["Status = COMPLETED"]
|
||||
|
||||
style R fill:#ff9800,color:#fff
|
||||
style S fill:#f44336,color:#fff
|
||||
style U fill:#4caf50,color:#fff
|
||||
```
|
||||
|
||||
### Sequential Execution
|
||||
|
||||
Steps execute sequentially. Each step receives a `StepContext` containing resolved inputs, accumulated step results, and workflow-level defaults. After execution, the step's output is stored in `context.steps[step_id]` and made available to subsequent steps via expressions like `{{ steps.specify.output.file }}`.
|
||||
|
||||
### Nested Steps (Control Flow)
|
||||
|
||||
Steps like `if`, `switch`, `while`, and `do-while` return `next_steps` — inline step definitions that the engine executes recursively via `_execute_steps()`. Nested steps share the same `StepContext` and `RunState`, so their outputs are visible to later top-level steps.
|
||||
|
||||
### State Persistence and Resume
|
||||
|
||||
The engine saves `RunState` to disk after each step, enabling resume from the exact point of interruption:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["CREATED"] --> B["RUNNING"]
|
||||
B --> C["COMPLETED"]
|
||||
B --> D["PAUSED"]
|
||||
B --> E["FAILED"]
|
||||
B --> F["ABORTED"]
|
||||
D -- "resume()" --> B
|
||||
E -- "resume()" --> B
|
||||
```
|
||||
|
||||
When a `gate` step pauses execution, the engine persists `current_step_index` and all accumulated `step_results`. On `specify workflow resume <run_id>`, the engine restores the context and continues from the paused step.
|
||||
|
||||
> **Note:** Resume tracking is at the top-level step index only. If a
|
||||
> nested step (inside `if`/`switch`/`while`) pauses, resume re-runs
|
||||
> the parent control-flow step and its nested body. A nested step-path
|
||||
> stack for exact resume is a planned enhancement.
|
||||
|
||||
## Step Types
|
||||
|
||||
The engine ships with 10 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`:
|
||||
|
||||
| Type Key | Class | Purpose | Returns `next_steps`? |
|
||||
|----------|-------|---------|-----------------------|
|
||||
| `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No |
|
||||
| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No |
|
||||
| `shell` | `ShellStep` | Run a shell command, capture output | No |
|
||||
| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) |
|
||||
| `if` | `IfThenStep` | Conditional branching (then/else) | Yes |
|
||||
| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes |
|
||||
| `while` | `WhileStep` | Loop while condition is truthy | Yes (if true) |
|
||||
| `do-while` | `DoWhileStep` | Loop, always runs body at least once | Yes (always) |
|
||||
| `fan-out` | `FanOutStep` | Dispatch per item over a collection | No (engine expands) |
|
||||
| `fan-in` | `FanInStep` | Aggregate results from fan-out | No |
|
||||
|
||||
## Step Registry
|
||||
|
||||
All step types register into `STEP_REGISTRY` via `_register_builtin_steps()` in `src/specify_cli/workflows/__init__.py`. The registry maps `type_key` strings to step instances:
|
||||
|
||||
```python
|
||||
STEP_REGISTRY: dict[str, StepBase] # e.g., {"command": CommandStep(), "gate": GateStep(), ...}
|
||||
```
|
||||
|
||||
Registration is explicit — each step class is imported and instantiated. New step types follow the same pattern: subclass `StepBase`, set `type_key`, implement `execute()` and optionally `validate()`.
|
||||
|
||||
## Expression Engine
|
||||
|
||||
Workflow definitions use Jinja2-like `{{ expression }}` syntax for dynamic values. The expression engine in `src/specify_cli/workflows/expressions.py` supports:
|
||||
|
||||
| Feature | Syntax | Example |
|
||||
|---------|--------|---------|
|
||||
| Variable access | `{{ inputs.name }}` | Dot-path traversal into context |
|
||||
| Step outputs | `{{ steps.plan.output.file }}` | Access previous step results |
|
||||
| Comparisons | `==`, `!=`, `>`, `<`, `>=`, `<=` | `{{ count > 5 }}` |
|
||||
| Boolean logic | `and`, `or`, `not` | `{{ items and status == 'ok' }}` |
|
||||
| Membership | `in`, `not in` | `{{ 'error' not in status }}` |
|
||||
| Literals | strings, numbers, booleans, lists | `{{ true }}`, `{{ [1, 2] }}` |
|
||||
| Filter: `default` | `{{ val \| default('fallback') }}` | Fallback for None/empty |
|
||||
| Filter: `join` | `{{ list \| join(', ') }}` | Join list elements |
|
||||
| Filter: `contains` | `{{ text \| contains('sub') }}` | Substring/membership check |
|
||||
| Filter: `map` | `{{ list \| map('attr') }}` | Extract attribute from each item |
|
||||
|
||||
**Single expressions** (`{{ expr }}` only) return typed values. **Mixed templates** (`"text {{ expr }} more"`) return interpolated strings.
|
||||
|
||||
### Namespace
|
||||
|
||||
The expression evaluator builds a namespace from the `StepContext`:
|
||||
|
||||
| Key | Source | Available when |
|
||||
|-----|--------|----------------|
|
||||
| `inputs` | Resolved workflow inputs | Always |
|
||||
| `steps` | Accumulated step results | After first step |
|
||||
| `item` | Current iteration item | Inside fan-out |
|
||||
| `fan_in` | Aggregated results | Inside fan-in |
|
||||
|
||||
## Input Resolution
|
||||
|
||||
When a workflow is executed, `_resolve_inputs()` validates and coerces provided values against the `inputs:` schema:
|
||||
|
||||
| Declared Type | Coercion | Example |
|
||||
|---------------|----------|---------|
|
||||
| `string` | None (pass-through) | `"my-feature"` |
|
||||
| `number` | `float()` → `int()` if whole | `"42"` → `42` |
|
||||
| `boolean` | `"true"/"1"/"yes"` → `True` | `"false"` → `False` |
|
||||
| `enum` | Validates against allowed values | `["full", "backend-only"]` |
|
||||
|
||||
Missing required inputs raise `ValueError`. Inputs with `default` values use the default when not provided.
|
||||
|
||||
## Catalog System
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["specify workflow search"] --> B["WorkflowCatalog.get_active_catalogs()"]
|
||||
B --> C{SPECKIT_WORKFLOW_CATALOG_URL set?}
|
||||
C -- Yes --> D["Single custom catalog"]
|
||||
C -- No --> E{.specify/workflow-catalogs.yml exists?}
|
||||
E -- Yes --> F["Project-level catalog stack"]
|
||||
E -- No --> G{"~/.specify/workflow-catalogs.yml exists?"}
|
||||
G -- Yes --> H["User-level catalog stack"]
|
||||
G -- No --> I["Built-in defaults"]
|
||||
I --> J["default (install allowed)"]
|
||||
I --> K["community (discovery only)"]
|
||||
|
||||
style D fill:#ff9800,color:#fff
|
||||
style F fill:#2196f3,color:#fff
|
||||
style H fill:#2196f3,color:#fff
|
||||
style J fill:#4caf50,color:#fff
|
||||
style K fill:#9e9e9e,color:#fff
|
||||
```
|
||||
|
||||
Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files in `.specify/workflows/.cache/`). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag.
|
||||
|
||||
When `specify workflow add <id>` installs from catalog, it downloads the workflow YAML from the catalog entry's `url` field into `.specify/workflows/<id>/workflow.yml`.
|
||||
|
||||
## State and Configuration Locations
|
||||
|
||||
| Component | Location | Format | Purpose |
|
||||
|-----------|----------|--------|---------|
|
||||
| Workflow definitions | `.specify/workflows/{id}/workflow.yml` | YAML | Installed workflow definitions |
|
||||
| Workflow registry | `.specify/workflows/workflow-registry.json` | JSON | Installed workflows metadata |
|
||||
| Run state | `.specify/workflows/runs/{run_id}/state.json` | JSON | Persisted execution state |
|
||||
| Run inputs | `.specify/workflows/runs/{run_id}/inputs.json` | JSON | Resolved input values |
|
||||
| Run log | `.specify/workflows/runs/{run_id}/log.jsonl` | JSONL | Append-only event log |
|
||||
| Catalog cache | `.specify/workflows/.cache/*.json` | JSON | Cached catalog entries (1hr TTL) |
|
||||
| Project catalogs | `.specify/workflow-catalogs.yml` | YAML | Project-level catalog sources |
|
||||
| User catalogs | `~/.specify/workflow-catalogs.yml` | YAML | User-level catalog sources |
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/specify_cli/
|
||||
├── workflows/
|
||||
│ ├── __init__.py # STEP_REGISTRY + _register_builtin_steps()
|
||||
│ ├── base.py # StepBase, StepContext, StepResult, StepStatus, RunStatus
|
||||
│ ├── catalog.py # WorkflowCatalog, WorkflowCatalogEntry, WorkflowRegistry
|
||||
│ ├── engine.py # WorkflowDefinition, WorkflowEngine, RunState, validate_workflow()
|
||||
│ ├── expressions.py # evaluate_expression(), evaluate_condition(), filters
|
||||
│ └── steps/
|
||||
│ ├── command/ # Dispatch command to AI integration
|
||||
│ ├── shell/ # Run shell command
|
||||
│ ├── gate/ # Human review checkpoint
|
||||
│ ├── if_then/ # Conditional branching
|
||||
│ ├── prompt/ # Arbitrary inline prompts
|
||||
│ ├── switch/ # Multi-branch dispatch
|
||||
│ ├── while_loop/ # While loop
|
||||
│ ├── do_while/ # Do-while loop
|
||||
│ ├── fan_out/ # Sequential per-item dispatch
|
||||
│ └── fan_in/ # Result aggregation
|
||||
└── __init__.py # CLI commands: specify workflow run/resume/status/
|
||||
# list/add/remove/search/info,
|
||||
# specify workflow catalog list/add/remove
|
||||
```
|
||||
285
workflows/PUBLISHING.md
Normal file
285
workflows/PUBLISHING.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Workflow Publishing Guide
|
||||
|
||||
This guide explains how to publish your workflow to the Spec Kit workflow catalog, making it discoverable by `specify workflow search`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Prepare Your Workflow](#prepare-your-workflow)
|
||||
3. [Submit to Catalog](#submit-to-catalog)
|
||||
4. [Verification Process](#verification-process)
|
||||
5. [Release Workflow](#release-workflow)
|
||||
6. [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before publishing a workflow, ensure you have:
|
||||
|
||||
1. **Valid Workflow**: A working `workflow.yml` that passes `specify workflow run` validation
|
||||
2. **Git Repository**: Workflow hosted on GitHub (or other public git hosting)
|
||||
3. **Documentation**: README.md with description, inputs, and step graph
|
||||
4. **License**: Open source license file (MIT, Apache 2.0, etc.)
|
||||
5. **Versioning**: Semantic versioning in the `workflow.version` field
|
||||
6. **Testing**: Workflow tested on real projects
|
||||
|
||||
---
|
||||
|
||||
## Prepare Your Workflow
|
||||
|
||||
### 1. Workflow Structure
|
||||
|
||||
Host your workflow in a repository with this structure:
|
||||
|
||||
```text
|
||||
your-workflow/
|
||||
├── workflow.yml # Required: Workflow definition
|
||||
├── README.md # Required: Documentation
|
||||
├── LICENSE # Required: License file
|
||||
└── CHANGELOG.md # Recommended: Version history
|
||||
```
|
||||
|
||||
### 2. workflow.yml Validation
|
||||
|
||||
Verify your definition is valid:
|
||||
|
||||
```yaml
|
||||
schema_version: "1.0"
|
||||
|
||||
workflow:
|
||||
id: "your-workflow" # Unique lowercase-hyphenated ID
|
||||
name: "Your Workflow Name" # Human-readable name
|
||||
version: "1.0.0" # Semantic version
|
||||
author: "Your Name or Organization"
|
||||
description: "Brief description (one sentence)"
|
||||
integration: claude # Default integration (optional)
|
||||
model: "claude-sonnet-4-20250514" # Default model (optional)
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.6.1"
|
||||
integrations:
|
||||
any: ["claude", "gemini"] # At least one required
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
type: string
|
||||
required: true
|
||||
prompt: "Describe what you want to build"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
|
||||
steps:
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review
|
||||
type: gate
|
||||
message: "Review the output."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
```
|
||||
|
||||
**Validation Checklist**:
|
||||
|
||||
- ✅ `id` is lowercase alphanumeric with hyphens (single-character IDs are allowed)
|
||||
- ✅ `version` follows semantic versioning (X.Y.Z)
|
||||
- ✅ `description` is concise
|
||||
- ✅ All step IDs are unique
|
||||
- ✅ Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in`
|
||||
- ✅ Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`)
|
||||
- ✅ Input types are valid: `string`, `number`, `boolean`
|
||||
- ✅ Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`)
|
||||
|
||||
### 3. Test Locally
|
||||
|
||||
```bash
|
||||
# Run with required inputs
|
||||
specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support"
|
||||
|
||||
# Check validation
|
||||
specify workflow info ./workflow.yml
|
||||
|
||||
# Resume after a gate pause
|
||||
specify workflow resume <run_id>
|
||||
|
||||
# Check run status
|
||||
specify workflow status <run_id>
|
||||
```
|
||||
|
||||
### 4. Create GitHub Release
|
||||
|
||||
Create a GitHub release for your workflow version:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
The raw YAML URL will be:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml
|
||||
```
|
||||
|
||||
### 5. Test Installation from URL
|
||||
|
||||
```bash
|
||||
specify workflow add your-workflow
|
||||
# (once published to catalog)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Submit to Catalog
|
||||
|
||||
### Understanding the Catalogs
|
||||
|
||||
Spec Kit uses a dual-catalog system:
|
||||
|
||||
- **`catalog.json`** — Official, verified workflows (install allowed by default)
|
||||
- **`catalog.community.json`** — Community-contributed workflows (discovery only by default)
|
||||
|
||||
All community workflows should be submitted to `catalog.community.json`.
|
||||
|
||||
### 1. Fork the spec-kit Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/spec-kit.git
|
||||
cd spec-kit
|
||||
```
|
||||
|
||||
### 2. Add Workflow to Community Catalog
|
||||
|
||||
Edit `workflows/catalog.community.json` and add your workflow.
|
||||
|
||||
> **⚠️ Entries must be sorted alphabetically by workflow ID.** Insert your workflow in the correct position within the `"workflows"` object.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
|
||||
"workflows": {
|
||||
"your-workflow": {
|
||||
"id": "your-workflow",
|
||||
"name": "Your Workflow Name",
|
||||
"description": "Brief description of what your workflow automates",
|
||||
"author": "Your Name",
|
||||
"version": "1.0.0",
|
||||
"url": "https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml",
|
||||
"repository": "https://github.com/your-org/spec-kit-workflow-your-workflow",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.15.0"
|
||||
},
|
||||
"tags": [
|
||||
"category",
|
||||
"automation"
|
||||
],
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Submit Pull Request
|
||||
|
||||
```bash
|
||||
git checkout -b add-your-workflow
|
||||
git add workflows/catalog.community.json
|
||||
git commit -m "Add your-workflow to community catalog
|
||||
|
||||
- Workflow ID: your-workflow
|
||||
- Version: 1.0.0
|
||||
- Author: Your Name
|
||||
- Description: Brief description
|
||||
"
|
||||
git push origin add-your-workflow
|
||||
```
|
||||
|
||||
**Pull Request Checklist**:
|
||||
|
||||
```markdown
|
||||
## Workflow Submission
|
||||
|
||||
**Workflow Name**: Your Workflow Name
|
||||
**Workflow ID**: your-workflow
|
||||
**Version**: 1.0.0
|
||||
**Repository**: https://github.com/your-org/spec-kit-workflow-your-workflow
|
||||
|
||||
### Checklist
|
||||
- [ ] Valid workflow.yml (passes `specify workflow info`)
|
||||
- [ ] README.md with description, inputs, and step graph
|
||||
- [ ] LICENSE file included
|
||||
- [ ] GitHub release created with raw YAML URL
|
||||
- [ ] Workflow tested end-to-end with `specify workflow run`
|
||||
- [ ] All gate steps have clear review messages
|
||||
- [ ] Input prompts are descriptive
|
||||
- [ ] Added to workflows/catalog.community.json (alphabetical order)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Process
|
||||
|
||||
After submission, maintainers will review:
|
||||
|
||||
1. **Definition validation** — valid `workflow.yml`, correct schema
|
||||
2. **Step correctness** — all step types used correctly, no dangling references
|
||||
3. **Input design** — clear prompts, sensible defaults and enums
|
||||
4. **Security** — no malicious shell commands, safe operations
|
||||
5. **Documentation** — clear README explaining what the workflow does and when to use it
|
||||
|
||||
Once verified, the workflow appears in `specify workflow search`.
|
||||
|
||||
---
|
||||
|
||||
## Release Workflow
|
||||
|
||||
When releasing a new version:
|
||||
|
||||
1. Update `version` in `workflow.yml`
|
||||
2. Update CHANGELOG.md
|
||||
3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0`
|
||||
4. Submit PR to update `version` and `url` in `workflows/catalog.community.json`
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Step Design
|
||||
|
||||
- **Use gates at decision points** — place `gate` steps after each major output so users can review before proceeding
|
||||
- **Keep steps focused** — each step should do one thing; prefer more steps over complex single steps
|
||||
- **Provide clear gate messages** — explain what to review and what approve/reject means
|
||||
|
||||
### Inputs
|
||||
|
||||
- **Use descriptive prompts** — the `prompt` field is shown to users when running the workflow
|
||||
- **Set sensible defaults** — optional inputs should have defaults that work for the common case
|
||||
- **Constrain with enums** — when there's a fixed set of valid values, use `enum` for validation
|
||||
- **Type appropriately** — use `number` for counts, `boolean` for flags, `string` for names
|
||||
|
||||
### Shell Steps
|
||||
|
||||
- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate
|
||||
- **Quote variables** — use proper quoting in shell commands to handle spaces
|
||||
- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust
|
||||
|
||||
### Integration Flexibility
|
||||
|
||||
- **Set `integration` at workflow level** — use the `workflow.integration` field as the default
|
||||
- **Allow per-step overrides** — let individual steps specify a different integration if needed
|
||||
- **Document required integrations** — list which integrations must be installed in `requires.integrations`
|
||||
|
||||
### Expression References
|
||||
|
||||
- **Only reference prior steps** — expressions like `{{ steps.plan.output.file }}` only work if `plan` ran before the current step
|
||||
- **Use `default` filter** — `{{ val | default('fallback') }}` prevents failures from missing values
|
||||
- **Keep expressions simple** — complex logic should be in shell steps, not expressions
|
||||
339
workflows/README.md
Normal file
339
workflows/README.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Workflows
|
||||
|
||||
Workflows are multi-step, resumable automation pipelines defined in YAML. They orchestrate Spec Kit commands across integrations, evaluate control flow, and pause at human review gates — enabling end-to-end Spec-Driven Development cycles without manual step-by-step invocation.
|
||||
|
||||
## How It Works
|
||||
|
||||
A workflow definition declares a sequence of steps. The engine executes them in order, dispatching commands to AI integrations, running shell commands, evaluating conditions for branching, and pausing at gates for human review. State is persisted after each step, so workflows can be resumed after interruption.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review
|
||||
type: gate
|
||||
message: "Review the spec before planning."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: plan
|
||||
command: speckit.plan
|
||||
```
|
||||
|
||||
For detailed architecture and internals, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Search available workflows
|
||||
specify workflow search
|
||||
|
||||
# Install the built-in SDD workflow
|
||||
specify workflow add speckit
|
||||
|
||||
# Or run directly from a local YAML file
|
||||
specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support"
|
||||
|
||||
# Run an installed workflow with inputs
|
||||
specify workflow run speckit --input spec="Build a user authentication system with OAuth support"
|
||||
|
||||
# Check run status
|
||||
specify workflow status
|
||||
|
||||
# Resume after a gate pause
|
||||
specify workflow resume <run_id>
|
||||
|
||||
# Get detailed workflow info
|
||||
specify workflow info speckit
|
||||
|
||||
# Remove a workflow
|
||||
specify workflow remove speckit
|
||||
```
|
||||
|
||||
## Running Workflows
|
||||
|
||||
### From an Installed Workflow
|
||||
|
||||
```bash
|
||||
specify workflow add speckit
|
||||
specify workflow run speckit --input spec="Build a user authentication system with OAuth support"
|
||||
```
|
||||
|
||||
### From a Local YAML File
|
||||
|
||||
```bash
|
||||
specify workflow run ./my-workflow.yml --input spec="Build a user authentication system with OAuth support"
|
||||
```
|
||||
|
||||
### Multiple Inputs
|
||||
|
||||
```bash
|
||||
specify workflow run speckit \
|
||||
--input spec="Build a user authentication system with OAuth support" \
|
||||
--input scope="backend-only"
|
||||
```
|
||||
|
||||
## Step Types
|
||||
|
||||
Workflows support 10 built-in step types:
|
||||
|
||||
### Command Steps (default)
|
||||
|
||||
Invoke an installed Spec Kit command by name via the integration CLI:
|
||||
|
||||
```yaml
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
integration: claude # Optional: override workflow default
|
||||
model: "claude-sonnet-4-20250514" # Optional: override model
|
||||
```
|
||||
|
||||
### Prompt Steps
|
||||
|
||||
Send an arbitrary inline prompt to an integration CLI (no command file needed):
|
||||
|
||||
```yaml
|
||||
- id: security-review
|
||||
type: prompt
|
||||
prompt: "Review {{ inputs.file }} for security vulnerabilities"
|
||||
integration: claude
|
||||
```
|
||||
|
||||
### Shell Steps
|
||||
|
||||
Run a shell command and capture output:
|
||||
|
||||
```yaml
|
||||
- id: run-tests
|
||||
type: shell
|
||||
run: "cd {{ inputs.project_dir }} && npm test"
|
||||
```
|
||||
|
||||
### Gate Steps
|
||||
|
||||
Pause for human review. The workflow resumes when `specify workflow resume` is called:
|
||||
|
||||
```yaml
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec before planning."
|
||||
options: [approve, edit, reject]
|
||||
on_reject: abort
|
||||
```
|
||||
|
||||
### If/Then/Else Steps
|
||||
|
||||
Conditional branching based on an expression:
|
||||
|
||||
```yaml
|
||||
- id: check-scope
|
||||
type: if
|
||||
condition: "{{ inputs.scope == 'full' }}"
|
||||
then:
|
||||
- id: full-plan
|
||||
command: speckit.plan
|
||||
else:
|
||||
- id: quick-plan
|
||||
command: speckit.plan
|
||||
options:
|
||||
quick: true
|
||||
```
|
||||
|
||||
### Switch Steps
|
||||
|
||||
Multi-branch dispatch on an expression value:
|
||||
|
||||
```yaml
|
||||
- id: route
|
||||
type: switch
|
||||
expression: "{{ steps.review.output.choice }}"
|
||||
cases:
|
||||
approve:
|
||||
- id: plan
|
||||
command: speckit.plan
|
||||
reject:
|
||||
- id: log
|
||||
type: shell
|
||||
run: "echo 'Rejected'"
|
||||
default:
|
||||
- id: fallback
|
||||
type: gate
|
||||
message: "Unexpected choice"
|
||||
```
|
||||
|
||||
### While Loop Steps
|
||||
|
||||
Repeat steps while a condition is truthy:
|
||||
|
||||
```yaml
|
||||
- id: retry
|
||||
type: while
|
||||
condition: "{{ steps.run-tests.output.exit_code != 0 }}"
|
||||
max_iterations: 5
|
||||
steps:
|
||||
- id: fix
|
||||
command: speckit.implement
|
||||
```
|
||||
|
||||
### Do-While Loop Steps
|
||||
|
||||
Execute steps at least once, then repeat while condition holds:
|
||||
|
||||
```yaml
|
||||
- id: refine
|
||||
type: do-while
|
||||
condition: "{{ steps.review.output.choice == 'edit' }}"
|
||||
max_iterations: 3
|
||||
steps:
|
||||
- id: revise
|
||||
command: speckit.specify
|
||||
```
|
||||
|
||||
### Fan-Out Steps
|
||||
|
||||
Dispatch a step template for each item in a collection (sequential):
|
||||
|
||||
```yaml
|
||||
- id: parallel-impl
|
||||
type: fan-out
|
||||
items: "{{ steps.tasks.output.task_list }}"
|
||||
max_concurrency: 3
|
||||
step:
|
||||
id: impl
|
||||
command: speckit.implement
|
||||
```
|
||||
|
||||
### Fan-In Steps
|
||||
|
||||
Aggregate results from fan-out steps:
|
||||
|
||||
```yaml
|
||||
- id: collect
|
||||
type: fan-in
|
||||
wait_for: [parallel-impl]
|
||||
output: {}
|
||||
```
|
||||
|
||||
## Expressions
|
||||
|
||||
Workflow definitions use `{{ expression }}` syntax for dynamic values:
|
||||
|
||||
```yaml
|
||||
# Access inputs
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
# Access previous step outputs
|
||||
args: "{{ steps.specify.output.file }}"
|
||||
|
||||
# Comparisons
|
||||
condition: "{{ steps.run-tests.output.exit_code != 0 }}"
|
||||
|
||||
# Filters
|
||||
message: "{{ status | default('pending') }}"
|
||||
```
|
||||
|
||||
Supported filters: `default`, `join`, `contains`, `map`.
|
||||
|
||||
## Input Types
|
||||
|
||||
Workflow inputs are type-checked and coerced from CLI string values:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
spec:
|
||||
type: string
|
||||
required: true
|
||||
prompt: "Describe what you want to build"
|
||||
task_count:
|
||||
type: number
|
||||
default: 5
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: false
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
```
|
||||
|
||||
| Type | Accepts | Example |
|
||||
|------|---------|---------|
|
||||
| `string` | Any string | `"user-auth"` |
|
||||
| `number` | Numeric strings → int/float | `"42"` → `42` |
|
||||
| `boolean` | `true`/`1`/`yes` → `True`, `false`/`0`/`no` → `False` | `"true"` → `True` |
|
||||
|
||||
## State and Resume
|
||||
|
||||
Every workflow run persists state to `.specify/workflows/runs/<run_id>/`:
|
||||
|
||||
```bash
|
||||
# List all runs with status
|
||||
specify workflow status
|
||||
|
||||
# Check a specific run
|
||||
specify workflow status <run_id>
|
||||
|
||||
# Resume a paused run (after approving a gate)
|
||||
specify workflow resume <run_id>
|
||||
|
||||
# Resume a failed run (retries from the failed step)
|
||||
specify workflow resume <run_id>
|
||||
```
|
||||
|
||||
Run states: `created` → `running` → `completed` | `paused` | `failed` | `aborted`
|
||||
|
||||
## Catalog Management
|
||||
|
||||
Workflows are discovered through catalogs. By default, Spec Kit uses the official and community catalogs:
|
||||
|
||||
> [!NOTE]
|
||||
> Community workflows are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting and structure, but they do **not review, audit, endorse, or support the workflow definitions themselves**. Review workflow source before installation and use at your own discretion.
|
||||
|
||||
```bash
|
||||
# List active catalogs
|
||||
specify workflow catalog list
|
||||
|
||||
# Add a custom catalog
|
||||
specify workflow catalog add https://example.com/catalog.json --name my-org
|
||||
|
||||
# Remove a catalog
|
||||
specify workflow catalog remove <index>
|
||||
```
|
||||
|
||||
## Creating a Workflow
|
||||
|
||||
1. Create a `workflow.yml` following the schema above
|
||||
2. Test locally with `specify workflow run ./workflow.yml --input key=value`
|
||||
3. Verify with `specify workflow info ./workflow.yml`
|
||||
4. See [PUBLISHING.md](PUBLISHING.md) to submit to the catalog
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
|
||||
|
||||
## Configuration Files
|
||||
|
||||
| File | Scope | Description |
|
||||
|------|-------|-------------|
|
||||
| `.specify/workflow-catalogs.yml` | Project | Custom catalog stack for this project |
|
||||
| `~/.specify/workflow-catalogs.yml` | User | Custom catalog stack for all projects |
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
workflows/
|
||||
├── ARCHITECTURE.md # Internal architecture documentation
|
||||
├── PUBLISHING.md # Guide for submitting workflows to the catalog
|
||||
├── README.md # This file
|
||||
├── catalog.json # Official workflow catalog
|
||||
├── catalog.community.json # Community workflow catalog
|
||||
└── speckit/ # Built-in SDD cycle workflow
|
||||
└── workflow.yml
|
||||
```
|
||||
6
workflows/catalog.community.json
Normal file
6
workflows/catalog.community.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
|
||||
"workflows": {}
|
||||
}
|
||||
16
workflows/catalog.json
Normal file
16
workflows/catalog.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-13T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json",
|
||||
"workflows": {
|
||||
"speckit": {
|
||||
"id": "speckit",
|
||||
"name": "Full SDD Cycle",
|
||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
||||
"author": "GitHub",
|
||||
"version": "1.0.0",
|
||||
"url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml",
|
||||
"tags": ["sdd", "full-cycle"]
|
||||
}
|
||||
}
|
||||
}
|
||||
63
workflows/speckit/workflow.yml
Normal file
63
workflows/speckit/workflow.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
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 }}"
|
||||
Reference in New Issue
Block a user